[
  {
    "path": ".dockerignore",
    "content": "# Python\n__pycache__/\n*.py[cod]\n*$py.class\n*.so\n.Python\n*.egg-info/\ndist/\nbuild/\n*.egg\n.venv/\nvenv/\nenv/\nENV/\n\n# Node\n**/node_modules/\nnode_modules/\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.pnpm-debug.log*\n.npm\n.eslintcache\nfrontend/node_modules/\nfrontend/dist/\nfrontend/.vite/\n\n# IDE\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n.DS_Store\n\n# Project specific\ninstance/*.db\ninstance/*.db-journal\nuploads/\n*.log\nserver.log\nserver_running.log\n\n# Test files\ntest_*.py\n*_test.py\ntests/\n\n# Docs and examples\n*.md\n!README.md\ndocs/\ndemo.py\ngemini_genai.py\ngenerate-example.py\n!assets/**\n*.png\n*.jpg\n*.jpeg\n*.pptx\n*.pdf\noutput/\nres.png\ntemplate*.png\npage*.png\n\n# Git\n.git/\n.gitignore\n\n# Docker\nDockerfile*\ndocker-compose*.yml\n.dockerignore\n\n# Environment variables\n.env\n.env.local\n.env.*.local\n\n# Others\n*.lock\nuv.lock\nLICENSE\nPRD.md\n*.md\n\n# Windows\nThumbs.db\nDesktop.ini\n*.lnk\n\n# Build output\ndist/\nbuild/\n*.pptx\n*.pdf\n\n"
  },
  {
    "path": ".githooks/pre-commit.disabled",
    "content": "#!/bin/bash\n# Pre-commit hook: 自动翻译README.md到README_EN.md\n# 只有在README.md被修改时才会触发\n\nset -e\n\n# 检查README.md是否在本次提交中被修改\nif git diff --cached --name-only | grep -q \"^README\\.md$\"; then\n    echo \"检测到README.md变更，正在自动翻译到README_EN.md...\"\n    \n    # 检查是否在项目根目录\n    if [ ! -f \"README.md\" ]; then\n        echo \"错误: 未找到README.md\"\n        exit 1\n    fi\n    \n    # 检查.env文件\n    if [ ! -f \".env\" ]; then\n        echo \"警告: 未找到.env文件，跳过翻译\"\n        echo \"如需自动翻译，请确保.env文件包含必要的API密钥配置\"\n        exit 0\n    fi\n    \n    # 加载环境变量\n    set -a\n    source .env 2>/dev/null || true\n    set +a\n    \n    # 检查必要的环境变量\n    if [ -z \"$GOOGLE_API_KEY\" ]; then\n        echo \"警告: GOOGLE_API_KEY未设置，跳过翻译\"\n        echo \"如需自动翻译，请在.env文件中设置GOOGLE_API_KEY\"\n        exit 0\n    fi\n    \n    # 检查uv是否可用\n    if ! command -v uv &> /dev/null; then\n        echo \"警告: uv未安装，跳过翻译\"\n        exit 0\n    fi\n    \n    # 运行翻译脚本\n    echo \"开始翻译...\"\n    if uv run python scripts/translate_readme.py; then\n        echo \"✅ 翻译成功！\"\n        \n        # 将翻译后的README_EN.md添加到本次提交\n        git add README_EN.md\n        echo \"README_EN.md已自动添加到本次提交\"\n    else\n        echo \"❌ 翻译失败，但不阻止提交\"\n        echo \"你可以稍后手动运行: ./scripts/test_translation.sh\"\n        # 不阻止提交，允许继续\n        exit 0\n    fi\nelse\n    # README.md未修改，无需翻译\n    exit 0\nfi\n\n"
  },
  {
    "path": ".github/CI_SETUP.md",
    "content": "# CI/CD 配置说明\n\n本项目使用GitHub Actions实现自动化CI/CD，包含**Light检查**和**Full测试**两个层级。\n\n## 📋 CI架构概览\n\n### 🚀 Light检查 - PR快速反馈\n**触发时机**: 提交PR时自动运行  \n**耗时**: 2-5分钟  \n**工作流**: `.github/workflows/pr-quick-check.yml`\n\n包含：\n- ✅ 代码语法检查（flake8, ESLint）\n- ✅ 代码格式检查（black, prettier）\n- ✅ TypeScript构建检查\n- ✅ 后端冒烟测试（健康检查）\n- ✅ PR自动评论\n\n### 🎯 Full测试 - 完整验证\n**触发时机**:\n1. **PR添加`ready-for-test`标签时** 👈 推荐方式\n2. 直接Push到`main`或`develop`分支（不通过PR）\n\n**注意**：PR合并后**不会**再次运行完整测试，避免重复浪费资源\n\n**耗时**: 15-30分钟  \n**工作流**: `.github/workflows/ci-test.yml`\n\n包含：\n- ✅ 后端单元测试（pytest + coverage）\n- ✅ 后端集成测试（使用 mock AI）\n- ✅ 前端测试（Vitest + coverage）\n- ✅ Docker 环境测试（容器构建、启动、健康检查）\n- ✅ **E2E 测试（从创建到导出 PPT）**\n  - 需要真实 Google Gemini API key\n  - 测试完整的 AI 生成流程\n  - 如果未配置 API key，会自动跳过并显示说明\n- ✅ 安全扫描（依赖漏洞检查）\n\n---\n\n## 🔧 配置步骤\n\n### 1. 配置GitHub Secrets（必需）\n\n为了运行完整的E2E测试（包含真实AI生成），需要配置以下Secrets：\n\n#### 步骤：\n1. 进入GitHub仓库页面\n2. 点击 `Settings` → `Secrets and variables` → `Actions`\n3. 点击 `New repository secret`\n4. 添加以下Secret：\n\n| Secret名称 | 必需 | 说明 | 获取方式 |\n|-----------|------|------|---------|\n| `GOOGLE_API_KEY` | ✅ 必需 | Google Gemini API密钥（用于完整E2E测试） | [https://aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey) |\n| `OPENAI_API_KEY` | ⚪ 可选 | OpenAI API密钥（用于集成测试验证兼容性） | [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys) |\n| `SECRET_KEY` | ⚪ 可选 | Flask应用密钥（生产环境建议配置） | 随机生成，建议使用：`python -c \"import secrets; print(secrets.token_hex(32))\"` |\n| `MINERU_TOKEN` | ⚪ 可选 | MinerU服务Token（如果使用MinerU解析） | 从MinerU服务获取 |\n\n**关于 E2E 测试策略**：\n- 💡 **单一 E2E 测试**：使用 Gemini 格式测试完整流程（创建→大纲→描述→图片→导出）\n- 💰 **成本优化**：只运行一次完整 E2E，避免重复测试\n- ⚠️ **条件运行**：只在配置了真实 `GOOGLE_API_KEY` 时运行\n\n**注意**：\n- ⚠️ **没有配置 `GOOGLE_API_KEY` 时，E2E 测试会被跳过**\n- ✅ 其他测试（单元、集成、Docker）仍会运行，覆盖大部分功能\n- 💰 真实 API 调用会消耗配额（约 $0.01-0.05/次），建议使用测试专用账号\n- 🔧 CI 会自动将 Secrets 替换到 `.env` 文件中对应的占位符\n\n**CI如何处理Secrets**：\n\nCI配置会自动处理以下逻辑：\n\n1. **复制`.env.example`到`.env`**（保持所有默认配置）\n2. **自动检测并替换Secrets**：\n   - 如果GitHub Secrets中配置了某个Secret → 自动替换`.env`中对应的占位符\n   - 如果没有配置 → 保持`.env.example`中的默认值\n\n**支持的Secrets列表**：\n\nCI配置会自动检测并替换以下Secrets（如果配置了的话）：\n\n- ✅ `GOOGLE_API_KEY` - 必需，如果没有配置则使用`mock-api-key`\n- ⚪ `OPENAI_API_KEY` - 可选，如果配置了则替换\n- ⚪ `SECRET_KEY` - 可选，生产环境建议配置\n- ⚪ `MINERU_TOKEN` - 可选，如果使用MinerU服务则配置\n\n**添加新的Secret支持**：\n\n如果需要支持其他配置项的Secret替换，只需在`.github/workflows/ci-test.yml`中添加对应的检查逻辑：\n\n```yaml\n# 在\"设置环境变量\"步骤中添加\nif [ -n \"${{ secrets.YOUR_NEW_SECRET }}\" ]; then\n  sed -i '/^YOUR_ENV_VAR=/s/placeholder/${{ secrets.YOUR_NEW_SECRET }}/' .env\n  echo \"✓ 已替换 YOUR_ENV_VAR\"\nfi\n```\n\n### 2. （可选）配置CodeCov\n\n如果需要代码覆盖率报告和徽章：\n\n1. 访问 [codecov.io](https://codecov.io)\n2. 关联GitHub账号并授权仓库\n3. 获取Upload Token（通常不需要，公开仓库自动识别）\n4. 如需手动配置，添加Secret：`CODECOV_TOKEN`\n\n---\n\n## 🏷️ 如何触发Full测试\n\n### 方法1：PR添加标签触发（✅ 推荐）\n\n当你认为PR已经准备好进行完整测试时：\n\n```bash\n# 在PR页面右侧，点击 \"Labels\"\n# 添加 \"ready-for-test\" 标签\n```\n\n这会立即触发完整测试套件，包括：\n- ✅ 所有单元和集成测试\n- ✅ Docker 环境测试\n- ✅ **E2E 测试（如果配置了真实 API key）**\n\n**测试通过后，直接合并即可！合并后不会重复运行测试。**\n\n**E2E 测试说明**：\n- 如果配置了 `GOOGLE_API_KEY`：运行完整 E2E（额外 10-15 分钟）\n- 如果未配置：跳过 E2E，显示友好说明（其他测试已覆盖大部分功能）\n\n### 方法2：手动触发（✅ 新增）\n\n在GitHub Actions页面手动运行Full Test：\n\n1. 进入仓库页面\n2. 点击 **Actions** 标签\n3. 在左侧选择 **Full Test Suite**\n4. 点击右侧的 **Run workflow** 按钮\n5. 选择分支（通常是`main`或`develop`）\n6. 点击 **Run workflow**\n\n**适用场景**：\n- ✅ 想在任何时候验证代码\n- ✅ 调试CI问题\n- ✅ 验证main分支的当前状态\n\n### 方法3：直接Push到main\n\n如果你直接push到`main`或`develop`分支（不通过PR），会自动运行完整测试。\n\n**注意**：\n- ⚠️ **PR合并不会触发Full测试**（避免重复）\n- ✅ 请确保PR在合并前已通过`ready-for-test`测试\n- 🔒 建议在仓库设置中启用分支保护，要求`ready-for-test`状态通过才能合并\n\n---\n\n## 🔒 建议：启用分支保护规则\n\n为了确保所有PR在合并前都经过完整测试，建议配置GitHub分支保护：\n\n### 配置步骤\n\n1. 进入仓库 → `Settings` → `Branches`\n2. 在 `Branch protection rules` 下点击 `Add rule`\n3. 配置如下：\n   - **Branch name pattern**: `main`\n   - ✅ **Require status checks to pass before merging**\n     - 搜索并勾选 `Backend Unit Tests`（或其他关键测试）\n   - ✅ **Require branches to be up to date before merging**\n   - 可选：**Require pull request reviews before merging**\n\n### 效果\n\n配置后，PR只有在以下条件满足时才能合并：\n- ✅ Light检查通过（自动运行）\n- ✅ Full测试通过（通过`ready-for-test`标签触发）\n- ✅ 代码review通过（如果启用）\n\n这样可以完全避免未测试代码进入`main`分支！\n\n---\n\n## 🧪 测试文件说明\n\n### Light检查测试\n- **前端Lint**: `frontend/src/**/*.{ts,tsx}`\n- **后端语法**: `backend/**/*.py`\n- **冒烟测试**: 启动后端并检查`/health`端点\n\n### Full测试文件\n```\nbackend/tests/\n├── unit/              # 后端单元测试\n│   ├── test_ai_service.py\n│   ├── test_file_service.py\n│   └── ...\n├── integration/       # 后端集成测试\n│   ├── test_api.py\n│   └── ...\n\nfrontend/src/\n├── **/*.test.tsx     # 前端组件测试\n└── **/*.spec.tsx     # 前端功能测试\n\ne2e/\n├── home.spec.ts           # 首页UI测试\n├── create-ppt.spec.ts     # PPT创建基础测试\n└── full-flow.spec.ts      # 🎯 完整流程测试（创建→大纲→描述→图片→导出）\n```\n\n---\n\n## 📊 测试结果查看\n\n### CI状态检查\n- PR页面底部会显示所有检查状态\n- 点击 `Details` 查看详细日志\n- Light检查会在PR评论中自动发布结果\n\n### 测试报告和覆盖率\n- **代码覆盖率**: 自动上传到CodeCov（如果配置）\n- **E2E测试报告**: 失败时会上传Playwright报告和截图\n  - 在Actions页面 → 对应的workflow run → `Artifacts` 下载\n  - `playwright-report`: HTML测试报告\n  - `playwright-screenshots`: 失败时的截图和视频\n\n### 查看日志\n```bash\n# 本地查看Actions日志\ngh run list\ngh run view <run-id> --log\n```\n\n---\n\n## 🚨 常见问题\n\n### Q1: E2E测试超时失败\n**原因**: AI生成需要较长时间  \n**解决**: \n- 检查API key是否有效\n- 检查API配额是否用尽\n- 本地运行测试验证：`npx playwright test full-flow.spec.ts`\n\n### Q2: Docker测试失败\n**原因**: 容器启动超时或端口冲突  \n**解决**:\n- 检查`docker-compose.yml`配置\n- 查看容器日志（CI会在失败时自动显示）\n- 本地测试：`./scripts/test_docker_environment.sh`\n\n### Q3: 前端构建失败\n**原因**: TypeScript类型错误或依赖问题  \n**解决**:\n- 本地运行：`cd frontend && npm run build:check`\n- 检查`frontend/package.json`依赖版本\n- 确保`package-lock.json`已提交\n\n### Q4: \"ready-for-test\"标签不触发测试\n**原因**: Workflow权限或配置问题  \n**解决**:\n- 确认标签名称完全匹配（小写，带连字符）\n- 检查仓库Settings → Actions → General → Workflow permissions\n- 查看Actions页面确认workflow是否被触发\n\n---\n\n## 📝 本地测试\n\n### 🚀 快速开始\n\n```bash\n# Light检查（2-3分钟）- 提交前快速检查\n./scripts/run-local-ci.sh light\n\n# Full测试（10-20分钟）- PR合并前完整测试\n./scripts/run-local-ci.sh full\n```\n\n### 🔧 前置依赖\n\n```bash\n# Python环境 (>= 3.10)\npython3 --version\n\n# Node.js环境 (>= 18)\nnode --version\n\n# UV包管理器\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n\n# Docker\ndocker --version\ndocker compose --version\n\n# 安装依赖\nuv sync --extra test\ncd frontend && npm ci\nnpx playwright install --with-deps chromium\n```\n\n### 🧪 运行特定测试\n\n```bash\n# 后端单元测试\ncd backend\nuv run pytest tests/unit -v --cov=. --cov-report=html\n\n# 前端测试\ncd frontend\nnpm test -- --coverage\n\n# E2E测试（需要真实API key）\ncp .env.example .env  # 编辑.env填入真实API密钥\ndocker compose up -d\nnpx playwright test full-flow.spec.ts\n\n# Docker环境测试\n./scripts/test_docker_environment.sh\n```\n\n### 🐛 调试失败的测试\n\n```bash\n# E2E UI模式调试\nnpx playwright test --ui\n\n# 后端调试模式\ncd backend\nuv run pytest tests/unit/test_xxx.py --pdb\n\n# 查看Docker日志\ndocker compose logs backend\ndocker compose logs frontend\n```\n\n---\n\n## 🎯 最佳实践\n\n### 开发流程建议\n\n1. **开发阶段**：\n   - 频繁提交小改动\n   - 依赖Light检查快速反馈\n   - 修复lint和构建错误\n\n2. **功能完成后**：\n   - 自测主要功能\n   - 运行本地测试套件\n   - 提交PR\n\n3. **准备合并前**：\n   - 添加`ready-for-test`标签 👈 **关键步骤**\n   - 等待Full测试通过\n   - Code review通过后合并\n   - 合并后**不会重复运行测试**，节省资源 ✅\n\n4. **合并后**：\n   - 代码直接进入`main`分支\n   - 无需等待额外的CI运行\n   - 节省时间和成本\n\n### CI优化建议\n\n- ✅ 保持测试快速（单元测试 < 5分钟）\n- ✅ E2E测试只验证关键流程\n- ✅ 使用缓存加速依赖安装\n- ✅ 并行运行独立测试\n- ✅ 失败快速反馈（fail-fast）\n\n---\n\n## 📚 相关文档\n\n- [GitHub Actions文档](https://docs.github.com/en/actions)\n- [Playwright测试文档](https://playwright.dev)\n- [pytest文档](https://docs.pytest.org)\n- [Vitest文档](https://vitest.dev)\n\n---\n\n## 🆘 需要帮助？\n\n如果遇到CI问题：\n1. 查看Actions日志详细错误信息\n2. 参考本文档常见问题部分\n3. 在issue中提问并附上错误日志\n4. 联系维护者\n\n---\n\n**最后更新**: 2025-01-20  \n**维护者**: Banana Slides Team\n\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: Bug Report / 问题反馈\ndescription: Report a bug or issue / 报告错误或问题\nlabels: [\"bug\"]\nbody:\n  - type: dropdown\n    id: deployment\n    attributes:\n      label: Deployment Method / 部署方式\n      description: Where did you encounter this issue? / 你在哪里遇到了这个问题？\n      options:\n        - Demo Website / 在线 Demo (bananaslides.online)\n        - Docker Compose (docker-compose.yml)\n        - Docker Compose with Pre-built Images (docker-compose.prod.yml)\n        - Local Development / 本地开发 (uv + npm)\n        - Cloud Platform / 云平台部署 (雨云等)\n        - Other / 其他\n    validations:\n      required: true\n\n  - type: textarea\n    id: description\n    attributes:\n      label: Issue Description / 问题描述\n      description: Describe the issue you encountered / 描述你遇到的问题\n      placeholder: What happened? / 发生了什么？\n    validations:\n      required: true\n\n  - type: textarea\n    id: steps\n    attributes:\n      label: Steps to Reproduce / 复现步骤\n      description: How can we reproduce this issue? / 如何复现这个问题？\n      placeholder: |\n        1. Go to... / 进入...\n        2. Click on... / 点击...\n        3. See error / 看到错误\n    validations:\n      required: false\n\n  - type: textarea\n    id: expected\n    attributes:\n      label: Expected Behavior / 期望行为\n      description: What did you expect to happen? / 你期望发生什么？\n    validations:\n      required: false\n\n  - type: textarea\n    id: logs\n    attributes:\n      label: Logs / 日志\n      description: If applicable, paste relevant logs (docker logs, browser console, etc.) / 如果适用，粘贴相关日志\n      render: shell\n    validations:\n      required: false\n\n  - type: input\n    id: version\n    attributes:\n      label: Version / 版本\n      description: Which version are you using? / 你使用的是哪个版本？\n      placeholder: v0.4.0 or commit hash / v0.4.0 或 commit hash\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "## Summary\n\n<!-- Briefly describe the changes in this PR -->\n\n## Changed Files\n\n<!-- List the key files changed and what was modified -->\n\n## Test Plan\n\n<!-- Describe how you tested the changes -->\n\n## CLA\n\n- [ ] I have read the [Contributor License Agreement](CLA.md) and [Contributing Guidelines](CONTRIBUTING.md), and I agree to the CLA\n"
  },
  {
    "path": ".github/workflows/build-sha-image.yml",
    "content": "name: Build SHA Image\n\non:\n  workflow_dispatch:\n    inputs:\n      sha:\n        description: 'Git SHA to build (full or short)'\n        required: true\n        type: string\n      image_type:\n        description: 'Which images to build'\n        required: true\n        type: choice\n        options:\n          - allinone\n          - split\n          - both\n        default: allinone\n      tag:\n        description: 'Additional Docker tag (e.g. latest). Leave empty to only tag with SHA.'\n        required: false\n        type: string\n      run_tests:\n        description: 'Run unit tests before building'\n        required: false\n        type: boolean\n        default: false\n\nconcurrency:\n  group: build-sha-${{ inputs.sha }}\n  cancel-in-progress: false\n\njobs:\n  build-and-push:\n    name: Build ${{ inputs.image_type }} image(s) at SHA\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Checkout target SHA\n        run: git checkout ${{ inputs.sha }}\n\n      - name: Resolve SHA\n        id: resolve\n        run: |\n          echo \"full_sha=$(git rev-parse HEAD)\" >> $GITHUB_OUTPUT\n          echo \"short_sha=$(git rev-parse --short HEAD)\" >> $GITHUB_OUTPUT\n\n      - name: Overlay Dockerfiles from main\n        run: |\n          git fetch origin main\n          if [[ \"${{ inputs.image_type }}\" != \"split\" ]]; then\n            git checkout origin/main -- Dockerfile.allinone docker/\n          fi\n          if [[ \"${{ inputs.image_type }}\" != \"allinone\" ]]; then\n            git checkout origin/main -- backend/Dockerfile frontend/Dockerfile frontend/nginx.conf\n          fi\n\n      - name: Install uv\n        if: ${{ inputs.run_tests }}\n        run: |\n          curl -LsSf https://astral.sh/uv/install.sh | sh\n          echo \"$HOME/.cargo/bin\" >> $GITHUB_PATH\n\n      - name: Run backend unit tests\n        if: ${{ inputs.run_tests }}\n        run: |\n          uv sync --extra test\n          uv run pytest backend/tests/unit -v\n\n      - name: Run frontend lint + tests\n        if: ${{ inputs.run_tests }}\n        run: |\n          cd frontend && npm ci\n          npm run lint\n          npm test -- --run\n\n      - uses: docker/setup-qemu-action@v3\n      - uses: docker/setup-buildx-action@v3\n\n      - name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Compute tags\n        id: tags\n        env:\n          USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}\n          SHORT_SHA: ${{ steps.resolve.outputs.short_sha }}\n          CUSTOM_TAG: ${{ inputs.tag }}\n          IMAGE_TYPE: ${{ inputs.image_type }}\n        run: |\n          compute_tags() {\n            local image=\"$1\"\n            local tags=\"${USERNAME}/${image}:sha-${SHORT_SHA}\"\n            if [ -n \"$CUSTOM_TAG\" ]; then\n              tags=\"${tags},${USERNAME}/${image}:${CUSTOM_TAG}\"\n            fi\n            echo \"$tags\"\n          }\n          if [[ \"$IMAGE_TYPE\" != \"split\" ]]; then\n            ALLINONE_TAGS=$(compute_tags \"banana-slides\")\n            echo \"allinone=$ALLINONE_TAGS\" >> $GITHUB_OUTPUT\n            echo \"All-in-one tags: $ALLINONE_TAGS\"\n          fi\n          if [[ \"$IMAGE_TYPE\" != \"allinone\" ]]; then\n            BACKEND_TAGS=$(compute_tags \"banana-slides-backend\")\n            FRONTEND_TAGS=$(compute_tags \"banana-slides-frontend\")\n            echo \"backend=$BACKEND_TAGS\" >> $GITHUB_OUTPUT\n            echo \"frontend=$FRONTEND_TAGS\" >> $GITHUB_OUTPUT\n            echo \"Backend tags: $BACKEND_TAGS\"\n            echo \"Frontend tags: $FRONTEND_TAGS\"\n          fi\n\n      - name: Build and push all-in-one image\n        if: inputs.image_type == 'allinone' || inputs.image_type == 'both'\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile.allinone\n          push: true\n          platforms: linux/amd64,linux/arm64\n          tags: ${{ steps.tags.outputs.allinone }}\n          cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides:buildcache\n          cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides:buildcache,mode=max\n          build-args: |\n            DOCKER_REGISTRY=${{ secrets.DOCKER_REGISTRY }}\n            GHCR_REGISTRY=${{ secrets.GHCR_REGISTRY || 'ghcr.io/' }}\n            APT_MIRROR=${{ secrets.APT_MIRROR }}\n            PYPI_INDEX_URL=${{ secrets.PYPI_INDEX_URL }}\n            NPM_REGISTRY=${{ secrets.NPM_REGISTRY }}\n\n      - name: Build and push backend image\n        if: inputs.image_type == 'split' || inputs.image_type == 'both'\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./backend/Dockerfile\n          push: true\n          platforms: linux/amd64,linux/arm64\n          tags: ${{ steps.tags.outputs.backend }}\n          cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-backend:buildcache\n          cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-backend:buildcache,mode=max\n          build-args: |\n            DOCKER_REGISTRY=${{ secrets.DOCKER_REGISTRY }}\n            GHCR_REGISTRY=${{ secrets.GHCR_REGISTRY || 'ghcr.io/' }}\n            APT_MIRROR=${{ secrets.APT_MIRROR }}\n            PYPI_INDEX_URL=${{ secrets.PYPI_INDEX_URL }}\n\n      - name: Build and push frontend image\n        if: inputs.image_type == 'split' || inputs.image_type == 'both'\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./frontend/Dockerfile\n          push: true\n          platforms: linux/amd64,linux/arm64\n          tags: ${{ steps.tags.outputs.frontend }}\n          cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-frontend:buildcache\n          cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-frontend:buildcache,mode=max\n          build-args: |\n            DOCKER_BUILDKIT=1\n            DOCKER_REGISTRY=${{ secrets.DOCKER_REGISTRY }}\n            NPM_REGISTRY=${{ secrets.NPM_REGISTRY }}\n"
  },
  {
    "path": ".github/workflows/ci-test.yml",
    "content": "name: CI Tests\n\n# Push to main/develop (excluding docs-only changes) or manual trigger\non:\n  push:\n    branches: [ main, develop ]\n    paths-ignore:\n      - '**/*.md'\n      - 'docs/**'\n  workflow_dispatch:\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\nenv:\n  UV_INDEX_URL: https://pypi.org/simple\n\njobs:\n  backend-test:\n    name: Backend Tests\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: '3.10'\n\n      - name: Install uv\n        run: |\n          curl -LsSf https://astral.sh/uv/install.sh | sh\n          echo \"$HOME/.cargo/bin\" >> $GITHUB_PATH\n\n      - name: Install dependencies\n        run: uv sync --extra test\n\n      - name: Unit tests\n        run: uv run pytest backend/tests/unit -v --cov=backend --cov-report=xml\n\n      - name: Integration tests\n        run: uv run pytest backend/tests/integration -v -m \"not requires_service\"\n        env:\n          TESTING: true\n          GOOGLE_API_KEY: mock-api-key-for-testing\n\n      - name: Upload coverage\n        uses: codecov/codecov-action@v4\n        with:\n          file: ./backend/coverage.xml\n          flags: backend\n\n  frontend-test:\n    name: Frontend Tests\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '18'\n          cache: 'npm'\n          cache-dependency-path: frontend/package-lock.json\n\n      - run: cd frontend && npm ci\n      - name: Lint\n        run: cd frontend && npm run lint\n      - name: Unit tests\n        run: cd frontend && npm test -- --run --coverage\n      - name: Build check\n        run: cd frontend && npm run build\n\n      - name: Upload coverage\n        uses: codecov/codecov-action@v4\n        with:\n          file: ./frontend/coverage/coverage-final.json\n          flags: frontend\n"
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "content": "name: Release\n\n# 手动触发，输入版本号\non:\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'Release version (e.g. v0.3.1)'\n        required: true\n        type: string\n\npermissions:\n  contents: write\n\nconcurrency:\n  group: ${{ github.workflow }}\n  cancel-in-progress: false\n\nenv:\n  UV_INDEX_URL: https://pypi.org/simple\n\njobs:\n  validate:\n    name: Validate Version Format\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Check version format\n        env:\n          VERSION: ${{ inputs.version }}\n        run: |\n          if [[ ! \"$VERSION\" =~ ^v[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then\n            echo \"::error::Version must match vX.Y.Z format (got: $VERSION)\"\n            exit 1\n          fi\n\n      - name: Check tag does not exist\n        env:\n          VERSION: ${{ inputs.version }}\n        run: |\n          if git rev-parse \"$VERSION\" >/dev/null 2>&1; then\n            echo \"::error::Tag $VERSION already exists. Aborting to prevent duplicate release.\"\n            exit 1\n          fi\n\n  test:\n    name: Pre-release Tests\n    needs: validate\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-python@v5\n        with:\n          python-version: '3.10'\n\n      - name: Install uv\n        run: |\n          curl -LsSf https://astral.sh/uv/install.sh | sh\n          echo \"$HOME/.cargo/bin\" >> $GITHUB_PATH\n\n      - name: Install backend dependencies\n        run: uv sync --extra test\n\n      - name: Backend unit tests\n        run: uv run pytest backend/tests/unit -v\n\n      - name: Backend integration tests\n        run: uv run pytest backend/tests/integration -v -m \"not requires_service\"\n        env:\n          TESTING: true\n          GOOGLE_API_KEY: mock-api-key-for-testing\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '18'\n          cache: 'npm'\n          cache-dependency-path: frontend/package-lock.json\n\n      - name: Install frontend dependencies\n        run: cd frontend && npm ci\n\n      - name: Frontend lint\n        run: cd frontend && npm run lint\n\n      - name: Frontend tests\n        run: cd frontend && npm test -- --run\n\n      - name: Frontend build check\n        run: cd frontend && npm run build\n\n  e2e-test:\n    name: E2E Tests\n    needs: test\n    runs-on: ubuntu-latest\n    env:\n      GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Setup environment\n        run: |\n          chmod +x scripts/setup-env-from-secrets.sh\n          ./scripts/setup-env-from-secrets.sh\n          sed -i 's/^AI_PROVIDER_FORMAT=.*/AI_PROVIDER_FORMAT=gemini/' .env || echo \"AI_PROVIDER_FORMAT=gemini\" >> .env\n        env:\n          AI_PROVIDER_FORMAT: gemini\n          GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}\n          GOOGLE_API_BASE: ${{ secrets.GOOGLE_API_BASE }}\n          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n          OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}\n          OPENAI_TIMEOUT: ${{ secrets.OPENAI_TIMEOUT }}\n          OPENAI_MAX_RETRIES: ${{ secrets.OPENAI_MAX_RETRIES }}\n          TEXT_MODEL: ${{ secrets.TEXT_MODEL }}\n          IMAGE_MODEL: ${{ secrets.IMAGE_MODEL }}\n          LOG_LEVEL: ${{ secrets.LOG_LEVEL }}\n          FLASK_ENV: ${{ secrets.FLASK_ENV }}\n          SECRET_KEY: ${{ secrets.SECRET_KEY }}\n          BACKEND_PORT: ${{ secrets.BACKEND_PORT }}\n          CORS_ORIGINS: ${{ secrets.CORS_ORIGINS }}\n          MAX_DESCRIPTION_WORKERS: ${{ secrets.MAX_DESCRIPTION_WORKERS }}\n          MAX_IMAGE_WORKERS: ${{ secrets.MAX_IMAGE_WORKERS }}\n          MINERU_TOKEN: ${{ secrets.MINERU_TOKEN }}\n          MINERU_API_BASE: ${{ secrets.MINERU_API_BASE }}\n          IMAGE_CAPTION_MODEL: ${{ secrets.IMAGE_CAPTION_MODEL }}\n          OUTPUT_LANGUAGE: ${{ secrets.OUTPUT_LANGUAGE }}\n\n      - name: Build and start Docker services\n        run: |\n          docker compose build --no-cache\n          docker compose up -d\n\n      - name: Wait for services\n        run: |\n          chmod +x scripts/wait-for-health.sh\n          ./scripts/wait-for-health.sh http://localhost:5000/health 60 2\n          ./scripts/wait-for-health.sh http://localhost:3000 60 2\n\n      - name: Docker environment tests\n        run: |\n          chmod +x scripts/test_docker_environment.sh\n          AUTO_CLEANUP=false ./scripts/test_docker_environment.sh\n\n      - name: Setup Node.js\n        if: env.GOOGLE_API_KEY != '' && env.GOOGLE_API_KEY != 'mock-api-key-for-testing'\n        uses: actions/setup-node@v4\n        with:\n          node-version: '18'\n          cache: 'npm'\n          cache-dependency-path: frontend/package-lock.json\n\n      - name: Install Playwright\n        if: env.GOOGLE_API_KEY != '' && env.GOOGLE_API_KEY != 'mock-api-key-for-testing'\n        run: |\n          cd frontend\n          npm ci\n          npx playwright install --with-deps chromium\n\n      - name: Run E2E tests\n        if: env.GOOGLE_API_KEY != '' && env.GOOGLE_API_KEY != 'mock-api-key-for-testing'\n        run: cd frontend && npx playwright test ui-full-flow.spec.ts --workers=1\n        env:\n          CI: true\n        timeout-minutes: 25\n\n      - name: Upload E2E reports\n        if: always()\n        uses: actions/upload-artifact@v4\n        with:\n          name: release-e2e-report\n          path: frontend/playwright-report/\n          retention-days: 7\n\n      - name: View logs on failure\n        if: failure()\n        run: |\n          docker compose logs backend\n          docker compose logs frontend\n\n      - name: Cleanup\n        if: always()\n        run: |\n          docker compose down -v\n          docker system prune -f\n\n  sync-version:\n    name: Sync Version to Source Files\n    needs: e2e-test\n    runs-on: ubuntu-latest\n    environment: release\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          ref: main\n\n      - name: Update version files\n        env:\n          VERSION: ${{ inputs.version }}\n        run: |\n          V=\"${VERSION#v}\"\n          jq --arg v \"$V\" '.version = $v' package.json > tmp.json && mv tmp.json package.json\n          sed -i \"s/^version = .*/version = \\\"$V\\\"/\" pyproject.toml\n\n      - name: Commit and push\n        env:\n          VERSION: ${{ inputs.version }}\n        run: |\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"41898282+github-actions[bot]@users.noreply.github.com\"\n          git add package.json pyproject.toml\n          git diff --cached --quiet && exit 0\n          git commit -m \"chore: bump version to $VERSION\"\n          git pull --rebase\n          git push\n\n  create-release:\n    name: Create GitHub Release\n    needs: sync-version\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          ref: main\n\n      - name: Pull latest (includes version bump)\n        run: git pull origin main\n\n      - name: Create tag and release\n        env:\n          VERSION: ${{ inputs.version }}\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          git tag \"$VERSION\"\n          git push origin \"$VERSION\"\n          gh release create \"$VERSION\" --generate-notes\n\n  build-and-push:\n    name: Build and Push Docker Images\n    needs: create-release\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          ref: ${{ inputs.version }}\n\n      - uses: docker/setup-qemu-action@v3\n      - uses: docker/setup-buildx-action@v3\n\n      - name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Extract metadata for backend\n        id: meta-backend\n        uses: docker/metadata-action@v5\n        with:\n          images: ${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-backend\n          tags: |\n            type=raw,value=${{ inputs.version }}\n            type=raw,value=latest\n            type=sha\n\n      - name: Extract metadata for frontend\n        id: meta-frontend\n        uses: docker/metadata-action@v5\n        with:\n          images: ${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-frontend\n          tags: |\n            type=raw,value=${{ inputs.version }}\n            type=raw,value=latest\n            type=sha\n\n      - name: Build and push backend image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./backend/Dockerfile\n          push: true\n          platforms: linux/amd64,linux/arm64\n          tags: ${{ steps.meta-backend.outputs.tags }}\n          labels: ${{ steps.meta-backend.outputs.labels }}\n          cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-backend:buildcache\n          cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-backend:buildcache,mode=max\n          build-args: |\n            DOCKER_REGISTRY=${{ secrets.DOCKER_REGISTRY }}\n            GHCR_REGISTRY=${{ secrets.GHCR_REGISTRY || 'ghcr.io/' }}\n            APT_MIRROR=${{ secrets.APT_MIRROR }}\n            PYPI_INDEX_URL=${{ secrets.PYPI_INDEX_URL }}\n\n      - name: Build and push frontend image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./frontend/Dockerfile\n          push: true\n          platforms: linux/amd64,linux/arm64\n          tags: ${{ steps.meta-frontend.outputs.tags }}\n          labels: ${{ steps.meta-frontend.outputs.labels }}\n          cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-frontend:buildcache\n          cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-frontend:buildcache,mode=max\n          build-args: |\n            DOCKER_BUILDKIT=1\n            DOCKER_REGISTRY=${{ secrets.DOCKER_REGISTRY }}\n            NPM_REGISTRY=${{ secrets.NPM_REGISTRY }}\n\n  # build-binaries:\n  #   name: Build Binary Artifacts\n  #   needs: create-release\n  #   runs-on: ubuntu-latest\n  #   steps:\n  #     - uses: actions/checkout@v4\n  #     - name: Build\n  #       run: echo \"TODO: add build steps\"\n  #     - name: Upload to Release\n  #       env:\n  #         VERSION: ${{ inputs.version }}\n  #         GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n  #       run: gh release upload \"$VERSION\" ./dist/*\n"
  },
  {
    "path": ".github/workflows/nightly.yml",
    "content": "name: Nightly\n\n# Every day at 03:00 UTC, or manual trigger\non:\n  schedule:\n    - cron: '0 3 * * *'\n  workflow_dispatch:\n\nconcurrency:\n  group: ${{ github.workflow }}\n  cancel-in-progress: false\n\njobs:\n  e2e-test:\n    name: Docker E2E Tests\n    runs-on: ubuntu-latest\n    env:\n      GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Setup environment\n        run: |\n          chmod +x scripts/setup-env-from-secrets.sh\n          ./scripts/setup-env-from-secrets.sh\n          sed -i 's/^AI_PROVIDER_FORMAT=.*/AI_PROVIDER_FORMAT=gemini/' .env || echo \"AI_PROVIDER_FORMAT=gemini\" >> .env\n        env:\n          AI_PROVIDER_FORMAT: gemini\n          GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}\n          GOOGLE_API_BASE: ${{ secrets.GOOGLE_API_BASE }}\n          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n          OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}\n          OPENAI_TIMEOUT: ${{ secrets.OPENAI_TIMEOUT }}\n          OPENAI_MAX_RETRIES: ${{ secrets.OPENAI_MAX_RETRIES }}\n          TEXT_MODEL: ${{ secrets.TEXT_MODEL }}\n          IMAGE_MODEL: ${{ secrets.IMAGE_MODEL }}\n          LOG_LEVEL: ${{ secrets.LOG_LEVEL }}\n          FLASK_ENV: ${{ secrets.FLASK_ENV }}\n          SECRET_KEY: ${{ secrets.SECRET_KEY }}\n          BACKEND_PORT: ${{ secrets.BACKEND_PORT }}\n          CORS_ORIGINS: ${{ secrets.CORS_ORIGINS }}\n          MAX_DESCRIPTION_WORKERS: ${{ secrets.MAX_DESCRIPTION_WORKERS }}\n          MAX_IMAGE_WORKERS: ${{ secrets.MAX_IMAGE_WORKERS }}\n          MINERU_TOKEN: ${{ secrets.MINERU_TOKEN }}\n          MINERU_API_BASE: ${{ secrets.MINERU_API_BASE }}\n          IMAGE_CAPTION_MODEL: ${{ secrets.IMAGE_CAPTION_MODEL }}\n          OUTPUT_LANGUAGE: ${{ secrets.OUTPUT_LANGUAGE }}\n\n      - name: Build Docker images\n        run: docker compose build --no-cache\n\n      - name: Start services\n        run: docker compose up -d\n\n      - name: Wait for services\n        run: |\n          chmod +x scripts/wait-for-health.sh\n          ./scripts/wait-for-health.sh http://localhost:5000/health 60 2\n          ./scripts/wait-for-health.sh http://localhost:3000 60 2\n\n      - name: Docker environment tests\n        run: |\n          chmod +x scripts/test_docker_environment.sh\n          AUTO_CLEANUP=false ./scripts/test_docker_environment.sh\n\n      - name: Setup Node.js\n        if: env.GOOGLE_API_KEY != '' && env.GOOGLE_API_KEY != 'mock-api-key-for-testing'\n        uses: actions/setup-node@v4\n        with:\n          node-version: '18'\n          cache: 'npm'\n          cache-dependency-path: frontend/package-lock.json\n\n      - name: Install Playwright\n        if: env.GOOGLE_API_KEY != '' && env.GOOGLE_API_KEY != 'mock-api-key-for-testing'\n        run: |\n          cd frontend\n          npm ci\n          npx playwright install --with-deps chromium\n\n      - name: Run E2E tests\n        if: env.GOOGLE_API_KEY != '' && env.GOOGLE_API_KEY != 'mock-api-key-for-testing'\n        run: cd frontend && npx playwright test ui-full-flow.spec.ts --workers=1\n        env:\n          CI: true\n        timeout-minutes: 25\n\n      - name: Upload E2E reports\n        if: always()\n        uses: actions/upload-artifact@v4\n        with:\n          name: playwright-report\n          path: frontend/playwright-report/\n          retention-days: 7\n\n      - name: View logs on failure\n        if: failure()\n        run: |\n          docker compose logs backend\n          docker compose logs frontend\n\n      - name: Cleanup\n        if: always()\n        run: |\n          docker compose down -v\n          docker system prune -f\n\n  security-scan:\n    name: Security Scan\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Backend dependency scan\n        run: |\n          uv pip install safety\n          uv run safety check --json || echo \"Vulnerabilities found (warning)\"\n        continue-on-error: true\n\n      - name: Frontend dependency scan\n        run: cd frontend && npm audit --audit-level=moderate || true\n        continue-on-error: true\n\n      - name: Dockerfile scan\n        uses: hadolint/hadolint-action@v3.1.0\n        with:\n          dockerfile: backend/Dockerfile\n        continue-on-error: true\n\n  push-nightly:\n    name: Push Nightly Images\n    needs: e2e-test\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: docker/setup-qemu-action@v3\n      - uses: docker/setup-buildx-action@v3\n\n      - name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Build and push backend\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./backend/Dockerfile\n          push: true\n          platforms: linux/amd64,linux/arm64\n          tags: ${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-backend:nightly\n          cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-backend:buildcache\n          cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-backend:buildcache,mode=max\n          build-args: |\n            DOCKER_REGISTRY=${{ secrets.DOCKER_REGISTRY }}\n            GHCR_REGISTRY=${{ secrets.GHCR_REGISTRY || 'ghcr.io/' }}\n            APT_MIRROR=${{ secrets.APT_MIRROR }}\n            PYPI_INDEX_URL=${{ secrets.PYPI_INDEX_URL }}\n\n      - name: Build and push frontend\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./frontend/Dockerfile\n          push: true\n          platforms: linux/amd64,linux/arm64\n          tags: ${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-frontend:nightly\n          cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-frontend:buildcache\n          cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-frontend:buildcache,mode=max\n          build-args: |\n            DOCKER_BUILDKIT=1\n            DOCKER_REGISTRY=${{ secrets.DOCKER_REGISTRY }}\n            NPM_REGISTRY=${{ secrets.NPM_REGISTRY }}\n"
  },
  {
    "path": ".github/workflows/pr-quick-check.yml",
    "content": "name: PR Quick Check\n\non:\n  pull_request:\n    branches: [ main, develop ]\n\n# Quick check: lint + unit tests + build + smoke\nenv:\n  UV_INDEX_URL: https://pypi.org/simple\n\njobs:\n  quick-check:\n    name: Quick Check (Lint + Unit Tests + Build)\n    runs-on: ubuntu-latest\n    timeout-minutes: 8\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      # Backend checks\n      - name: Setup Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: '3.10'\n\n      - name: Install uv package manager\n        run: |\n          curl -LsSf https://astral.sh/uv/install.sh | sh\n          echo \"$HOME/.cargo/bin\" >> $GITHUB_PATH\n\n      - name: Install backend dependencies\n        run: uv sync --extra test\n\n      - name: Backend syntax check\n        run: |\n          cd backend\n          uv run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics || true\n        continue-on-error: true\n\n      - name: Backend unit tests\n        run: uv run pytest backend/tests/unit -v\n\n      # Frontend checks\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: '18'\n          cache: 'npm'\n          cache-dependency-path: frontend/package-lock.json\n\n      - name: Install frontend dependencies\n        run: cd frontend && npm ci\n\n      - name: Frontend lint check\n        run: cd frontend && npm run lint\n\n      - name: Frontend unit tests\n        run: cd frontend && npm test -- --run\n\n      - name: Frontend build check\n        run: cd frontend && npm run build\n\n  docs-check:\n    name: Docs Link Check\n    runs-on: ubuntu-latest\n    timeout-minutes: 3\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '18'\n      - name: Check docs links\n        run: cd docs && npx mintlify@latest broken-links\n\n  # Simple API smoke test\n  smoke-test:\n    name: Smoke Test\n    runs-on: ubuntu-latest\n    timeout-minutes: 3\n    needs: quick-check\n    \n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n      \n      - name: Setup Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: '3.10'\n      \n      - name: Install uv package manager\n        run: |\n          curl -LsSf https://astral.sh/uv/install.sh | sh\n          echo \"$HOME/.cargo/bin\" >> $GITHUB_PATH\n      \n      - name: Install dependencies\n        run: |\n          uv sync\n      \n      - name: Start backend and test\n        run: |\n          cp .env.example .env\n          cd backend\n          # Start in background\n          uv run python app.py &\n          SERVER_PID=$!\n          \n          # Poll until backend is ready (up to 30s)\n          echo \"Waiting for backend to start...\"\n          for i in $(seq 1 30); do\n            if curl -sf http://localhost:5000/health; then\n              echo \"\"\n              echo \"Backend smoke test passed (ready after ${i}s)\"\n              kill $SERVER_PID\n              exit 0\n            fi\n            if ! kill -0 $SERVER_PID 2>/dev/null; then\n              echo \"Backend process exited unexpectedly\"\n              cat ../instance/app.log 2>/dev/null || echo \"No log file found\"\n              exit 1\n            fi\n            sleep 1\n          done\n\n          echo \"Backend failed to start within 30s\"\n          cat ../instance/app.log 2>/dev/null || echo \"No log file found\"\n          kill $SERVER_PID 2>/dev/null\n          exit 1\n        env:\n          GOOGLE_API_KEY: mock-key-for-testing\n          TESTING: true\n\n"
  },
  {
    "path": ".github/workflows/translate-readme.yml",
    "content": "name: Auto Translate README\n\n# 当主分支的 README.md 改动时自动翻译到 README_EN.md\n\non:\n  push:\n    branches:\n      - main\n    paths:\n      - 'README.md'\n  workflow_dispatch:  # 允许手动触发\n\n# 防止多个翻译任务同时运行\nconcurrency:\n  group: translate-readme\n  cancel-in-progress: true\n\n# 授予工作流推送权限\npermissions:\n  contents: write\n\njobs:\n  translate:\n    name: Translate README to English\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          # 使用 GitHub Token 推送改动\n          token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: '3.10'\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v5\n        with:\n          enable-cache: true\n\n      - name: Install dependencies\n        run: |\n          uv sync\n\n      - name: Translate README\n        env:\n          # 从仓库 secrets 读取 API 密钥\n          GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}\n          GOOGLE_API_BASE: ${{ secrets.GOOGLE_API_BASE }}\n          AI_PROVIDER_FORMAT: gemini\n          TEXT_MODEL: gemini-3-flash-preview\n        run: |\n          echo \"开始增量翻译 README.md（仅翻译修改的部分）...\"\n          uv run python scripts/translate_readme_incremental.py\n          echo \"翻译完成！\"\n\n      - name: Check for changes\n        id: check_changes\n        run: |\n          if git diff --quiet README_EN.md; then\n            echo \"changed=false\" >> $GITHUB_OUTPUT\n            echo \"README_EN.md 无变化，跳过提交\"\n          else\n            echo \"changed=true\" >> $GITHUB_OUTPUT\n            echo \"README_EN.md 已更新\"\n          fi\n\n      - name: Commit and push changes\n        if: steps.check_changes.outputs.changed == 'true'\n        run: |\n          git config --local user.email \"github-actions[bot]@users.noreply.github.com\"\n          git config --local user.name \"github-actions[bot]\"\n          git add README_EN.md\n          git commit -m \"docs: auto translate README to English [skip ci]\"\n          git push\n\n      - name: Summary\n        run: |\n          if [ \"${{ steps.check_changes.outputs.changed }}\" == \"true\" ]; then\n            echo \"✅ README_EN.md 已自动更新并推送\"\n          else\n            echo \"ℹ️ README_EN.md 无变化\"\n          fi\n"
  },
  {
    "path": ".gitignore",
    "content": "*.png\n*.jpg\n*.jpeg\n*.gif\n*.bmp\n*.svg\n*.webp\n*.ico\n*.tiff\n*.tif\n*.heic\n*.heif\n*.avif\n\n!frontend/public/**/*.png\n!frontend/public/**/*.jpg\n!frontend/public/**/*.jpeg\n!frontend/public/**/*.gif\n!frontend/public/**/*.bmp\n!frontend/public/**/*.svg\n!frontend/public/**/*.webp\n!frontend/public/**/*.ico\n!frontend/public/**/*.tiff\n!frontend/public/**/*.tif\n!frontend/public/**/*.heic\n!frontend/public/**/*.heif\n!frontend/public/**/*.avif\n\n# 保留测试fixtures\n!e2e/fixtures/*.png\n\n# 保留项目模板图片\n!template*.png\n\n# 忽略临时文档，但保留项目文档\n*.md\n!README.md\n# !docs/**/*.md\n!.github/**/*.md\n*.pyc\n.env\n\n# GCP service-account key (sensitive — never commit)\ngcp-service-account.json\n*.ppt\n*.pptx\ngenerate-example.py\n*.mdc\n*.pdf\n.cursor/worktrees.json\nuploads/\n!assets/*\n\n# 镜像源配置脚本的备份文件\n*.orig\n# 本地备份目录（保存测试文件等）\n_local_backup/\n!CLA.md \n!CONTRIBUTING.md\n.venv\n"
  },
  {
    "path": "CLA.md",
    "content": "# Banana-slides Contributor License Agreement\n\nThank you for your interest in contributing to Banana-slides (\"Project\").\n\nBy signing this Contributor License Agreement (\"CLA\"), you accept and agree to the following terms and conditions for your present and future Contributions submitted to the Project.\n\n## 0. Acceptance (How this CLA becomes effective)\n\nBy submitting a Contribution to the Project (including via a Pull Request or any other form of submission intended for inclusion), you acknowledge that you have read and agree to be bound by the terms of this CLA.\n\nIf you do not agree to these terms, do not submit any Contribution.\n\n## 1. Definitions\n\n- **\"Contribution\"** means any code, documentation, or other original work of authorship, including any modifications or additions to existing work, that is intentionally submitted by you to the Project for inclusion in the Project.\n\n- **\"You\" (or \"Your\")** means the individual or legal entity on behalf of whom a Contribution is submitted.\n\n- **\"Project Maintainer\"** means the original author and primary maintainer of the Project (Anionex).\n\n## 2. Grant of Copyright License\n\nYou retain all right, title, and interest in your Contributions. You hereby grant to the Project Maintainer a perpetual, worldwide, non-exclusive, royalty-free, irrevocable copyright license to:\n\n- Reproduce, prepare derivative works of, publicly display, publicly perform, and distribute your Contributions and such derivative works.\n- Sublicense and relicense your Contributions under any license, **including for commercial purposes**.\n\n## 3. Grant of Patent License\n\nYou hereby grant to the Project Maintainer a perpetual, worldwide, non-exclusive, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer your Contributions, where such license applies only to those patent claims licensable by you that are necessarily infringed by your Contribution(s) alone or by combination of your Contribution(s) with the Project.\n\n## 4. Representations and Warranties\n\nYou represent and warrant that:\n\n- You are legally entitled to grant the above licenses.\n- Each of your Contributions is your original creation.\n- Your Contribution submissions include complete details of any third-party license or other restriction of which you are aware and which are associated with any part of your Contributions.\n- Your Contribution does not violate any third-party's intellectual property rights.\n- If you are employed, your employer has waived any rights to your Contributions, or you have obtained permission from your employer to submit Contributions.\n\n## 5. No Support Obligation\n\nYou are not expected to provide support for your Contributions, except to the extent you desire to provide support. You may provide support for free, for a fee, or not at all.\n\n## 6. Open Source Availability\n\nThe Project Maintainer will continue to make an open-source edition of the Project publicly available, under an OSI-approved open-source license.\n\nFor clarity:\n- The Maintainer may also distribute separate commercial/proprietary editions and/or offer alternative licenses for Contributions.\n- The code will be published in a publicly accessible source repository (not necessarily GitHub).\n\n## 7. No Warranty\n\nYour Contributions are provided \"AS IS\" without warranty of any kind, express or implied.\n\n---\n\n## How to Agree\n\nWhen you open a Pull Request, the PR template includes a CLA checkbox. Check the box to indicate that you have read and agree to this CLA.\n\nYour agreement will remain publicly visible in the Pull Request description and be associated with your GitHub account.\n\n---\n\n*Last updated: February 2026*\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Banana-slides\n\nThank you for your interest in contributing to Banana-slides! We welcome contributions from the community.\n\n## Before You Start\n\n### Contributor License Agreement (CLA)\n\nBy submitting a Pull Request (or any other Contribution intended for inclusion) to this repository, you confirm that you have read and agree to the terms of our [Contributor License Agreement (CLA)](CLA.md).\nIf you do not agree to the CLA, please do not submit a Pull Request.\n\n**Why do we need a CLA?**\n\n- To ensure we have the necessary rights to use, modify, and distribute contributions\n- To allow the project to explore sustainable commercial models while keeping the open-source edition available\n- To protect both contributors and the project legally\n\n**How to agree:**\n\nWhen you open a Pull Request, the PR template includes a CLA checkbox. Simply check it to indicate your agreement. PRs without the CLA checkbox checked may be delayed or closed.\n\n## How to Contribute\n\n### Reporting Bugs\n\n1. Check if the bug has already been reported in [Issues](https://github.com/Anionex/banana-slides/issues)\n2. If not, create a new issue with:\n   - A clear, descriptive title\n   - Steps to reproduce the bug\n   - Expected behavior vs actual behavior\n   - Screenshots if applicable\n   - Your environment (OS, browser, etc.)\n\n### Suggesting Features\n\n1. Check existing issues for similar suggestions\n2. Create a new issue with the \"feature request\" label\n3. Describe the feature and its use case\n\n### Submitting Code\n\n1. Fork the repository\n2. Create a new branch for your feature/fix: `git checkout -b feature/your-feature-name`\n3. Make your changes\n4. Test your changes thoroughly\n5. Commit with clear, descriptive messages\n6. Push to your fork\n7. Open a Pull Request with:\n   - A clear description of the changes\n   - Reference to any related issues\n   - **CLA checkbox checked** (PRs without this may be delayed/closed) (PRs without this statement may be delayed/closed)\n\n## Development Setup\n\n### 环境要求 / Requirements\n\n- Python 3.10+\n- [uv](https://github.com/astral-sh/uv) - Python 包管理器\n- Node.js 16+ 和 npm\n- 有效的 API 密钥（详见 `.env.example`）\n\n### 安装步骤 / Installation\n\n```bash\n# 克隆代码仓库\ngit clone https://github.com/Anionex/banana-slides.git\ncd banana-slides\n\n# 安装 uv（如果尚未安装）\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n\n# 安装后端依赖（在项目根目录运行）\nuv sync\n\n# 配置环境变量\ncp .env.example .env\n# 编辑 .env 文件，配置你的 API 密钥\n\n# 安装前端依赖\ncd frontend\nnpm install\n```\n\n### 启动开发服务器 / Start Development Server\n\n```bash\n# 启动后端（在项目根目录）\ncd backend\nuv run alembic upgrade head && uv run python app.py\n# 后端运行在 http://localhost:5000\n\n# 启动前端（新开一个终端）\ncd frontend\nnpm run dev\n# 前端运行在 http://localhost:3000\n```\n\n## Code Style\n\n- Follow the existing code style in the project\n- Write clear, self-documenting code\n- Add comments for complex logic\n- Include tests for new features when applicable\n\n## Questions?\n\nIf you have questions, feel free to open an issue or reach out to the maintainers.\n\n---\n\nThank you for contributing to Banana-slides! 🍌\n"
  },
  {
    "path": "Dockerfile.allinone",
    "content": "# 镜像源配置参数（可通过 build args 覆盖）\nARG DOCKER_REGISTRY=\nARG GHCR_REGISTRY=ghcr.io/\nARG NPM_REGISTRY=\nARG APT_MIRROR=\nARG PYPI_INDEX_URL=\n\n# ── Stage 1: 构建前端 ──────────────────────────────────────────\nFROM ${DOCKER_REGISTRY:-}node:18-alpine AS frontend-builder\n\nARG NPM_REGISTRY=\nWORKDIR /app\n\nCOPY frontend/package.json frontend/package-lock.json* ./\nRUN if [ -n \"$NPM_REGISTRY\" ]; then \\\n        npm config set registry \"$NPM_REGISTRY\"; \\\n    fi && \\\n    (npm install --frozen-lockfile || npm install)\n\nCOPY frontend/ ./\nRUN npm run build\n\n# ── Stage 2: 获取 uv ──────────────────────────────────────────\nFROM ${GHCR_REGISTRY}astral-sh/uv:latest AS uv\n\n# ── Stage 3: 最终一体镜像 ──────────────────────────────────────\nFROM ${DOCKER_REGISTRY:-}python:3.10-slim\n\nARG APT_MIRROR=\nARG PYPI_INDEX_URL=\n\nWORKDIR /app\n\n# 安装系统依赖：nginx + supervisor + curl\nRUN if [ -n \"$APT_MIRROR\" ]; then \\\n        if [ -f /etc/apt/sources.list.d/debian.sources ]; then \\\n            sed -i \"s@deb.debian.org@$APT_MIRROR@g\" /etc/apt/sources.list.d/debian.sources; \\\n        fi; \\\n    fi && \\\n    apt-get update && apt-get install -y \\\n        curl \\\n        nginx \\\n        supervisor \\\n    && rm -rf /var/lib/apt/lists/*\n\n# 复制 uv\nCOPY --from=uv /uv /usr/local/bin/uv\nRUN chmod +x /usr/local/bin/uv\n\n# 安装 Python 依赖\nCOPY pyproject.toml uv.lock* ./\nENV UV_INDEX_URL=${PYPI_INDEX_URL}\nENV UV_HTTP_TIMEOUT=300\nRUN if [ -f uv.lock ]; then uv sync --frozen; else uv sync; fi\n\n# 复制后端代码和资源\nCOPY backend/ ./backend/\nCOPY assets/ ./assets/\nCOPY docker/ ./docker/\n\n# 复制前端构建产物\nCOPY --from=frontend-builder /app/dist /usr/share/nginx/html\n\n# 配置 nginx\nCOPY docker/nginx-allinone.conf /etc/nginx/conf.d/default.conf\nRUN rm -f /etc/nginx/sites-enabled/default\n\n# 启动脚本可执行\nRUN chmod +x /app/docker/start-backend.sh\n\n# 创建必要目录\nRUN mkdir -p /app/backend/instance /app/uploads\n\nENV PYTHONPATH=/app\nENV FLASK_APP=backend/app.py\nENV IN_DOCKER=1\n\nEXPOSE 80\n\nHEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \\\n    CMD curl -f http://localhost/health || exit 1\n\nCMD [\"/usr/bin/supervisord\", \"-c\", \"/app/docker/supervisord.conf\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n<img src=\"https://github.com/user-attachments/assets/81fe6816-44cc-4c61-97c7-f3c099650966\" alt=\"banana-slides\">\n\n  \n<a href=\"https://trendshift.io/repositories/22056\" target=\"_blank\">\n  <img src=\"https://trendshift.io/api/badge/repositories/22056\" alt=\"Anionex%2Fbanana-slides | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/>\n</a>\n<br>\n\n<b>一个基于nano banana pro🍌的原生AI PPT生成应用<br></b>\n<b> 在几分钟内从想法到演示文稿，无需繁琐排版、口头提出修改，迈向真正的\"Vibe PPT\" </b>\n\n\n\n<p>\n  <a href=\"https://bananaslides.online/\"><b>🚀 在线 Demo</b></a>\n  &nbsp;•&nbsp;\n  <a href=\"https://docs.bananaslides.online/\"><b>📚 文档</b></a>\n  &nbsp;•&nbsp;\n  <a href=\"README_EN.md\"><b>English</b></a>\n</p>\n\n[![GitHub Stars](https://img.shields.io/github/stars/Anionex/banana-slides?style=flat-square&color=FFD700)](https://github.com/Anionex/banana-slides/stargazers)\n[![GitHub Forks](https://img.shields.io/github/forks/Anionex/banana-slides?style=flat-square&color=FFD700)](https://github.com/Anionex/banana-slides/network)\n[![GitHub Watchers](https://img.shields.io/github/watchers/Anionex/banana-slides?style=flat-square&color=FFD700)](https://github.com/Anionex/banana-slides/watchers)\n\n\n[![Version](https://img.shields.io/badge/version-v0.4.0-44cc11?style=flat-square)](https://github.com/Anionex/banana-slides)\n![Docker](https://img.shields.io/badge/Docker-Build-4A90D9?logo=docker&logoColor=white&style=flat-square)\n[![License](https://img.shields.io/github/license/Anionex/banana-slides?color=0055aa&style=flat-square)](https://github.com/Anionex/banana-slides/blob/main/LICENSE)\n\n\n\n\n<p>\n  如果该项目对你有用，欢迎 <b>Star 🌟</b> & <b>Fork 🍴</b>\n</p>\n\n</div>\n\n## ✨ 项目缘起\n你是否也曾陷入这样的困境：明天就要汇报，但PPT还是一片空白；脑中有无数精彩的想法，却被繁琐的排版和设计消磨掉所有热情？\n\n我(们)渴望能快速创作出既专业又具设计感的演示文稿，传统的AI PPT生成app，虽然大体满足“快”这一需求，却还存在以下问题：\n\n- 1️⃣只能选择预设模版，无法灵活调整风格\n- 2️⃣自由度低，多轮改动难以进行 \n- 3️⃣成品观感相似，同质化严重\n- 4️⃣素材质量较低，缺乏针对性\n- 5️⃣图文排版割裂，设计感差\n\n以上这些缺陷，让传统的AI ppt生成器难以同时满足我们“快”和“美”的两大PPT制作需求。即使自称Vibe PPT，但是在我的眼中还远不够“Vibe”。\n\n但是，nano banana🍌模型的出现让一切有了转机。我尝试使用🍌pro进行ppt页面生成，发现生成的结果无论是质量、美感还是一致性，都做的非常好，且几乎能精确渲染prompt要求的所有文字+遵循参考图的风格。那为什么不基于🍌pro，做一个原生的\"Vibe PPT\"应用呢？\n\n## 👨‍💻 适用场景\n\n\n1. **小白**：零门槛快速生成美观PPT，无需设计经验，减少模板选择烦恼\n2. **PPT专业人士**：参考AI生成的布局和图文元素组合，快速获取设计灵感\n3. **教育工作者**：将教学内容快速转换为配图教案PPT，提升课堂效果\n4. **学生**：快速完成作业Pre，把精力专注于内容而非排版美化\n5. **职场人士**：商业提案、产品介绍快速可视化，多场景快速适配\n\n<p>\n  <b>🎯目标： 降低 PPT 制作门槛，让每个人都能快速创作出美观专业的演示文稿</b>\n</p>\n\n\n## 🎨 结果案例\n\n\n<div align=\"center\">\n\n| | |\n|:---:|:---:|\n| <img src=\"https://github.com/user-attachments/assets/d58ce3f7-bcec-451d-a3b9-ca3c16223644\" width=\"500\" alt=\"案例3\"> | <img src=\"https://github.com/user-attachments/assets/c64cd952-2cdf-4a92-8c34-0322cbf3de4e\" width=\"500\" alt=\"案例2\"> |\n| **软件开发最佳实践** | **DeepSeek-V3.2技术展示** |\n| <img src=\"https://github.com/user-attachments/assets/383eb011-a167-4343-99eb-e1d0568830c7\" width=\"500\" alt=\"案例4\"> | <img src=\"https://github.com/user-attachments/assets/1a63afc9-ad05-4755-8480-fc4aa64987f1\" width=\"500\" alt=\"案例1\"> |\n| **预制菜智能产线装备研发和产业化** | **钱的演变：从贝壳到纸币的旅程** |\n\n</div>\n\n更多可见<a href=\"https://github.com/Anionex/banana-slides/issues/2\" > 使用案例 </a>\n\n\n## 🎯 功能介绍\n\n### 1. 灵活多样的创作路径\n支持**想法**、**大纲**、**页面描述**三种起步方式，满足不同创作习惯。\n- **一句话生成**：输入一个主题，AI 自动生成结构清晰的大纲和逐页内容描述。\n- **自然语言编辑**：支持以 Vibe 形式口头修改大纲或描述（如\"把第三页改成案例分析\"），AI 实时响应调整。\n- **大纲/描述模式**：既可一键批量生成，也可手动调整细节。\n\n<img width=\"2000\" height=\"1125\" alt=\"image\" src=\"https://github.com/user-attachments/assets/7fc1ecc6-433d-4157-b4ca-95fcebac66ba\" />\n\n\n### 2. 强大的素材解析能力\n- **多格式支持**：上传 PDF/Docx/MD/Txt 等文件，后台自动解析内容。\n- **智能提取**：自动识别文本中的关键点、图片链接和图表信息，为生成提供丰富素材。\n- **风格参考**：支持上传参考图片或模板，定制 PPT 风格。\n\n<img width=\"1920\" height=\"1080\" alt=\"文件解析与素材处理\" src=\"https://github.com/user-attachments/assets/8cda1fd2-2369-4028-b310-ea6604183936\" />\n\n### 3. \"Vibe\" 式自然语言修改\n不再受限于复杂的菜单按钮，直接通过**自然语言**下达修改指令。\n- **局部重绘**：对不满意的区域进行口头式修改（如\"把这个图换成饼图\"）。\n- **整页优化**：基于 nano banana pro🍌 生成高清、风格统一的页面。\n\n<img width=\"2000\" height=\"1125\" alt=\"image\" src=\"https://github.com/user-attachments/assets/929ba24a-996c-4f6d-9ec6-818be6b08ea3\" />\n\n\n### 4. 开箱即用的格式导出\n- **多格式支持**：一键导出标准 **PPTX** 或 **PDF** 文件。\n- **完美适配**：默认 16:9 比例，排版无需二次调整，直接演示。\n\n<img width=\"1000\" alt=\"image\" src=\"https://github.com/user-attachments/assets/3e54bbba-88be-4f69-90a1-02e875c25420\" />\n<img width=\"1748\" height=\"538\" alt=\"PPT与PDF导出\" src=\"https://github.com/user-attachments/assets/647eb9b1-d0b6-42cb-a898-378ebe06c984\" />\n\n### 5. 可自由编辑的pptx导出（Beta迭代中）\n- **导出图像为高还原度、背景干净的、可自由编辑图像和文字的PPT页面**\n- 相关更新见 https://github.com/Anionex/banana-slides/issues/121\n<img width=\"1000\"  alt=\"image\" src=\"https://github.com/user-attachments/assets/a85d2d48-1966-4800-a4bf-73d17f914062\" />\n\n<br>\n\n**🌟和notebooklm slide deck功能对比**\n| 功能 | notebooklm | 本项目 | \n| --- | --- | --- |\n| 页数上限 | 15页 | **无限制** | \n| 二次编辑 | 提示词修改 | **框选编辑+口头编辑** |\n| 素材添加 | 生成后无法添加 | **生成后自由添加** |\n| 导出格式 | 支持导出为 PDF、（不可编辑图片）pptx | **导出为PDF、(图片or可编辑)pptx** |\n| 水印 | 免费版有水印 | **无水印，自由增删元素** |\n\n> 注：随着新功能添加,对比可能过时\n\n\n\n## 🔥 近期更新\n- 【2-9】：\n  * 新功能\n    * 支持在首页、大纲、描述卡片里面粘贴图片并立即识别，并提供更好的交互体验\n    * 大纲章节手动编辑：支持手动调整页面所属章节（part）。\n    * Docker 多架构：镜像支持 amd64 / arm64 构建。\n    * 国际化 + 暗黑模式：新增中英文切换；支持亮色/暗色/跟随系统主题；全组件适配暗黑模式。\n  * 修复与体验优化\n    * 修复导出相关 500、参考文件关联时序、outline/page 数据错位、任务轮询错误项目、描述生成无限轮询、图片预览内存泄漏、批量删除部分失败处理。\n    * 优化格式示例提示、HTTP 错误提示文案、Modal 关闭体验、清理旧项目 localStorage、移除首次创建项目冗余提示。\n    * 若干其他优化和修复\n- 【1-4】 : v0.4.0发布：可编辑pptx导出全面升级：\n  * 支持最大程度还原图片中文字的字号、颜色、加粗等样式；\n  * 支持了识别表格中的文字内容；\n  * 更精确的文字大小和文字位置还原逻辑\n  * 优化导出工作流，大大减少了导出后背景图残留文字的现象；\n  * 支持页面多选逻辑，灵活选择需要生成和导出的具体页面。\n  * **详细效果和使用方法见 https://github.com/Anionex/banana-slides/issues/121**\n\n- 【12-27】: 加入了对无图片模板模式的支持和较高质量的文字预设，现在可以通过纯文字描述的方式来控制ppt页面风格\n\n\n## 🗺️ 开发计划\n\n| 状态 | 里程碑 |\n| --- | --- |\n| ✅ 已完成 | 从想法、大纲、页面描述三种路径创建 PPT |\n| ✅ 已完成 | 解析文本中的 Markdown 格式图片 |\n| ✅ 已完成 | PPT 单页添加更多素材 |\n| ✅ 已完成 | PPT 单页框选区域Vibe口头编辑 |\n| ✅ 已完成 | 素材模块: 素材生成、上传等 |\n| ✅ 已完成 | 支持多种文件的上传+解析 |\n| ✅ 已完成 | 支持Vibe口头调整大纲和描述 |\n| ✅ 已完成 | 初步支持可编辑版本pptx文件导出 |\n| 🔄 进行中 | 支持多层次、精确抠图的可编辑pptx导出 |\n| 🔄 进行中 | 网络搜索 |\n| 🔄 进行中 | Agent 模式 |\n| 🚍 部分 | 优化前端加载速度 |\n| 🧭 规划中 | 在线播放功能 |\n| 🧭 规划中 | 简单的动画和页面切换效果 |\n| 🚍 部分 | 多语种支持 |\n| 🏢商业版功能 | 用户系统 |\n\n## 📦 使用方法\n\n### （新）使用应用模板一键部署\n这是最简单的方式，无需安装docker或下载项目，创建后可直接进入应用\n\n\n1. 通过雨云一键部署和启动本应用 (新用户有15天免费使用+首充双倍政策)\n\n[![通过雨云一键部署](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/7549/anionex_)\n\n2. 敬请期待\n\n\n### 使用 Docker Compose🐳\n通过docker compose快速启动前后端服务。\n\n<details>\n  <summary>📒 Windows/Mac用户说明</summary>\n\n如果你使用 **Windows 或 macOS**，请先安装 **Docker Desktop**，并确保 Docker 正在运行（Windows 可检查系统托盘图标；macOS 可检查菜单栏图标），然后按文档中的相同步骤操作。\n\n> **提示**：如果遇到问题，Windows 用户请在 Docker Desktop 设置中启用 **WSL 2 后端**（推荐）；同时确保端口 **3000** 和 **5000** 未被占用。\n\n</details>\n\n0. **克隆代码仓库**\n```bash\ngit clone https://github.com/Anionex/banana-slides\ncd banana-slides\n```\n\n1. **配置环境变量**\n\n创建 `.env` 文件（参考 `.env.example`）：\n```bash\ncp .env.example .env\n```\n\n编辑 `.env` 文件，配置必要的环境变量：\n> **项目中大模型接口以AIHubMix平台格式为标准，推荐使用 [AIHubMix(点击此处可直接访问)](https://aihubmix.com/?aff=17EC) 获取API密钥，减小迁移成本**<br>\n> **友情提示：谷歌nano banana pro模型接口费用较高，请注意调用成本**\n```env\n# AI Provider格式配置 (gemini / openai / vertex)\nAI_PROVIDER_FORMAT=gemini\n\n# Gemini 格式配置（当 AI_PROVIDER_FORMAT=gemini 时使用）\nGOOGLE_API_KEY=your-api-key-here\nGOOGLE_API_BASE=https://generativelanguage.googleapis.com\n# 代理示例: https://aihubmix.com/gemini\n\n# OpenAI 格式配置（当 AI_PROVIDER_FORMAT=openai 时使用）\nOPENAI_API_KEY=your-api-key-here\nOPENAI_API_BASE=https://api.openai.com/v1\n# 代理示例: https://aihubmix.com/v1\n\n# Vertex AI 配置（AI_PROVIDER_FORMAT=vertex）\n# 需要 GCP 项目和服务账户密钥\n# VERTEX_PROJECT_ID=your-gcp-project-id\n# VERTEX_LOCATION=global\n# GOOGLE_APPLICATION_CREDENTIALS=./gcp-service-account.json\n\n# Lazyllm 格式配置（当 AI_PROVIDER_FORMAT=lazyllm 时使用）\n# 选择文本生成和图片生成使用的厂商\nTEXT_MODEL_SOURCE=deepseek        # 文本生成模型厂商\nIMAGE_MODEL_SOURCE=doubao         # 图片编辑模型厂商\nIMAGE_CAPTION_MODEL_SOURCE=qwen   # 图片描述模型厂商\n\n# 各厂商 API Key（只需配置你要使用的厂商）\nDOUBAO_API_KEY=your-doubao-api-key            # 火山引擎/豆包\nDEEPSEEK_API_KEY=your-deepseek-api-key        # DeepSeek\nQWEN_API_KEY=your-qwen-api-key                # 阿里云/通义千问\nGLM_API_KEY=your-glm-api-key                  # 智谱 GLM\nSILICONFLOW_API_KEY=your-siliconflow-api-key  # 硅基流动\nSENSENOVA_API_KEY=your-sensenova-api-key      # 商汤日日新\nMINIMAX_API_KEY=your-minimax-api-key          # MiniMax\n...\n```\n\n**使用新版可编辑导出配置方法，获得更好的可编辑导出效果**: 需在[百度智能云平台](https://console.bce.baidu.com/iam/#/iam/apikey/list)（点击此处进入）中获取API KEY，填写在.env文件中的BAIDU_API_KEY字段（有充足的免费使用额度）。详见https://github.com/Anionex/banana-slides/issues/121 中的说明\n\n\n<details>\n  <summary>📒 Vertex AI 配置指南（适用于 GCP 用户）</summary>\n\nGoogle Cloud Vertex AI 允许通过 GCP 服务账户调用 Gemini 模型，新用户可使用赠金额度。配置步骤：\n\n1. 前往 [GCP Console](https://console.cloud.google.com/)，创建一个服务账户并下载 JSON 格式的密钥文件\n2. 将密钥文件保存为项目根目录下的 `gcp-service-account.json`\n3. 在 `.env` 中设置：\n   ```env\n   AI_PROVIDER_FORMAT=vertex\n   VERTEX_PROJECT_ID=your-gcp-project-id\n   VERTEX_LOCATION=global\n   ```\n4. 如果使用 Docker 部署，还需要在 `docker-compose.yml` 中取消相关注释，将密钥文件挂载到容器内并设置 `GOOGLE_APPLICATION_CREDENTIALS` 环境变量。\n\n> `gemini-3-*` 系列模型要求 `VERTEX_LOCATION=global`\n\n</details>\n\n2. **启动服务**\n\n**⚡ 使用预构建镜像（推荐）**\n\n项目在 Docker Hub 提供了构建好的前端和后端镜像（同步主分支最新版本），可以跳过本地构建步骤，实现快速部署：\n\n```bash\n# 使用预构建镜像启动（无需从头构建）\ndocker compose -f docker-compose.prod.yml up -d\n```\n\n镜像名称：\n- `anoinex/banana-slides-frontend:latest`\n- `anoinex/banana-slides-backend:latest`\n\n**从头构建镜像**\n\n```bash\ndocker compose up -d\n```\n\n\n> [!TIP]\n> 如遇网络问题，可在 `.env` 文件中取消镜像源配置的注释, 再重新运行启动命令：\n> ```env\n> # 在 .env 文件中取消以下注释即可使用国内镜像源\n> DOCKER_REGISTRY=docker.1ms.run/\n> GHCR_REGISTRY=ghcr.nju.edu.cn/\n> APT_MIRROR=mirrors.aliyun.com\n> PYPI_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple\n> NPM_REGISTRY=https://registry.npmmirror.com/\n> ```\n\n\n3. **访问应用**\n\n- 前端：http://localhost:3000\n- 后端 API：http://localhost:5000\n\n4. **查看日志**\n\n```bash\n# 查看后端日志（最后 200 行）\ndocker logs --tail 200 banana-slides-backend\n\n# 实时查看后端日志（最后 100 行）\ndocker logs -f --tail 100 banana-slides-backend\n\n# 查看前端日志（最后 100 行）\ndocker logs --tail 100 banana-slides-frontend\n```\n\n5. **停止服务**\n\n```bash\ndocker compose down\n```\n\n6. **更新项目**\n\n**使用预构建镜像（docker-compose.prod.yml）**\n\n```bash\ndocker compose -f docker-compose.prod.yml pull\ndocker compose -f docker-compose.prod.yml up -d\n```\n\n**使用本地构建（docker-compose.yml）**\n\n```bash\ngit pull\ndocker compose down\ndocker compose build --no-cache\ndocker compose up -d\n```\n\n**注：感谢优秀开发者朋友 [@ShellMonster](https://github.com/ShellMonster/) 提供了[新人部署教程](https://github.com/ShellMonster/banana-slides/blob/docs-deploy-tutorial/docs/NEWBIE_DEPLOYMENT.md)，专为没有任何服务器部署经验的新手设计，可[点击链接](https://github.com/ShellMonster/banana-slides/blob/docs-deploy-tutorial/docs/NEWBIE_DEPLOYMENT.md)查看。**\n\n### 从源码部署\n\n#### 环境要求\n- Python 3.10 或更高版本\n- [uv](https://github.com/astral-sh/uv) - Python 包管理器\n- Node.js 16+ 和 npm\n- 有效的 Google Gemini API 密钥\n- （可选）[LibreOffice](https://www.libreoffice.org/) - 使用「PPT 翻新」功能上传 PPTX 文件时需要，用于将 PPTX 转换为 PDF。**推荐先在本地将 PPTX 转为 PDF 后再上传**，原因：LibreOffice 在服务端渲染时可能因缺少字体（如微软雅黑、Calibri 等）导致排版错位，且无法完整还原部分特效。上传 PDF 文件则不需要 LibreOffice。Docker 用户如仍需在容器内支持 PPTX 上传，可执行：\n  ```bash\n  docker exec -it banana-slides-backend bash -c \"apt-get update && apt-get install -y libreoffice-impress && rm -rf /var/lib/apt/lists/*\"\n  ```\n  > 注意：此方式安装的 LibreOffice 在容器重建后会丢失，需重新安装。\n\n#### 后端安装\n\n0. **克隆代码仓库**\n```bash\ngit clone https://github.com/Anionex/banana-slides\ncd banana-slides\n```\n\n1. **安装 uv（如果尚未安装）**\n```bash\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n```\n\n2. **安装依赖**\n\n在项目根目录下运行：\n```bash\nuv sync\n```\n\n这将根据 `pyproject.toml` 自动安装所有依赖。\n\n3. **配置环境变量**\n\n复制环境变量模板：\n```bash\ncp .env.example .env\n```\n\n编辑 `.env` 文件，配置你的 API 密钥：\n> **项目中大模型接口以AIHubMix平台格式为标准，推荐使用 [AIHubMix](https://aihubmix.com/?aff=17EC) 获取API密钥，减小迁移成本** \n```env\n# AI Provider格式配置 (gemini / openai / vertex)\nAI_PROVIDER_FORMAT=gemini\n\n# Gemini 格式配置（当 AI_PROVIDER_FORMAT=gemini 时使用）\nGOOGLE_API_KEY=your-api-key-here\nGOOGLE_API_BASE=https://generativelanguage.googleapis.com\n# 代理示例: https://aihubmix.com/gemini\n\n# OpenAI 格式配置（当 AI_PROVIDER_FORMAT=openai 时使用）\nOPENAI_API_KEY=your-api-key-here\nOPENAI_API_BASE=https://api.openai.com/v1\n# 代理示例: https://aihubmix.com/v1\n\n# Vertex AI 配置（AI_PROVIDER_FORMAT=vertex）\n# 需要 GCP 项目和服务账户密钥\n# VERTEX_PROJECT_ID=your-gcp-project-id\n# VERTEX_LOCATION=global\n# GOOGLE_APPLICATION_CREDENTIALS=./gcp-service-account.json\n\n# 可修改此变量来控制后端服务端口\nBACKEND_PORT=5000\n...\n```\n\n#### 前端安装\n\n1. **进入前端目录**\n```bash\ncd frontend\n```\n\n2. **安装依赖**\n```bash\nnpm install\n```\n\n3. **配置API地址**\n\n前端会自动连接到 `http://localhost:5000` 的后端服务。如需修改，请编辑 `src/api/client.ts`。\n\n\n#### 启动后端服务\n> （可选）如果本地已有重要数据，升级前建议先备份数据库：  \n> `cp backend/instance/database.db backend/instance/database.db.bak`\n\n```bash\ncd backend\nuv run alembic upgrade head && uv run python app.py\n```\n\n后端服务将在 `http://localhost:5000` 启动。\n\n访问 `http://localhost:5000/health` 验证服务是否正常运行。\n\n#### 启动前端开发服务器\n\n```bash\ncd frontend\nnpm run dev\n```\n\n前端开发服务器将在 `http://localhost:3000` 启动。\n\n打开浏览器访问即可使用应用。\n\n\n## 🛠️ 技术架构\n\n### 前端技术栈\n- **框架**：React 18 + TypeScript\n- **构建工具**：Vite 5\n- **状态管理**：Zustand\n- **路由**：React Router v6\n- **UI组件**：Tailwind CSS\n- **拖拽功能**：@dnd-kit\n- **图标**：Lucide React\n- **HTTP客户端**：Axios\n\n### 后端技术栈\n- **语言**：Python 3.10+\n- **框架**：Flask 3.0\n- **包管理**：uv\n- **数据库**：SQLite + Flask-SQLAlchemy\n- **AI能力**：Google Gemini API\n- **PPT处理**：python-pptx\n- **图片处理**：Pillow\n- **并发处理**：ThreadPoolExecutor\n- **跨域支持**：Flask-CORS\n\n## 📁 项目结构\n\n```\nbanana-slides/\n├── frontend/                    # React前端应用\n│   ├── src/\n│   │   ├── pages/              # 页面组件\n│   │   │   ├── Home.tsx        # 首页（创建项目）\n│   │   │   ├── OutlineEditor.tsx    # 大纲编辑页\n│   │   │   ├── DetailEditor.tsx     # 详细描述编辑页\n│   │   │   ├── SlidePreview.tsx     # 幻灯片预览页\n│   │   │   └── History.tsx          # 历史版本管理页\n│   │   ├── components/         # UI组件\n│   │   │   ├── outline/        # 大纲相关组件\n│   │   │   │   └── OutlineCard.tsx\n│   │   │   ├── preview/        # 预览相关组件\n│   │   │   │   ├── SlideCard.tsx\n│   │   │   │   └── DescriptionCard.tsx\n│   │   │   ├── shared/         # 共享组件\n│   │   │   │   ├── Button.tsx\n│   │   │   │   ├── Card.tsx\n│   │   │   │   ├── Input.tsx\n│   │   │   │   ├── Textarea.tsx\n│   │   │   │   ├── Modal.tsx\n│   │   │   │   ├── Loading.tsx\n│   │   │   │   ├── Toast.tsx\n│   │   │   │   ├── Markdown.tsx\n│   │   │   │   ├── MaterialSelector.tsx\n│   │   │   │   ├── MaterialGeneratorModal.tsx\n│   │   │   │   ├── TemplateSelector.tsx\n│   │   │   │   ├── ReferenceFileSelector.tsx\n│   │   │   │   └── ...\n│   │   │   ├── layout/         # 布局组件\n│   │   │   └── history/        # 历史版本组件\n│   │   ├── store/              # Zustand状态管理\n│   │   │   └── useProjectStore.ts\n│   │   ├── api/                # API接口\n│   │   │   ├── client.ts       # Axios客户端配置\n│   │   │   └── endpoints.ts    # API端点定义\n│   │   ├── types/              # TypeScript类型定义\n│   │   ├── utils/              # 工具函数\n│   │   ├── constants/          # 常量定义\n│   │   └── styles/             # 样式文件\n│   ├── public/                 # 静态资源\n│   ├── package.json\n│   ├── vite.config.ts\n│   ├── tailwind.config.js      # Tailwind CSS配置\n│   ├── Dockerfile\n│   └── nginx.conf              # Nginx配置\n│\n├── backend/                    # Flask后端应用\n│   ├── app.py                  # Flask应用入口\n│   ├── config.py               # 配置文件\n│   ├── models/                 # 数据库模型\n│   │   ├── project.py          # Project模型\n│   │   ├── page.py             # Page模型（幻灯片页）\n│   │   ├── task.py             # Task模型（异步任务）\n│   │   ├── material.py         # Material模型（参考素材）\n│   │   ├── user_template.py    # UserTemplate模型（用户模板）\n│   │   ├── reference_file.py   # ReferenceFile模型（参考文件）\n│   │   ├── page_image_version.py # PageImageVersion模型（页面版本）\n│   ├── services/               # 服务层\n│   │   ├── ai_service.py       # AI生成服务（Gemini集成）\n│   │   ├── file_service.py     # 文件管理服务\n│   │   ├── file_parser_service.py # 文件解析服务\n│   │   ├── export_service.py   # PPTX/PDF导出服务\n│   │   ├── task_manager.py     # 异步任务管理\n│   │   ├── prompts.py          # AI提示词模板\n│   ├── controllers/            # API控制器\n│   │   ├── project_controller.py      # 项目管理\n│   │   ├── page_controller.py         # 页面管理\n│   │   ├── material_controller.py     # 素材管理\n│   │   ├── template_controller.py     # 模板管理\n│   │   ├── reference_file_controller.py # 参考文件管理\n│   │   ├── export_controller.py       # 导出功能\n│   │   └── file_controller.py         # 文件上传\n│   ├── utils/                  # 工具函数\n│   │   ├── response.py         # 统一响应格式\n│   │   ├── validators.py       # 数据验证\n│   │   └── path_utils.py       # 路径处理\n│   ├── instance/               # SQLite数据库（自动生成）\n│   ├── exports/                # 导出文件目录\n│   ├── Dockerfile\n│   └── README.md\n│\n├── tests/                      # 测试文件目录\n├── v0_demo/                    # 早期演示版本\n├── output/                     # 输出文件目录\n│\n├── pyproject.toml              # Python项目配置（uv管理）\n├── uv.lock                     # uv依赖锁定文件\n├── docker-compose.yml          # Docker Compose配置\n├── .env.example                 # 环境变量示例\n├── LICENSE                     # 许可证\n└── README.md                   # 本文件\n```\n\n## 交流群\n为了方便大家沟通互助，建此微信交流群.\n\n欢迎提出新功能建议或反馈，本人也会~~佛系~~回答大家问题\n\n\n\n\n<img width=\"302\" src=\"https://private-user-images.githubusercontent.com/123177548/563407714-01f84824-a8f5-4858-97c2-a7e65d70755e.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NzM0NjQ5MTksIm5iZiI6MTc3MzQ2NDYxOSwicGF0aCI6Ii8xMjMxNzc1NDgvNTYzNDA3NzE0LTAxZjg0ODI0LWE4ZjUtNDg1OC05N2MyLWE3ZTY1ZDcwNzU1ZS5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjYwMzE0JTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI2MDMxNFQwNTAzMzlaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT1jYjQxMzQ4ZjIxNjQ2MjIwMTI2NGUzMDFmMDQ2M2Q1Y2MwNDE4OGVkMTdmYTlmMzk1NzcxYmZkMDEzMTBhNzY2JlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.RMmYdJClH6y7tdMuET73o7UzJ-bCONxmF6IjpvUBmLw\">\n\n\n\n\n\n\n\n\n## **🔧 常见问题**\n\n1. **生成页面文字有乱码，文字不清晰**\n    - 可选择更高分辨率的输出（openai 格式可能不支持调高分辨率，建议使用gemini格式）。根据测试，生成页面前将 1k 分辨率调整至 2k 后，文字渲染质量会显著提升。\n    - 请确保在页面描述中包含具体要渲染的文字内容。\n\n2. **导出可编辑 ppt 效果不佳，如文字重叠、无样式等**\n    - 90% 情况为 API 配置出现问题。可以参考 [issue 121](https://github.com/Anionex/banana-slides/issues/121) 中的排查与解决方案。\n\n3. **支持免费层级的 Gemini API Key 吗？**\n    - 免费层级只支持文本生成，不支持图片生成。\n\n4. **生成内容时提示 503 错误或 Retry Error**\n    - 可以根据 README 中的命令查看 Docker 后端日志，定位 503 问题的详细报错，一般是模型配置不正确导致。\n\n5. **.env 中设置了 API Key 之后，为什么不生效？**\n    - 运行时编辑 `.env` 后需要重启 Docker 容器以应用更改。\n    - 如果曾在网页设置页中配置参数，会覆盖 `.env` 中的参数，可通过\"还原默认设置\"恢复为 `.env` 设置。\n\n\n## 🤝 贡献指南\n\n欢迎通过\n[Issue](https://github.com/Anionex/banana-slides/issues)\n和\n[Pull Request](https://github.com/Anionex/banana-slides/pulls)\n为本项目贡献力量！\n\n> **重要：** 贡献前请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)\n\n## 📄 许可证\n\n本项目采用 **GNU Affero General Public License v3.0（AGPL-3.0）** 开源，\n可自由用于个人学习、研究、试验、教育或非营利科研活动等非商业用途；\n<details> \n<summary> 详情 </summary>\n需要商业许可证（Commercial License）（例如：希望闭源使用、私有化部署交付、将本项目集成进闭源产品，或在不公开对应源代码的前提下提供服务），请联系作者：anionex@qq.com\n- 联系方式：anionex@qq.com\n</details>\n\n\n\n<h2>🚀 Sponsor / 赞助 </h2>\n<br>\n<div align=\"center\">\n<a href=\"https://aihubmix.com/?aff=17EC\">\n  <img src=\"./assets/logo_aihubmix.png\" alt=\"AIHubMix\" style=\"height:48px;\">\n</a>\n<p>感谢AIHubMix对本项目的赞助</p>\n</div>\n\n\n<div align=\"center\">\n\n <br>\n\n<a href=\"https://api.chatfire.site/login?inviteCode=A15CD6A0\"><img width=\"200\" alt=\"image\" src=\"https://github.com/user-attachments/assets/d6bd255f-ba2c-4ea3-bd90-fef292fc3397\" />\n</a>\n\n\n<details>\n  <summary>感谢<a href=\"https://api.chatfire.site/login?inviteCode=A15CD6A0\">AI火宝</a>对本项目的赞助</summary>\n  “聚合全球多模型API服务商。更低价格享受安全、稳定且72小时链接全球最新模型的服务。”\n</details>\n \n</div>\n\n\n\n## 致谢\n\n- 项目贡献者们：\n\n[![Contributors](https://contrib.rocks/image?repo=Anionex/banana-slides)](https://github.com/Anionex/banana-slides/graphs/contributors)\n\n- [Linux.do](https://linux.do/): 新的理想型社区\n  \n## 赞赏\n\n开源不易🙏如果本项目对你有价值，欢迎请开发者喝杯咖啡☕️\n\n<img width=\"240\" alt=\"image\" src=\"https://github.com/user-attachments/assets/fd7a286d-711b-445e-aecf-43e3fe356473\" />\n\n感谢以下朋友对项目的无偿赞助支持：\n> @雅俗共赏、@曹峥、@以年观日、@John、@胡yun星Ethan, @azazo1、@刘聪NLP、@🍟、@苍何、@万瑾、@biubiu、@law、@方源、@寒松Falcon\n> 如对赞助列表有疑问，可<a href=\"mailto:anionex@qq.com\">联系作者</a>\n \n## 📈 项目统计\n\n<a href=\"https://www.star-history.com/#Anionex/banana-slides&type=Timeline&legend=top-left\">\n\n <picture>\n\n   <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=Anionex/banana-slides&type=Timeline&theme=dark&legend=top-left\" />\n\n   <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=Anionex/banana-slides&type=Timeline&legend=top-left\" />\n\n   <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=Anionex/banana-slides&type=Timeline&legend=top-left\" />\n\n </picture>\n\n</a>\n\n<br>\n"
  },
  {
    "path": "backend/.gitignore",
    "content": "# Python\n__pycache__/\n*.py[cod]\n*$py.class\n*.so\n.Python\nenv/\nvenv/\nENV/\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\n*.egg-info/\n.installed.cfg\n*.egg\n\n# Flask\ninstance/\n.webassets-cache\n\n# Environment\n.env\n.env.local\n\n# Uploads\nuploads/\n*.db\n\n# IDE\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n\n# OS\n.DS_Store\nThumbs.db\n\n"
  },
  {
    "path": "backend/Dockerfile",
    "content": "# 镜像源配置参数（可通过 build args 覆盖）\nARG DOCKER_REGISTRY=\nARG GHCR_REGISTRY=ghcr.io/\nARG APT_MIRROR=\nARG PYPI_INDEX_URL=\n\n# 安装 uv（使用中间阶段避免 COPY --from 的变量展开问题）\nFROM ${GHCR_REGISTRY}astral-sh/uv:latest AS uv\n\n# 使用 Python 3.10 作为基础镜像\n# 如果指定了 DOCKER_REGISTRY，使用镜像源；否则使用官方源\nFROM ${DOCKER_REGISTRY:-}python:3.10-slim\n\n# 重新声明ARG（FROM之后ARG作用域失效，需要重新声明）\nARG APT_MIRROR=\nARG PYPI_INDEX_URL=\n\n# 设置工作目录\nWORKDIR /app\n\n# 安装系统依赖（如果配置了 APT_MIRROR，先替换镜像源）\nRUN if [ -n \"$APT_MIRROR\" ]; then \\\n        if [ -f /etc/apt/sources.list.d/debian.sources ]; then \\\n            sed -i \"s@deb.debian.org@$APT_MIRROR@g\" /etc/apt/sources.list.d/debian.sources; \\\n        else \\\n            echo \"Warning: /etc/apt/sources.list.d/debian.sources not found, skipping mirror setup.\" >&2; \\\n        fi; \\\n    fi && \\\n    apt-get update && apt-get install -y \\\n    curl \\\n    && rm -rf /var/lib/apt/lists/*\n\n# 从 uv 阶段复制二进制文件\nCOPY --from=uv /uv /usr/local/bin/uv\nRUN chmod +x /usr/local/bin/uv\n\n# 复制项目配置文件\nCOPY pyproject.toml ./\nCOPY uv.lock* ./\n\n# 配置 PyPI 镜像源（如果指定）\nENV UV_INDEX_URL=${PYPI_INDEX_URL}\n\n# 配置 uv 网络超时\nENV UV_HTTP_TIMEOUT=300\n\n# 安装 Python 依赖\n# 如果有 uv.lock 文件则使用 --frozen，否则生成新的锁定文件\nRUN if [ -f uv.lock ]; then \\\n        uv sync --frozen; \\\n    else \\\n        uv sync; \\\n    fi\n\n# 复制后端代码\nCOPY backend/ ./backend/\n\n# 复制测试资源\nCOPY assets/ ./assets/\n\n# 创建必要的目录\nRUN mkdir -p /app/backend/instance /app/uploads\n\nENV PYTHONPATH=/app\nENV FLASK_APP=backend/app.py\n# 容器内固定监听 5000；宿主机端口由 docker-compose 的 BACKEND_PORT 控制\nENV IN_DOCKER=1\n\n# 暴露端口\nEXPOSE 5000\n\n# 容器内部固定使用 5000 端口\nHEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\\n    CMD [\"sh\", \"-c\", \"curl -f http://localhost:5000/health || exit 1\"]\n\n# 启动应用\nCMD [\"sh\", \"-c\", \"uv run --directory backend alembic upgrade head && uv run --directory backend python app.py\"]\n\n"
  },
  {
    "path": "backend/README.md",
    "content": "# Banana Slides Backend\n\n蕉幻（Banana Slides）后端服务 - AI驱动的PPT生成系统\n\n## 技术栈\n\n- **框架**: Flask 3.0\n- **数据库**: SQLite + SQLAlchemy ORM\n- **AI服务**: Google Gemini API\n- **PPT处理**: python-pptx\n- **并发处理**: ThreadPoolExecutor\n- **包管理**: uv\n\n## 项目结构\n\n```\nbackend/\n├── app.py                    # Flask应用入口\n├── config.py                 # 配置文件\n├── models/                   # 数据库模型\n│   ├── __init__.py\n│   ├── project.py           # Project模型\n│   ├── page.py              # Page模型\n│   └── task.py              # Task模型\n├── services/                 # 服务层\n│   ├── __init__.py\n│   ├── ai_service.py        # AI相关服务\n│   ├── file_service.py      # 文件管理服务\n│   ├── export_service.py    # 导出服务\n│   └── task_manager.py      # 异步任务管理\n├── controllers/              # 控制器层\n│   ├── __init__.py\n│   ├── project_controller.py\n│   ├── page_controller.py\n│   ├── template_controller.py\n│   ├── export_controller.py\n│   └── file_controller.py\n├── utils/                    # 工具函数\n│   ├── __init__.py\n│   ├── response.py          # 统一响应格式\n│   └── validators.py        # 数据验证\n├── instance/                 # 数据库文件目录（自动创建）\n├── uploads/                  # 文件上传目录（自动创建）\n├── .env.example             # 环境变量示例\n└── README.md                # 本文件\n```\n\n## 快速开始\n\n### 1. 安装依赖\n\n本项目使用 [uv](https://github.com/astral-sh/uv) 管理 Python 依赖。所有依赖定义在项目根目录的 `pyproject.toml` 文件中。\n\n在项目根目录下运行：\n```bash\nuv sync\n```\n\n这将自动安装所有必需的依赖包。\n\n### 2. 配置环境变量\n\n复制 `.env.example` 为 `.env` 并填写配置：\n\n```bash\ncp .env.example .env\n```\n\n编辑 `.env` 文件：\n\n```env\nGOOGLE_API_KEY=your-google-api-key\nGOOGLE_API_BASE=https://generativelanguage.googleapis.com\n\n# 火山引擎配置（可选，用于 Inpainting 图像消除功能）\nVOLCENGINE_ACCESS_KEY=your-volcengine-access-key\nVOLCENGINE_SECRET_KEY=your-volcengine-secret-key\nVOLCENGINE_INPAINTING_TIMEOUT=60\nVOLCENGINE_INPAINTING_MAX_RETRIES=3\n```\n\n### 3. 初始化 / 升级数据库结构（Alembic 迁移）\n\n从当前版本开始，后端使用 Alembic 管理数据库结构变更。\n\n```bash\ncd backend\nuv run alembic upgrade head\n```\n\n> 注意：  \n> - 首次运行时会自动创建 `alembic_version` 表并将数据库迁移到最新结构；  \n> - 后续新增模型字段时，只需要更新 `models/`，然后使用 `alembic revision --autogenerate` 生成迁移，再执行 `alembic upgrade head`。\n\n### 4. 运行服务\n\n使用 uv 运行：\n```bash\ncd backend\nuv run python app.py\n```\n服务将在 `http://localhost:5000` 启动。\n\n## API文档\n\n完整的API文档请参考项目根目录的 `API设计文档.md`。\n\n### 主要端点\n\n#### 项目管理\n- `POST /api/projects` - 创建项目\n- `GET /api/projects/{project_id}` - 获取项目详情\n- `PUT /api/projects/{project_id}` - 更新项目\n- `DELETE /api/projects/{project_id}` - 删除项目\n\n#### 大纲生成\n- `POST /api/projects/{project_id}/generate/outline` - 生成大纲\n\n#### 描述生成\n- `POST /api/projects/{project_id}/generate/descriptions` - 批量生成描述（异步）\n- `POST /api/projects/{project_id}/pages/{page_id}/generate/description` - 单页生成\n\n#### 图片生成\n- `POST /api/projects/{project_id}/generate/images` - 批量生成图片（异步）\n- `POST /api/projects/{project_id}/pages/{page_id}/generate/image` - 单页生成\n- `POST /api/projects/{project_id}/pages/{page_id}/edit/image` - 编辑图片\n\n#### 模板管理\n- `POST /api/projects/{project_id}/template` - 上传模板\n- `DELETE /api/projects/{project_id}/template` - 删除模板\n\n#### 导出\n- `GET /api/projects/{project_id}/export/pptx` - 导出PPTX\n- `GET /api/projects/{project_id}/export/pdf` - 导出PDF\n\n#### 静态文件\n- `GET /files/{project_id}/{type}/{filename}` - 获取文件\n\n## 核心功能\n\n### 1. AI驱动的内容生成\n\n基于 Google Gemini API，支持：\n- 自动生成PPT大纲\n- 并行生成页面描述\n- 根据参考模板生成图片\n- 自然语言编辑图片\n\n### 2. 异步任务处理\n\n使用 `ThreadPoolExecutor` 实现简单但高效的异步任务处理：\n- 并行生成多个页面描述\n- 并行生成多个页面图片\n- 实时任务进度跟踪\n\n### 3. 文件管理\n\n完整的文件管理系统：\n- 项目级文件隔离\n- 模板图片管理\n- 生成图片管理\n- 自动清理机制\n\n### 4. Inpainting 图像消除（可选）\n\n基于火山引擎的 Inpainting 服务，支持：\n- 根据边界框（bbox）精确消除图像区域\n- 自动生成掩码图像\n- 重新生成背景（保留前景，消除其他区域）\n- 支持批量处理和重试机制\n\n使用方法：\n```python\nfrom services.inpainting_service import InpaintingService, remove_regions\nfrom PIL import Image\n\n# 方式1：使用服务类\nservice = InpaintingService()\nimage = Image.open('original.png')\nbboxes = [(100, 100, 200, 200), (300, 150, 400, 250)]  # 要消除的区域\nresult = service.remove_regions_by_bboxes(image, bboxes)\n\n# 方式2：使用便捷函数\nresult = remove_regions(image, bboxes, expand_pixels=5)\n```\n\n### 5. 数据持久化\n\n使用 SQLite + SQLAlchemy：\n- 轻量级，无需额外配置\n- 支持关系型数据操作\n- 事务保证数据一致性\n\n## 开发说明\n\n### 数据模型\n\n#### Project（项目）\n- 项目基本信息\n- 模板图片路径\n- 项目状态\n- 关联的页面和任务\n\n#### Page（页面）\n- 页面顺序\n- 大纲内容（JSON）\n- 描述内容（JSON）\n- 生成的图片路径\n- 页面状态\n\n#### Task（任务）\n- 任务类型（生成描述/生成图片）\n- 任务状态\n- 进度信息（JSON）\n- 错误信息\n\n### 状态机\n\n#### 项目状态\n```\nDRAFT → OUTLINE_GENERATED → DESCRIPTIONS_GENERATED → GENERATING_IMAGES → COMPLETED\n```\n\n#### 页面状态\n```\nDRAFT → DESCRIPTION_GENERATED → GENERATING → COMPLETED | FAILED\n```\n\n#### 任务状态\n```\nPENDING → PROCESSING → COMPLETED | FAILED\n```\n\n### 扩展开发\n\n#### 添加新的AI模型\n\n在 `services/ai_service.py` 中添加新的模型支持：\n\n```python\nclass AIService:\n    def __init__(self, api_key: str, model_type: str = 'gemini'):\n        if model_type == 'gemini':\n            # Gemini implementation\n        elif model_type == 'openai':\n            # OpenAI implementation\n        # ...\n```\n\n#### 自定义提示词模板\n\n修改 `services/ai_service.py` 中的提示词生成逻辑：\n\n```python\ndef generate_image_prompt(self, ...):\n    prompt = dedent(f\"\"\"\n        # 自定义提示词模板\n        ...\n    \"\"\")\n    return prompt\n```\n\n#### 添加新的导出格式\n\n在 `services/export_service.py` 中添加新的导出方法：\n\n```python\nclass ExportService:\n    @staticmethod\n    def create_custom_format(image_paths, output_file):\n        # 实现自定义格式导出\n        pass\n```\n\n\n## 测试\n\n### 健康检查\n\n```bash\ncurl http://localhost:5000/health\n```\n\n### 创建项目\n\n```bash\ncurl -X POST http://localhost:5000/api/projects \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"creation_type\":\"idea\",\"idea_prompt\":\"生成环保主题ppt\"}'\n```\n\n### 上传模板\n\n```bash\ncurl -X POST http://localhost:5000/api/projects/{project_id}/template \\\n  -F \"template_image=@template.png\"\n```\n\n### 生成大纲\n\n```bash\ncurl -X POST http://localhost:5000/api/projects/{project_id}/generate/outline \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"idea_prompt\":\"生成环保主题ppt\"}'\n```\n\n## 常见问题\n\n### Q: 数据库文件在哪里？\nA: 在 `backend/instance/database.db`，会自动创建。\n\n### Q: 上传的文件存在哪里？\nA: 在 `uploads/{project_id}/` 目录下，按项目隔离。\n\n### Q: 如何修改并发数？\nA: 推荐通过前端设置页修改（会同步到数据库并覆盖 `.env` 值）；也可以在 `.env` 文件中修改 `MAX_DESCRIPTION_WORKERS` 和 `MAX_IMAGE_WORKERS` 作为默认值，然后在设置页点击“重置为默认值”同步到 DB。\n\n### Q: 如何切换到其他AI模型 / 修改 MinerU 地址？\nA: 从当前版本开始，推荐通过前端“系统设置”页面修改：  \n- 大模型提供商格式 / API Base / API Key  \n- 文本模型 (`TEXT_MODEL`) / 图片模型 (`IMAGE_MODEL`)  \n- MinerU 地址 (`MINERU_API_BASE`) / 图片识别模型 (`IMAGE_CAPTION_MODEL`)  \n\n这些值会保存到 `settings` 表并覆盖 `.env` 中对应配置，点击“重置为默认值”会回到 `.env` 的默认值。\n\n### Q: 支持哪些图片格式？\nA: PNG, JPG, JPEG, GIF, WEBP。在 `config.py` 中的 `ALLOWED_EXTENSIONS` 配置。\n\n\n## 开源字体说明\n\n本项目包含 **Noto Sans CJK SC**（思源黑体简体中文）字体文件，用于 PPT 导出时的精确文本测量。\n\n- **字体文件**: `fonts/NotoSansSC-Regular.ttf`\n- **来源**: [Google Noto CJK Fonts](https://github.com/googlefonts/noto-cjk)\n- **许可证**: [SIL Open Font License 1.1 (OFL)](https://scripts.sil.org/OFL)\n\nOFL 许可证允许自由使用、修改和分发该字体。\n\n## 联系方式\n\n如有问题或建议，请通过 GitHub Issues 反馈。\n\n"
  },
  {
    "path": "backend/alembic.ini",
    "content": "[alembic]\nscript_location = migrations\nsqlalchemy.url = sqlite:///placeholder.db\n\n[loggers]\nkeys = root,sqlalchemy,alembic\n\n[handlers]\nkeys = console\n\n[formatters]\nkeys = generic\n\n[logger_root]\nlevel = WARN\nhandlers = console\n\n[logger_sqlalchemy]\nlevel = WARN\nhandlers =\nqualname = sqlalchemy.engine\n\n[logger_alembic]\nlevel = INFO\nhandlers =\nqualname = alembic\n\n[handler_console]\nclass = StreamHandler\nargs = (sys.stderr,)\nlevel = NOTSET\nformatter = generic\n\n[formatter_generic]\nformat = %(levelname)-5.5s [%(name)s] %(message)s\ndatefmt = %H:%M:%S\n\n\n\n"
  },
  {
    "path": "backend/app.py",
    "content": "\"\"\"\nSimplified Flask Application Entry Point\n\"\"\"\nimport os\nimport sys\nimport hmac\nimport logging\nfrom pathlib import Path\nfrom dotenv import load_dotenv\nfrom sqlalchemy import event\nfrom sqlalchemy.engine import Engine\nimport sqlite3\nfrom sqlalchemy.exc import SQLAlchemyError\nfrom flask_migrate import Migrate\n\n# Load environment variables from project root .env file\n_project_root = Path(__file__).parent.parent\n_env_file = _project_root / '.env'\nload_dotenv(dotenv_path=_env_file, override=True)\n\nfrom flask import Flask\nfrom flask_cors import CORS\nfrom models import db\nfrom config import Config\nfrom controllers.material_controller import material_bp, material_global_bp\nfrom controllers.reference_file_controller import reference_file_bp\nfrom controllers.settings_controller import settings_bp\nfrom controllers import project_bp, page_bp, template_bp, user_template_bp, export_bp, file_bp, style_bp\n\n\n# Enable SQLite WAL mode for all connections\n@event.listens_for(Engine, \"connect\")\ndef set_sqlite_pragma(dbapi_conn, connection_record):\n    \"\"\"\n    Enable WAL mode and related PRAGMAs for each SQLite connection.\n    Registered once at import time to avoid duplicate handlers when\n    create_app() is called multiple times.\n    \"\"\"\n    # Only apply to SQLite connections\n    if not isinstance(dbapi_conn, sqlite3.Connection):\n        return\n\n    cursor = dbapi_conn.cursor()\n    try:\n        cursor.execute(\"PRAGMA journal_mode=WAL\")\n        cursor.execute(\"PRAGMA synchronous=NORMAL\")\n        cursor.execute(\"PRAGMA busy_timeout=30000\")  # 30 seconds timeout\n    finally:\n        cursor.close()\n\n\ndef create_app():\n    \"\"\"Application factory\"\"\"\n    app = Flask(__name__)\n    \n    # Load configuration from Config class\n    app.config.from_object(Config)\n    \n    # Override with environment-specific paths (use absolute path)\n    backend_dir = os.path.dirname(os.path.abspath(__file__))\n    instance_dir = os.path.join(backend_dir, 'instance')\n    os.makedirs(instance_dir, exist_ok=True)\n    \n    db_path = os.path.join(instance_dir, 'database.db')\n    app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'\n    \n    # Ensure upload folder exists\n    project_root = os.path.dirname(backend_dir)\n    upload_folder = os.path.join(project_root, 'uploads')\n    os.makedirs(upload_folder, exist_ok=True)\n    app.config['UPLOAD_FOLDER'] = upload_folder\n    \n    # CORS configuration (parse from environment)\n    raw_cors = os.getenv('CORS_ORIGINS', 'http://localhost:3000')\n    if raw_cors.strip() == '*':\n        cors_origins = '*'\n    else:\n        cors_origins = [o.strip() for o in raw_cors.split(',') if o.strip()]\n    app.config['CORS_ORIGINS'] = cors_origins\n    \n    # Initialize logging (log to stdout so Docker can capture it)\n    log_level = getattr(logging, app.config['LOG_LEVEL'], logging.INFO)\n    logging.basicConfig(\n        level=log_level,\n        format=\"%(asctime)s [%(levelname)s] %(name)s - %(message)s\",\n        handlers=[logging.StreamHandler(sys.stdout)],\n    )\n    \n    # 设置第三方库的日志级别，避免过多的DEBUG日志\n    logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)\n    logging.getLogger('httpcore').setLevel(logging.WARNING)\n    logging.getLogger('httpx').setLevel(logging.WARNING)\n    logging.getLogger('urllib3').setLevel(logging.WARNING)\n    logging.getLogger('werkzeug').setLevel(logging.INFO)  # Flask开发服务器日志保持INFO\n    logging.getLogger('volcenginesdkarkruntime').setLevel(logging.WARNING)\n\n    # Initialize extensions\n    db.init_app(app)\n    CORS(app, origins=cors_origins)\n    # Database migrations (Alembic via Flask-Migrate)\n    Migrate(app, db)\n    \n    # Register blueprints\n    app.register_blueprint(project_bp)\n    app.register_blueprint(page_bp)\n    app.register_blueprint(template_bp)\n    app.register_blueprint(user_template_bp)\n    app.register_blueprint(export_bp)\n    app.register_blueprint(file_bp)\n    app.register_blueprint(material_bp)\n    app.register_blueprint(material_global_bp)\n    app.register_blueprint(reference_file_bp, url_prefix='/api/reference-files')\n    app.register_blueprint(settings_bp)\n    app.register_blueprint(style_bp)\n\n    with app.app_context():\n        # Load settings from database and sync to app.config\n        _load_settings_to_config(app)\n\n    # Access code enforcement on all /api/ routes\n    @app.before_request\n    def _enforce_access_code():\n        from flask import request, jsonify\n        expected = os.getenv('ACCESS_CODE', '').strip()\n        if not expected:\n            return  # not enabled\n        if not request.path.startswith('/api/'):\n            return  # non-API routes (health, static, etc.)\n        if request.path.startswith('/api/access-code/'):\n            return  # allow check/verify endpoints\n        code = request.headers.get('X-Access-Code', '')\n        if hmac.compare_digest(code, expected):\n            return\n        return jsonify({'error': 'Access code required'}), 403\n\n    # Health check endpoint\n    @app.route('/health')\n    def health_check():\n        return {'status': 'ok', 'message': 'Banana Slides API is running'}\n\n    # Access code verification\n    @app.route('/api/access-code/check', methods=['GET'])\n    def check_access_code():\n        \"\"\"Check if access code protection is enabled\"\"\"\n        enabled = bool(os.getenv('ACCESS_CODE', '').strip())\n        return {'data': {'enabled': enabled}}\n\n    @app.route('/api/access-code/verify', methods=['POST'])\n    def verify_access_code():\n        \"\"\"Verify the provided access code\"\"\"\n        from flask import request, jsonify\n        expected = os.getenv('ACCESS_CODE', '').strip()\n        if not expected:\n            return {'data': {'valid': True}}\n        code = (request.json or {}).get('code', '')\n        if hmac.compare_digest(code, expected):\n            return {'data': {'valid': True}}\n        return jsonify({'error': 'Invalid access code'}), 403\n    \n    # Output language endpoint\n    @app.route('/api/output-language', methods=['GET'])\n    def get_output_language():\n        \"\"\"\n        获取用户的输出语言偏好（从数据库 Settings 读取）\n        返回: zh, ja, en, auto\n        \"\"\"\n        from models import Settings\n        try:\n            settings = Settings.get_settings()\n            return {'data': {'language': settings.output_language or Config.OUTPUT_LANGUAGE}}\n        except SQLAlchemyError as db_error:\n            logging.warning(f\"Failed to load output language from settings: {db_error}\")\n            return {'data': {'language': Config.OUTPUT_LANGUAGE}}  # 默认中文\n\n    # Root endpoint\n    @app.route('/')\n    def index():\n        return {\n            'name': 'Banana Slides API',\n            'version': '1.0.0',\n            'description': 'AI-powered PPT generation service',\n            'endpoints': {\n                'health': '/health',\n                'api_docs': '/api',\n                'projects': '/api/projects'\n            }\n        }\n    \n    return app\n\n\ndef _load_settings_to_config(app):\n    \"\"\"Load settings from database and apply to app.config on startup\"\"\"\n    from models import Settings\n    try:\n        settings = Settings.get_settings()\n        \n        # Load AI provider format (always sync, has default value)\n        if settings.ai_provider_format:\n            app.config['AI_PROVIDER_FORMAT'] = settings.ai_provider_format\n            logging.info(f\"Loaded AI_PROVIDER_FORMAT from settings: {settings.ai_provider_format}\")\n        \n        # Load API configuration\n        # Note: We load even if value is None/empty to allow clearing settings\n        # But we only log if there's an actual value\n        if settings.api_base_url is not None:\n            # 将数据库中的统一 API Base 同步到 Google/OpenAI 两个配置，确保覆盖环境变量\n            app.config['GOOGLE_API_BASE'] = settings.api_base_url\n            app.config['OPENAI_API_BASE'] = settings.api_base_url\n            if settings.api_base_url:\n                logging.info(f\"Loaded API_BASE from settings: {settings.api_base_url}\")\n            else:\n                logging.info(\"API_BASE is empty in settings, using env var or default\")\n\n        if settings.api_key is not None:\n            # 同步到两个提供商的 key，数据库优先于环境变量\n            app.config['GOOGLE_API_KEY'] = settings.api_key\n            app.config['OPENAI_API_KEY'] = settings.api_key\n            if settings.api_key:\n                logging.info(\"Loaded API key from settings\")\n            else:\n                logging.info(\"API key is empty in settings, using env var or default\")\n\n        # Load image generation settings (fall back to .env/Config when NULL)\n        resolution = settings.image_resolution or Config.DEFAULT_RESOLUTION\n        aspect_ratio = settings.image_aspect_ratio or Config.DEFAULT_ASPECT_RATIO\n        app.config['DEFAULT_RESOLUTION'] = resolution\n        app.config['DEFAULT_ASPECT_RATIO'] = aspect_ratio\n        logging.info(f\"Loaded image settings: {resolution}, {aspect_ratio}\")\n\n        # Load worker settings (fall back to .env/Config when NULL)\n        desc_workers = settings.max_description_workers or Config.MAX_DESCRIPTION_WORKERS\n        img_workers = settings.max_image_workers or Config.MAX_IMAGE_WORKERS\n        app.config['MAX_DESCRIPTION_WORKERS'] = desc_workers\n        app.config['MAX_IMAGE_WORKERS'] = img_workers\n        logging.info(f\"Loaded worker settings: desc={desc_workers}, img={img_workers}\")\n\n        # Load model settings (FIX for Issue #136: these were missing before)\n        if settings.text_model:\n            app.config['TEXT_MODEL'] = settings.text_model\n            logging.info(f\"Loaded TEXT_MODEL from settings: {settings.text_model}\")\n        \n        if settings.image_model:\n            app.config['IMAGE_MODEL'] = settings.image_model\n            logging.info(f\"Loaded IMAGE_MODEL from settings: {settings.image_model}\")\n        \n        # Load MinerU settings\n        if settings.mineru_api_base:\n            app.config['MINERU_API_BASE'] = settings.mineru_api_base\n            logging.info(f\"Loaded MINERU_API_BASE from settings: {settings.mineru_api_base}\")\n        \n        if settings.mineru_token:\n            app.config['MINERU_TOKEN'] = settings.mineru_token\n            logging.info(\"Loaded MINERU_TOKEN from settings\")\n        \n        # Load image caption model\n        if settings.image_caption_model:\n            app.config['IMAGE_CAPTION_MODEL'] = settings.image_caption_model\n            logging.info(f\"Loaded IMAGE_CAPTION_MODEL from settings: {settings.image_caption_model}\")\n        \n        # Load output language\n        if settings.output_language:\n            app.config['OUTPUT_LANGUAGE'] = settings.output_language\n            logging.info(f\"Loaded OUTPUT_LANGUAGE from settings: {settings.output_language}\")\n        \n        # Load reasoning mode settings (separate for text and image)\n        app.config['ENABLE_TEXT_REASONING'] = settings.enable_text_reasoning\n        app.config['TEXT_THINKING_BUDGET'] = settings.text_thinking_budget\n        app.config['ENABLE_IMAGE_REASONING'] = settings.enable_image_reasoning\n        app.config['IMAGE_THINKING_BUDGET'] = settings.image_thinking_budget\n        logging.info(f\"Loaded reasoning config: text={settings.enable_text_reasoning}(budget={settings.text_thinking_budget}), image={settings.enable_image_reasoning}(budget={settings.image_thinking_budget})\")\n        \n        # Load Baidu API settings\n        if settings.baidu_api_key:\n            app.config['BAIDU_API_KEY'] = settings.baidu_api_key\n            logging.info(\"Loaded BAIDU_API_KEY from settings\")\n\n        # Load LazyLLM source settings\n        if settings.text_model_source:\n            app.config['TEXT_MODEL_SOURCE'] = settings.text_model_source\n            logging.info(f\"Loaded TEXT_MODEL_SOURCE from settings: {settings.text_model_source}\")\n        if settings.image_model_source:\n            app.config['IMAGE_MODEL_SOURCE'] = settings.image_model_source\n            logging.info(f\"Loaded IMAGE_MODEL_SOURCE from settings: {settings.image_model_source}\")\n        if settings.image_caption_model_source:\n            app.config['IMAGE_CAPTION_MODEL_SOURCE'] = settings.image_caption_model_source\n            logging.info(f\"Loaded IMAGE_CAPTION_MODEL_SOURCE from settings: {settings.image_caption_model_source}\")\n\n        # Load per-model API credentials (for gemini/openai per-model overrides)\n        for model_type in ('text', 'image', 'image_caption'):\n            prefix = model_type.upper()\n            for suffix, setting_suffix in [('_API_KEY', '_api_key'), ('_API_BASE', '_api_base_url')]:\n                config_key = f'{prefix}{suffix}'\n                val = getattr(settings, f'{model_type}{setting_suffix}', None)\n                if val:\n                    app.config[config_key] = val\n                    if suffix == '_API_BASE':\n                        logging.info(f\"Loaded {config_key} from settings: {val}\")\n                    else:\n                        logging.info(f\"Loaded {config_key} from settings\")\n\n        # Sync LazyLLM vendor API keys to environment variables\n        # Only allow known vendor names to prevent environment variable injection\n        from services.ai_providers.lazyllm_env import ALLOWED_LAZYLLM_VENDORS\n        if settings.lazyllm_api_keys:\n            import json\n            try:\n                keys = json.loads(settings.lazyllm_api_keys)\n                for vendor, key in keys.items():\n                    if key and vendor.lower() in ALLOWED_LAZYLLM_VENDORS:\n                        os.environ[f\"{vendor.upper()}_API_KEY\"] = key\n                    elif key:\n                        logging.warning(f\"Ignoring unknown lazyllm vendor: {vendor}\")\n                logging.info(f\"Loaded LazyLLM API keys for vendors: {[v for v, k in keys.items() if k and v.lower() in ALLOWED_LAZYLLM_VENDORS]}\")\n            except (json.JSONDecodeError, TypeError):\n                logging.warning(\"Failed to parse lazyllm_api_keys from settings\")\n\n    except Exception as e:\n        if isinstance(e, SQLAlchemyError) and \"no such table: settings\" in str(e):\n            logging.debug(f\"Settings table not yet created (expected on first boot): {e}\")\n        else:\n            logging.warning(f\"Could not load settings from database: {e}\")\n\n\n# Create app instance\napp = create_app()\n\n\ndef _compute_worktree_port(base_port: int) -> int:\n    \"\"\"Compute a deterministic port from the worktree directory name.\n\n    Uses MD5 of the project root basename so each worktree gets a unique,\n    stable port pair (backend 5xxx, frontend 3xxx) without manual config.\n    \"\"\"\n    import hashlib\n    basename = _project_root.name\n    offset = int(hashlib.md5(basename.encode()).hexdigest()[:8], 16) % 500\n    return base_port + offset\n\n\nif __name__ == '__main__':\n    # Run development server\n    if os.getenv(\"IN_DOCKER\", \"0\") == \"1\":\n        port = 5000  # Docker 容器内部固定使用 5000 端口\n    elif os.getenv('BACKEND_PORT'):\n        port = int(os.getenv('BACKEND_PORT'))\n    else:\n        port = _compute_worktree_port(5000)\n    debug = os.getenv('FLASK_ENV', 'development') == 'development'\n    \n    logging.info(\n        \"\\n\"\n        \"╔══════════════════════════════════════╗\\n\"\n        \"║   🍌 Banana Slides API Server 🍌   ║\\n\"\n        \"╚══════════════════════════════════════╝\\n\"\n        f\"Server starting on: http://localhost:{port}\\n\"\n        f\"Output Language: {Config.OUTPUT_LANGUAGE}\\n\"\n        f\"Environment: {os.getenv('FLASK_ENV', 'development')}\\n\"\n        f\"Debug mode: {debug}\\n\"\n        f\"API Base URL: http://localhost:{port}/api\\n\"\n        f\"Database: {app.config['SQLALCHEMY_DATABASE_URI']}\\n\"\n        f\"Uploads: {app.config['UPLOAD_FOLDER']}\"\n    )\n    \n    # Using absolute paths for database, so WSL path issues should not occur\n    app.run(host='0.0.0.0', port=port, debug=debug, use_reloader=debug)\n"
  },
  {
    "path": "backend/config.py",
    "content": "\"\"\"\nBackend configuration file\n\"\"\"\nimport os\nimport sys\nfrom datetime import timedelta\n\n# 基础配置 - 使用更可靠的路径计算方式\n# 在模块加载时立即计算并固定路径\n_current_file = os.path.realpath(__file__)  # 使用realpath解析所有符号链接\nBASE_DIR = os.path.dirname(_current_file)\nPROJECT_ROOT = os.path.dirname(BASE_DIR)\n\n# Flask配置\nclass Config:\n    \"\"\"Base configuration\"\"\"\n    SECRET_KEY = os.getenv('SECRET_KEY', 'your-secret-key-change-this')\n    \n    # 数据库配置\n    # Use absolute path to avoid WSL path issues\n    db_path = os.path.join(BASE_DIR, 'instance', 'database.db')\n    SQLALCHEMY_DATABASE_URI = os.getenv(\n        'DATABASE_URL', \n        f'sqlite:///{db_path}'\n    )\n    SQLALCHEMY_TRACK_MODIFICATIONS = False\n    \n    # SQLite线程安全配置 - 关键修复\n    SQLALCHEMY_ENGINE_OPTIONS = {\n        'connect_args': {\n            'check_same_thread': False,  # 允许跨线程使用（仅SQLite）\n            'timeout': 30  # 增加超时时间\n        },\n        'pool_pre_ping': True,  # 连接前检查\n        'pool_recycle': 3600,  # 1小时回收连接\n    }\n    \n    # 文件存储配置\n    UPLOAD_FOLDER = os.path.join(PROJECT_ROOT, 'uploads')\n    MAX_CONTENT_LENGTH = 200 * 1024 * 1024  # 200MB max file size\n    ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}\n    ALLOWED_REFERENCE_FILE_EXTENSIONS = {'pdf', 'docx', 'pptx', 'doc', 'ppt', 'xlsx', 'xls', 'csv', 'txt', 'md'}\n    \n    # AI服务配置\n    GOOGLE_API_KEY = os.getenv('GOOGLE_API_KEY', '')\n    GOOGLE_API_BASE = os.getenv('GOOGLE_API_BASE', '')\n    \n    # Provider format: gemini | openai | vertex | lazyllm\n    AI_PROVIDER_FORMAT = os.getenv('AI_PROVIDER_FORMAT', 'gemini')\n\n    # Google Cloud Vertex AI (requires AI_PROVIDER_FORMAT=vertex)\n    VERTEX_PROJECT_ID = os.getenv('VERTEX_PROJECT_ID', '')\n    VERTEX_LOCATION = os.getenv('VERTEX_LOCATION', 'us-central1')\n    \n    # GenAI (Gemini) 格式专用配置\n    GENAI_TIMEOUT = float(os.getenv('GENAI_TIMEOUT', '300.0'))  # Gemini 超时时间（秒）\n    GENAI_MAX_RETRIES = int(os.getenv('GENAI_MAX_RETRIES', '2'))  # Gemini 最大重试次数（应用层实现）\n    \n    # OpenAI 格式专用配置（当 AI_PROVIDER_FORMAT=openai 时使用）\n    OPENAI_API_KEY = os.getenv('OPENAI_API_KEY', '')  # 当 AI_PROVIDER_FORMAT=openai 时必须设置\n    OPENAI_API_BASE = os.getenv('OPENAI_API_BASE', 'https://aihubmix.com/v1')\n    OPENAI_TIMEOUT = float(os.getenv('OPENAI_TIMEOUT', '300.0'))  # 增加到 5 分钟（生成清洁背景图需要很长时间）\n    OPENAI_MAX_RETRIES = int(os.getenv('OPENAI_MAX_RETRIES', '2'))  # 减少重试次数，避免过多重试导致累积超时\n\n    # Lazyllm 格式专用配置（当 AI_PROVIDER_FORMAT=lazyllm 时使用）\n    TEXT_MODEL_SOURCE = os.getenv('TEXT_MODEL_SOURCE', '')                   # 文本生成模型厂商（留空则跟随全局 AI_PROVIDER_FORMAT）\n    IMAGE_MODEL_SOURCE = os.getenv('IMAGE_MODEL_SOURCE', '')                   # 图片生成模型厂商（留空则跟随全局 AI_PROVIDER_FORMAT）\n    IMAGE_CAPTION_MODEL_SOURCE = os.getenv('IMAGE_CAPTION_MODEL_SOURCE', '')   # 图片识别模型厂商（留空则跟随全局 AI_PROVIDER_FORMAT）\n    \n    # AI 模型配置\n    TEXT_MODEL = os.getenv('TEXT_MODEL', 'gemini-3-flash-preview')\n    IMAGE_MODEL = os.getenv('IMAGE_MODEL', 'gemini-3-pro-image-preview')\n\n    # MinerU 文件解析服务配置\n    MINERU_TOKEN = os.getenv('MINERU_TOKEN', '')\n    MINERU_API_BASE = os.getenv('MINERU_API_BASE', 'https://mineru.net')\n    \n    # 图片识别模型配置\n    IMAGE_CAPTION_MODEL = os.getenv('IMAGE_CAPTION_MODEL', 'gemini-3-flash-preview')\n    \n    # 并发配置\n    MAX_DESCRIPTION_WORKERS = int(os.getenv('MAX_DESCRIPTION_WORKERS', '5'))\n    MAX_IMAGE_WORKERS = int(os.getenv('MAX_IMAGE_WORKERS', '8'))\n    \n    # 图片生成配置\n    DEFAULT_ASPECT_RATIO = \"16:9\"\n    DEFAULT_RESOLUTION = \"2K\"\n    \n    # 日志配置\n    LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()\n    \n    # CORS配置\n    CORS_ORIGINS = os.getenv('CORS_ORIGINS', 'http://localhost:3000').split(',')\n    \n    # 输出语言配置\n    # 可选值: 'zh' (中文), 'ja' (日本語), 'en' (English), 'auto' (自动)\n    OUTPUT_LANGUAGE = os.getenv('OUTPUT_LANGUAGE', 'zh')\n    \n    # 火山引擎配置\n    VOLCENGINE_ACCESS_KEY = os.getenv('VOLCENGINE_ACCESS_KEY', '')\n    VOLCENGINE_SECRET_KEY = os.getenv('VOLCENGINE_SECRET_KEY', '')\n    VOLCENGINE_INPAINTING_TIMEOUT = int(os.getenv('VOLCENGINE_INPAINTING_TIMEOUT', '60'))  # Inpainting 超时时间（秒）\n    VOLCENGINE_INPAINTING_MAX_RETRIES = int(os.getenv('VOLCENGINE_INPAINTING_MAX_RETRIES', '3'))  # 最大重试次数\n\n    # Inpainting Provider 配置（用于 InpaintingService 的单张图片修复）\n    # 可选值: 'volcengine' (火山引擎), 'gemini' (Google Gemini)\n    # 注意: 可编辑PPTX导出功能使用 ImageEditabilityService，其中 HybridInpaintProvider 会结合百度重绘和生成式质量增强\n    INPAINTING_PROVIDER = os.getenv('INPAINTING_PROVIDER', 'gemini')  # 默认使用 Gemini\n\n    # 百度 API 配置（用于 OCR 和图像修复）\n    BAIDU_API_KEY = os.getenv('BAIDU_API_KEY', '') or os.getenv('BAIDU_OCR_API_KEY', '')\n\n\nclass DevelopmentConfig(Config):\n    \"\"\"Development configuration\"\"\"\n    DEBUG = True\n\n\nclass ProductionConfig(Config):\n    \"\"\"Production configuration\"\"\"\n    DEBUG = False\n\n\n# 根据环境变量选择配置\nconfig_map = {\n    'development': DevelopmentConfig,\n    'production': ProductionConfig,\n    'default': DevelopmentConfig\n}\n\ndef get_config():\n    \"\"\"Get configuration based on environment\"\"\"\n    env = os.getenv('FLASK_ENV', 'development')\n    return config_map.get(env, DevelopmentConfig)\n"
  },
  {
    "path": "backend/controllers/__init__.py",
    "content": "\"\"\"Controllers package\"\"\"\nfrom .project_controller import project_bp, style_bp\nfrom .page_controller import page_bp\nfrom .template_controller import template_bp, user_template_bp\nfrom .export_controller import export_bp\nfrom .file_controller import file_bp\nfrom .material_controller import material_bp\nfrom .settings_controller import settings_bp\n\n__all__ = ['project_bp', 'style_bp', 'page_bp', 'template_bp', 'user_template_bp', 'export_bp', 'file_bp', 'material_bp', 'settings_bp']\n\n"
  },
  {
    "path": "backend/controllers/export_controller.py",
    "content": "\"\"\"\nExport Controller - handles file export endpoints\n\"\"\"\nimport logging\nimport os\nimport io\nimport shutil\nimport time\nimport zipfile\n\nfrom flask import Blueprint, request, current_app\nfrom werkzeug.utils import secure_filename\nfrom models import db, Project, Page, Task\nfrom utils import (\n    error_response, not_found, bad_request, success_response,\n    parse_page_ids_from_query, parse_page_ids_from_body, get_filtered_pages\n)\nfrom services import ExportService, FileService\nfrom services.ai_service_manager import get_ai_service\n\nlogger = logging.getLogger(__name__)\n\nexport_bp = Blueprint('export', __name__, url_prefix='/api/projects')\n\n\n@export_bp.route('/<project_id>/export/pptx', methods=['GET'])\ndef export_pptx(project_id):\n    \"\"\"\n    GET /api/projects/{project_id}/export/pptx?filename=...&page_ids=id1,id2,id3 - Export PPTX\n    \n    Query params:\n        - filename: optional custom filename\n        - page_ids: optional comma-separated page IDs to export (if not provided, exports all pages)\n    \n    Returns:\n        JSON with download URL, e.g.\n        {\n            \"success\": true,\n            \"data\": {\n                \"download_url\": \"/files/{project_id}/exports/xxx.pptx\",\n                \"download_url_absolute\": \"http://host:port/files/{project_id}/exports/xxx.pptx\"\n            }\n        }\n    \"\"\"\n    try:\n        project = Project.query.get(project_id)\n        \n        if not project:\n            return not_found('Project')\n        \n        # Get page_ids from query params and fetch filtered pages\n        selected_page_ids = parse_page_ids_from_query(request)\n        logger.debug(f\"[export_pptx] selected_page_ids: {selected_page_ids}\")\n        \n        pages = get_filtered_pages(project_id, selected_page_ids if selected_page_ids else None)\n        logger.debug(f\"[export_pptx] Exporting {len(pages)} pages\")\n        \n        if not pages:\n            return bad_request(\"No pages found for project\")\n        \n        # Get image paths\n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n        \n        image_paths = []\n        for page in pages:\n            if page.generated_image_path:\n                abs_path = file_service.get_absolute_path(page.generated_image_path)\n                image_paths.append(abs_path)\n        \n        if not image_paths:\n            return bad_request(\"No generated images found for project\")\n        \n        # Determine export directory and filename\n        exports_dir = file_service._get_exports_dir(project_id)\n\n        # Get filename from query params or use default\n        filename = secure_filename(request.args.get('filename', f'presentation_{project_id}.pptx'))\n        if not filename.endswith('.pptx'):\n            filename += '.pptx'\n\n        output_path = os.path.join(exports_dir, filename)\n\n        # Generate PPTX file on disk\n        ExportService.create_pptx_from_images(image_paths, output_file=output_path, aspect_ratio=project.image_aspect_ratio)\n\n        # Build download URLs\n        download_path = f\"/files/{project_id}/exports/{filename}\"\n        base_url = request.url_root.rstrip(\"/\")\n        download_url_absolute = f\"{base_url}{download_path}\"\n\n        return success_response(\n            data={\n                \"download_url\": download_path,\n                \"download_url_absolute\": download_url_absolute,\n            },\n            message=\"Export PPTX task created\"\n        )\n    \n    except Exception as e:\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@export_bp.route('/<project_id>/export/pdf', methods=['GET'])\ndef export_pdf(project_id):\n    \"\"\"\n    GET /api/projects/{project_id}/export/pdf?filename=...&page_ids=id1,id2,id3 - Export PDF\n    \n    Query params:\n        - filename: optional custom filename\n        - page_ids: optional comma-separated page IDs to export (if not provided, exports all pages)\n    \n    Returns:\n        JSON with download URL, e.g.\n        {\n            \"success\": true,\n            \"data\": {\n                \"download_url\": \"/files/{project_id}/exports/xxx.pdf\",\n                \"download_url_absolute\": \"http://host:port/files/{project_id}/exports/xxx.pdf\"\n            }\n        }\n    \"\"\"\n    try:\n        project = Project.query.get(project_id)\n        \n        if not project:\n            return not_found('Project')\n        \n        # Get page_ids from query params and fetch filtered pages\n        selected_page_ids = parse_page_ids_from_query(request)\n        pages = get_filtered_pages(project_id, selected_page_ids if selected_page_ids else None)\n        \n        if not pages:\n            return bad_request(\"No pages found for project\")\n        \n        # Get image paths\n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n        \n        image_paths = []\n        for page in pages:\n            if page.generated_image_path:\n                abs_path = file_service.get_absolute_path(page.generated_image_path)\n                image_paths.append(abs_path)\n        \n        if not image_paths:\n            return bad_request(\"No generated images found for project\")\n        \n        # Determine export directory and filename\n        exports_dir = file_service._get_exports_dir(project_id)\n\n        # Get filename from query params or use default\n        filename = secure_filename(request.args.get('filename', f'presentation_{project_id}.pdf'))\n        if not filename.endswith('.pdf'):\n            filename += '.pdf'\n\n        output_path = os.path.join(exports_dir, filename)\n\n        # Generate PDF file on disk\n        ExportService.create_pdf_from_images(image_paths, output_file=output_path, aspect_ratio=project.image_aspect_ratio)\n\n        # Build download URLs\n        download_path = f\"/files/{project_id}/exports/{filename}\"\n        base_url = request.url_root.rstrip(\"/\")\n        download_url_absolute = f\"{base_url}{download_path}\"\n\n        return success_response(\n            data={\n                \"download_url\": download_path,\n                \"download_url_absolute\": download_url_absolute,\n            },\n            message=\"Export PDF task created\"\n        )\n    \n    except Exception as e:\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@export_bp.route('/<project_id>/export/images', methods=['GET'])\ndef export_images(project_id):\n    \"\"\"\n    GET /api/projects/{project_id}/export/images?page_ids=id1,id2,id3 - Export images\n\n    Single image: copies to exports dir and returns download URL.\n    Multiple images: creates a ZIP archive and returns download URL.\n    \"\"\"\n    try:\n        if '..' in project_id or '/' in project_id or '\\\\' in project_id:\n            return bad_request('Invalid project ID')\n        s_project_id = secure_filename(project_id)\n        if s_project_id != project_id:\n            return bad_request('Invalid project ID')\n\n        project = Project.query.get(s_project_id)\n        if not project:\n            return not_found('Project')\n\n        selected_page_ids = parse_page_ids_from_query(request)\n        pages = get_filtered_pages(s_project_id, selected_page_ids if selected_page_ids else None)\n        if not pages:\n            return bad_request(\"No pages found for project\")\n\n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n\n        image_items = []\n        for page in pages:\n            if page.generated_image_path:\n                abs_path = file_service.get_absolute_path(page.generated_image_path)\n                if os.path.exists(abs_path):\n                    image_items.append((page, abs_path))\n\n        if not image_items:\n            return bad_request(\"No generated images found for project\")\n\n        exports_dir = file_service._get_exports_dir(s_project_id)\n        timestamp = int(time.time())\n\n        if len(image_items) == 1:\n            page, path = image_items[0]\n            ext = os.path.splitext(path)[1] or '.png'\n            filename = f'slide_{page.id}_{timestamp}{ext}'\n            output_path = os.path.join(exports_dir, filename)\n            shutil.copy2(path, output_path)\n        else:\n            filename = f'slides_{s_project_id}_{timestamp}.zip'\n            output_path = os.path.join(exports_dir, filename)\n            with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:\n                for page, path in image_items:\n                    ext = os.path.splitext(path)[1] or '.png'\n                    zf.write(path, f'slide_{page.order_index + 1:03d}{ext}')\n\n        download_path = f\"/files/{s_project_id}/exports/{filename}\"\n        base_url = request.url_root.rstrip(\"/\")\n\n        return success_response(\n            data={\n                \"download_url\": download_path,\n                \"download_url_absolute\": f\"{base_url}{download_path}\",\n            },\n            message=\"Export images completed\"\n        )\n\n    except Exception as e:\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@export_bp.route('/<project_id>/export/editable-pptx', methods=['POST'])\ndef export_editable_pptx(project_id):\n    \"\"\"\n    POST /api/projects/{project_id}/export/editable-pptx - 导出可编辑PPTX（异步）\n    \n    使用递归分析方法（支持任意尺寸、递归子图分析）\n    \n    这个端点创建一个异步任务来执行以下操作：\n    1. 递归分析图片（支持任意尺寸和分辨率）\n    2. 转换为PDF并上传MinerU识别\n    3. 提取元素bbox和生成clean background（inpainting）\n    4. 递归处理图片/图表中的子元素\n    5. 创建可编辑PPTX\n    \n    Request body (JSON):\n        {\n            \"filename\": \"optional_custom_name.pptx\",\n            \"page_ids\": [\"id1\", \"id2\"],  // 可选，要导出的页面ID列表（不提供则导出所有）\n            \"max_depth\": 1,      // 可选，递归深度（默认1=不递归，2=递归一层）\n            \"max_workers\": 4     // 可选，并发数（默认4）\n        }\n    \n    Returns:\n        JSON with task_id, e.g.\n        {\n            \"success\": true,\n            \"data\": {\n                \"task_id\": \"uuid-here\",\n                \"method\": \"recursive_analysis\",\n                \"max_depth\": 2,\n                \"max_workers\": 4\n            },\n            \"message\": \"Export task created\"\n        }\n    \n    轮询 /api/projects/{project_id}/tasks/{task_id} 获取进度和下载链接\n    \"\"\"\n    try:\n        project = Project.query.get(project_id)\n        \n        if not project:\n            return not_found('Project')\n        \n        # Get parameters from request body\n        data = request.get_json() or {}\n        \n        # Get page_ids from request body and fetch filtered pages\n        selected_page_ids = parse_page_ids_from_body(data)\n        pages = get_filtered_pages(project_id, selected_page_ids if selected_page_ids else None)\n        \n        if not pages:\n            return bad_request(\"No pages found for project\")\n        \n        # Check if pages have generated images\n        has_images = any(page.generated_image_path for page in pages)\n        if not has_images:\n            return bad_request(\"No generated images found for project\")\n        \n        # Get parameters from request body\n        data = request.get_json() or {}\n        filename = data.get('filename', f'presentation_editable_{project_id}.pptx')\n        if not filename.endswith('.pptx'):\n            filename += '.pptx'\n        \n        # 递归分析参数\n        # max_depth 语义：1=只处理表层不递归，2=递归一层（处理图片/图表中的子元素）\n        max_depth = data.get('max_depth', 1)  # 默认不递归，与测试脚本一致\n        max_workers = data.get('max_workers', 4)\n        \n        # Validate parameters\n        # max_depth >= 1: 至少处理表层元素\n        if not isinstance(max_depth, int) or max_depth < 1 or max_depth > 5:\n            return bad_request(\"max_depth must be an integer between 1 and 5\")\n        \n        if not isinstance(max_workers, int) or max_workers < 1 or max_workers > 16:\n            return bad_request(\"max_workers must be an integer between 1 and 16\")\n        \n        # Create task record\n        task = Task(\n            project_id=project_id,\n            task_type='EXPORT_EDITABLE_PPTX',\n            status='PENDING'\n        )\n        db.session.add(task)\n        db.session.commit()\n        \n        logger.info(f\"Created export task {task.id} for project {project_id} (recursive analysis: depth={max_depth}, workers={max_workers})\")\n        \n        # Get services\n        from services.file_service import FileService\n        from services.task_manager import task_manager, export_editable_pptx_with_recursive_analysis_task\n        \n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n        \n        # Get Flask app instance for background task\n        app = current_app._get_current_object()\n        \n        # 读取项目的导出设置\n        export_extractor_method = project.export_extractor_method or 'hybrid'\n        export_inpaint_method = project.export_inpaint_method or 'hybrid'\n        logger.info(f\"Export settings: extractor={export_extractor_method}, inpaint={export_inpaint_method}\")\n        \n        # 使用递归分析任务（不需要 ai_service，使用 ImageEditabilityService）\n        task_manager.submit_task(\n            task.id,\n            export_editable_pptx_with_recursive_analysis_task,\n            project_id=project_id,\n            filename=filename,\n            file_service=file_service,\n            page_ids=selected_page_ids if selected_page_ids else None,\n            max_depth=max_depth,\n            max_workers=max_workers,\n            export_extractor_method=export_extractor_method,\n            export_inpaint_method=export_inpaint_method,\n            app=app\n        )\n        \n        logger.info(f\"Submitted recursive export task {task.id} to task manager\")\n        \n        return success_response(\n            data={\n                \"task_id\": task.id,\n                \"method\": \"recursive_analysis\",\n                \"max_depth\": max_depth,\n                \"max_workers\": max_workers\n            },\n            message=\"Export task created (using recursive analysis)\"\n        )\n    \n    except Exception as e:\n        logger.exception(\"Error creating export task\")\n        return error_response('SERVER_ERROR', str(e), 500)\n"
  },
  {
    "path": "backend/controllers/file_controller.py",
    "content": "\"\"\"\nFile Controller - handles static file serving\n\"\"\"\nfrom flask import Blueprint, send_from_directory, current_app\nfrom utils import error_response, not_found\nfrom utils.path_utils import find_file_with_prefix\nimport os\nfrom pathlib import Path\nfrom werkzeug.utils import secure_filename\n\nfile_bp = Blueprint('files', __name__, url_prefix='/files')\n\n\n@file_bp.route('/<project_id>/<file_type>/<filename>', methods=['GET'])\ndef serve_file(project_id, file_type, filename):\n    \"\"\"\n    GET /files/{project_id}/{type}/{filename} - Serve static files\n    \n    Args:\n        project_id: Project UUID\n        file_type: 'template' or 'pages'\n        filename: File name\n    \"\"\"\n    try:\n        if file_type not in ['template', 'pages', 'materials', 'exports']:\n            return not_found('File')\n        \n        # Construct file path\n        file_dir = os.path.join(\n            current_app.config['UPLOAD_FOLDER'],\n            project_id,\n            file_type\n        )\n        \n        # Check if directory exists\n        if not os.path.exists(file_dir):\n            return not_found('File')\n        \n        # Check if file exists\n        file_path = os.path.join(file_dir, filename)\n        if not os.path.exists(file_path):\n            return not_found('File')\n        \n        # Serve file\n        return send_from_directory(file_dir, filename)\n    \n    except Exception as e:\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@file_bp.route('/user-templates/<template_id>/<filename>', methods=['GET'])\ndef serve_user_template(template_id, filename):\n    \"\"\"\n    GET /files/user-templates/{template_id}/{filename} - Serve user template files\n    \n    Args:\n        template_id: Template UUID\n        filename: File name\n    \"\"\"\n    try:\n        # Construct file path\n        file_dir = os.path.join(\n            current_app.config['UPLOAD_FOLDER'],\n            'user-templates',\n            template_id\n        )\n        \n        # Check if directory exists\n        if not os.path.exists(file_dir):\n            return not_found('File')\n        \n        # Check if file exists\n        file_path = os.path.join(file_dir, filename)\n        if not os.path.exists(file_path):\n            return not_found('File')\n        \n        # Serve file\n        return send_from_directory(file_dir, filename)\n    \n    except Exception as e:\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@file_bp.route('/materials/<filename>', methods=['GET'])\ndef serve_global_material(filename):\n    \"\"\"\n    GET /files/materials/{filename} - Serve global material files (not bound to a project)\n    \n    Args:\n        filename: File name\n    \"\"\"\n    try:\n        safe_filename = secure_filename(filename)\n        # Construct file path\n        file_dir = os.path.join(\n            current_app.config['UPLOAD_FOLDER'],\n            'materials'\n        )\n        \n        # Check if directory exists\n        if not os.path.exists(file_dir):\n            return not_found('File')\n        \n        # Check if file exists\n        file_path = os.path.join(file_dir, safe_filename)\n        if not os.path.exists(file_path):\n            return not_found('File')\n        \n        # Serve file\n        return send_from_directory(file_dir, safe_filename)\n    \n    except Exception as e:\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@file_bp.route('/mineru/<extract_id>/<path:filepath>', methods=['GET'])\ndef serve_mineru_file(extract_id, filepath):\n    \"\"\"\n    GET /files/mineru/{extract_id}/{filepath} - Serve MinerU extracted files.\n\n    Args:\n        extract_id: Extract UUID\n        filepath: Relative file path within the extract\n    \"\"\"\n    try:\n        root_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'mineru_files', extract_id)\n        full_path = Path(root_dir) / filepath\n\n        # This prevents path traversal attacks\n        resolved_root_dir = Path(root_dir).resolve()\n        \n        try:\n            # Check if the path is trying to escape the root directory\n            resolved_full_path = full_path.resolve()\n            if not str(resolved_full_path).startswith(str(resolved_root_dir)):\n                return error_response('INVALID_PATH', 'Invalid file path', 403)\n        except Exception:\n            # If we can't resolve the path at all, it's invalid\n            return error_response('INVALID_PATH', 'Invalid file path', 403)\n\n        # Try to find file with prefix matching\n        matched_path = find_file_with_prefix(full_path)\n        \n        if matched_path is not None:\n            # Additional security check for matched path\n            try:\n                resolved_matched_path = matched_path.resolve(strict=True)\n                \n                # Verify the matched file is still within the root directory\n                if not str(resolved_matched_path).startswith(str(resolved_root_dir)):\n                    return error_response('INVALID_PATH', 'Invalid file path', 403)\n            except FileNotFoundError:\n                return not_found('File')\n            except Exception:\n                return error_response('INVALID_PATH', 'Invalid file path', 403)\n            \n            return send_from_directory(str(matched_path.parent), matched_path.name)\n\n        return not_found('File')\n    except Exception as e:\n        return error_response('SERVER_ERROR', str(e), 500)\n\n"
  },
  {
    "path": "backend/controllers/material_controller.py",
    "content": "\"\"\"\nMaterial Controller - handles standalone material image generation\n\"\"\"\nfrom flask import Blueprint, request, current_app, send_file\nfrom models import db, Project, Material, Task\nfrom utils import success_response, error_response, not_found, bad_request\nfrom services import FileService\nfrom services.ai_service_manager import get_ai_service\nfrom services.task_manager import task_manager, generate_material_image_task\nfrom pathlib import Path\nfrom werkzeug.utils import secure_filename\nfrom typing import Optional\nimport tempfile\nimport shutil\nimport time\nimport zipfile\nimport io\nimport base64\nimport logging\n\nlogger = logging.getLogger(__name__)\n\nmaterial_bp = Blueprint('materials', __name__, url_prefix='/api/projects')\nmaterial_global_bp = Blueprint('materials_global', __name__, url_prefix='/api/materials')\n\nALLOWED_MATERIAL_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'}\nALLOWED_ASPECT_RATIOS = frozenset({'16:9', '21:9', '4:3', '3:2', '5:4', '1:1', '4:5', '2:3', '3:4', '9:16'})\n\n\ndef _generate_image_caption(filepath: str) -> str:\n    \"\"\"Generate AI caption for an uploaded image. Returns empty string on failure.\"\"\"\n    if filepath.lower().endswith('.svg'):\n        return \"\"\n    try:\n        from PIL import Image\n\n        image = Image.open(filepath)\n        image.thumbnail((1024, 1024), Image.Resampling.LANCZOS)\n\n        output_lang = current_app.config.get('OUTPUT_LANGUAGE', 'zh')\n        if output_lang == 'en':\n            prompt = \"Please provide a short description of the main content of this image. Return only the description text without any other explanation.\"\n        else:\n            prompt = \"请用一句简短的中文描述这张图片的主要内容。只返回描述文字，不要其他解释。\"\n\n        provider_format = (current_app.config.get('AI_PROVIDER_FORMAT') or 'gemini').lower()\n        caption_model = current_app.config.get('IMAGE_CAPTION_MODEL', 'gemini-3-flash-preview')\n\n        if provider_format == 'openai':\n            from openai import OpenAI\n            api_key = current_app.config.get('OPENAI_API_KEY', '')\n            if not api_key:\n                return \"\"\n            client = OpenAI(\n                api_key=api_key,\n                base_url=current_app.config.get('OPENAI_API_BASE') or None\n            )\n\n            buffered = io.BytesIO()\n            if image.mode in ('RGBA', 'LA', 'P'):\n                background = Image.new('RGB', image.size, (255, 255, 255))\n                background.paste(image, mask=image.split()[-1] if image.mode in ('RGBA', 'LA') else None)\n                image = background\n            image.save(buffered, format=\"JPEG\", quality=95)\n            base64_image = base64.b64encode(buffered.getvalue()).decode('utf-8')\n\n            response = client.chat.completions.create(\n                model=caption_model,\n                messages=[{\n                    \"role\": \"user\",\n                    \"content\": [\n                        {\"type\": \"image_url\", \"image_url\": {\"url\": f\"data:image/jpeg;base64,{base64_image}\"}},\n                        {\"type\": \"text\", \"text\": prompt}\n                    ]\n                }],\n                temperature=0.3\n            )\n            return response.choices[0].message.content.strip()\n        else:\n            # Gemini (default)\n            from google import genai\n            from google.genai import types\n            api_key = current_app.config.get('GOOGLE_API_KEY', '')\n            if not api_key:\n                return \"\"\n            api_base = current_app.config.get('GOOGLE_API_BASE', '')\n            client = genai.Client(\n                http_options=types.HttpOptions(base_url=api_base) if api_base else None,\n                api_key=api_key\n            )\n            result = client.models.generate_content(\n                model=caption_model,\n                contents=[image, prompt],\n                config=types.GenerateContentConfig(temperature=0.3)\n            )\n            return result.text.strip()\n    except Exception as e:\n        logger.warning(f\"Failed to generate caption for {filepath}: {e}\")\n        return \"\"\n\n\ndef _build_material_query(filter_project_id: str):\n    \"\"\"Build common material query with project validation.\"\"\"\n    query = Material.query\n\n    if filter_project_id == 'all':\n        return query, None\n    if filter_project_id == 'none':\n        return query.filter(Material.project_id.is_(None)), None\n\n    project = Project.query.get(filter_project_id)\n    if not project:\n        return None, not_found('Project')\n\n    return query.filter(Material.project_id == filter_project_id), None\n\n\ndef _get_materials_list(filter_project_id: str):\n    \"\"\"\n    Common logic to get materials list.\n    Returns (materials_list, error_response)\n    \"\"\"\n    query, error = _build_material_query(filter_project_id)\n    if error:\n        return None, error\n    \n    materials = query.order_by(Material.created_at.desc()).all()\n    materials_list = [material.to_dict() for material in materials]\n    \n    return materials_list, None\n\n\ndef _handle_material_upload(default_project_id: Optional[str] = None):\n    \"\"\"\n    Common logic to handle material upload.\n    Returns Flask response object.\n    \"\"\"\n    try:\n        raw_project_id = request.args.get('project_id', default_project_id)\n        target_project_id, error = _resolve_target_project_id(raw_project_id)\n        if error:\n            return error\n\n        file = request.files.get('file')\n        material, error = _save_material_file(file, target_project_id)\n        if error:\n            return error\n\n        result = material.to_dict()\n\n        # Generate AI caption if requested\n        generate_caption = request.args.get('generate_caption', '').lower() in ('true', '1', 'yes')\n        if generate_caption:\n            file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n            filepath = file_service.get_absolute_path(material.relative_path)\n            caption = _generate_image_caption(filepath)\n            result['caption'] = caption\n\n        return success_response(result, status_code=201)\n\n    except Exception as e:\n        db.session.rollback()\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\ndef _resolve_target_project_id(raw_project_id: Optional[str], allow_none: bool = True):\n    \"\"\"\n    Normalize project_id from request.\n    Returns (project_id | None, error_response | None)\n    \"\"\"\n    if allow_none and (raw_project_id is None or raw_project_id == 'none'):\n        return None, None\n\n    if raw_project_id == 'all':\n        return None, bad_request(\"project_id cannot be 'all' when uploading materials\")\n\n    if raw_project_id:\n        project = Project.query.get(raw_project_id)\n        if not project:\n            return None, not_found('Project')\n\n    return raw_project_id, None\n\n\ndef _save_material_file(file, target_project_id: Optional[str]):\n    \"\"\"Shared logic for saving uploaded material files to disk and DB.\"\"\"\n    if not file or not file.filename:\n        return None, bad_request(\"file is required\")\n\n    filename = secure_filename(file.filename)\n    file_ext = Path(filename).suffix.lower()\n    if file_ext not in ALLOWED_MATERIAL_EXTENSIONS:\n        return None, bad_request(f\"Unsupported file type. Allowed: {', '.join(sorted(ALLOWED_MATERIAL_EXTENSIONS))}\")\n\n    file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n    if target_project_id:\n        materials_dir = file_service.upload_folder / file_service._get_materials_dir(target_project_id)\n    else:\n        materials_dir = file_service.upload_folder / \"materials\"\n    materials_dir.mkdir(exist_ok=True, parents=True)\n\n    timestamp = int(time.time() * 1000)\n    base_name = Path(filename).stem\n    unique_filename = f\"{base_name}_{timestamp}{file_ext}\"\n\n    filepath = materials_dir / unique_filename\n    file.save(str(filepath))\n\n    relative_path = str(filepath.relative_to(file_service.upload_folder))\n    if target_project_id:\n        image_url = file_service.get_file_url(target_project_id, 'materials', unique_filename)\n    else:\n        image_url = f\"/files/materials/{unique_filename}\"\n\n    material = Material(\n        project_id=target_project_id,\n        filename=unique_filename,\n        relative_path=relative_path,\n        url=image_url\n    )\n\n    try:\n        db.session.add(material)\n        db.session.commit()\n        return material, None\n    except Exception:\n        db.session.rollback()\n        raise\n\n\n@material_bp.route('/<project_id>/materials/generate', methods=['POST'])\ndef generate_material_image(project_id):\n    \"\"\"\n    POST /api/projects/{project_id}/materials/generate - Generate a standalone material image\n\n    Supports multipart/form-data:\n    - prompt: Text-to-image prompt (passed directly to the model without modification)\n    - ref_image: Main reference image (optional)\n    - extra_images: Additional reference images (multiple files, optional)\n    \n    Note: project_id can be 'none' to generate global materials (not associated with any project)\n    \"\"\"\n    try:\n        # 支持 'none' 作为特殊值，表示生成全局素材\n        if project_id != 'none':\n            project = Project.query.get(project_id)\n            if not project:\n                return not_found('Project')\n        else:\n            project = None\n            project_id = None  # 设置为None表示全局素材\n\n        # Parse request data (prioritize multipart for file uploads)\n        if request.is_json:\n            data = request.get_json() or {}\n            prompt = data.get('prompt', '').strip()\n            ref_file = None\n            extra_files = []\n        else:\n            data = request.form.to_dict()\n            prompt = (data.get('prompt') or '').strip()\n            ref_file = request.files.get('ref_image')\n            extra_files = request.files.getlist('extra_images') or []\n\n        aspect_ratio = (data.get('aspect_ratio') or '').strip() or None\n        if aspect_ratio and aspect_ratio not in ALLOWED_ASPECT_RATIOS:\n            return bad_request(f\"Invalid aspect ratio. Allowed values: {', '.join(sorted(ALLOWED_ASPECT_RATIOS))}\")\n\n        if not prompt:\n            return bad_request(\"prompt is required\")\n\n        # 处理project_id：对于全局素材，使用'global'作为Task的project_id\n        # Task模型要求project_id不能为null，但Material可以\n        task_project_id = project_id if project_id is not None else 'global'\n        \n        # 验证project_id（如果不是'global'）\n        if task_project_id != 'global':\n            project = Project.query.get(task_project_id)\n            if not project:\n                return not_found('Project')\n\n        # Initialize services\n        ai_service = get_ai_service()\n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n\n        # 创建临时目录保存参考图片（后台任务会清理）\n        temp_dir = Path(tempfile.mkdtemp(dir=current_app.config['UPLOAD_FOLDER']))\n        temp_dir_str = str(temp_dir)\n\n        try:\n            ref_path = None\n            # Save main reference image to temp directory if provided\n            if ref_file and ref_file.filename:\n                ref_filename = secure_filename(ref_file.filename or 'ref.png')\n                ref_path = temp_dir / ref_filename\n                ref_file.save(str(ref_path))\n                ref_path_str = str(ref_path)\n            else:\n                ref_path_str = None\n\n            # Save additional reference images to temp directory\n            additional_ref_images = []\n            for extra in extra_files:\n                if not extra or not extra.filename:\n                    continue\n                extra_filename = secure_filename(extra.filename)\n                extra_path = temp_dir / extra_filename\n                extra.save(str(extra_path))\n                additional_ref_images.append(str(extra_path))\n\n            # Create async task for material generation\n            task = Task(\n                project_id=task_project_id,\n                task_type='GENERATE_MATERIAL',\n                status='PENDING'\n            )\n            task.set_progress({\n                'total': 1,\n                'completed': 0,\n                'failed': 0\n            })\n            db.session.add(task)\n            db.session.commit()\n\n            # Get app instance for background task\n            app = current_app._get_current_object()\n\n            # Submit background task\n            task_manager.submit_task(\n                task.id,\n                generate_material_image_task,\n                task_project_id,  # 传递给任务函数，它会处理'global'的情况\n                prompt,\n                ai_service,\n                file_service,\n                ref_path_str,\n                additional_ref_images if additional_ref_images else None,\n                aspect_ratio or (project.image_aspect_ratio if project else None) or current_app.config.get('DEFAULT_ASPECT_RATIO', '16:9'),\n                current_app.config['DEFAULT_RESOLUTION'],\n                temp_dir_str,\n                app\n            )\n\n            # Return task_id immediately (不再清理temp_dir，由后台任务清理)\n            return success_response({\n                'task_id': task.id,\n                'status': 'PENDING'\n            }, status_code=202)\n        \n        except Exception as e:\n            # Clean up temp directory on error\n            if temp_dir.exists():\n                shutil.rmtree(temp_dir, ignore_errors=True)\n            raise\n\n    except Exception as e:\n        db.session.rollback()\n        return error_response('AI_SERVICE_ERROR', str(e), 503)\n\n\n@material_bp.route('/<project_id>/materials', methods=['GET'])\ndef list_materials(project_id):\n    \"\"\"\n    GET /api/projects/{project_id}/materials - List materials for a specific project\n    \n    Returns:\n        List of material images with filename, url, and metadata for the specified project\n    \"\"\"\n    try:\n        materials_list, error = _get_materials_list(project_id)\n        if error:\n            return error\n        \n        return success_response({\n            \"materials\": materials_list,\n            \"count\": len(materials_list)\n        })\n    \n    except Exception as e:\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@material_bp.route('/<project_id>/materials/upload', methods=['POST'])\ndef upload_material(project_id):\n    \"\"\"\n    POST /api/projects/{project_id}/materials/upload - Upload a material image\n    \n    Supports multipart/form-data:\n    - file: Image file (required)\n    - project_id: Optional query parameter, defaults to path parameter if not provided\n    \n    Returns:\n        Material info with filename, url, and metadata\n    \"\"\"\n    return _handle_material_upload(default_project_id=project_id)\n\n\n@material_global_bp.route('', methods=['GET'])\ndef list_all_materials():\n    \"\"\"\n    GET /api/materials - Global materials endpoint for complex queries\n    \n    Query params:\n        - project_id: Filter by project_id\n          * 'all' (default): Get all materials regardless of project\n          * 'none': Get only materials without a project (global materials)\n          * <project_id>: Get materials for specific project\n    \n    Returns:\n        List of material images with filename, url, and metadata\n    \"\"\"\n    try:\n        filter_project_id = request.args.get('project_id', 'all')\n        materials_list, error = _get_materials_list(filter_project_id)\n        if error:\n            return error\n        \n        return success_response({\n            \"materials\": materials_list,\n            \"count\": len(materials_list)\n        })\n    \n    except Exception as e:\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@material_global_bp.route('/upload', methods=['POST'])\ndef upload_material_global():\n    \"\"\"\n    POST /api/materials/upload - Upload a material image (global, not bound to a project)\n    \n    Supports multipart/form-data:\n    - file: Image file (required)\n    - project_id: Optional query parameter to associate with a project\n    \n    Returns:\n        Material info with filename, url, and metadata\n    \"\"\"\n    return _handle_material_upload(default_project_id=None)\n\n\n@material_global_bp.route('/<material_id>', methods=['DELETE'])\ndef delete_material(material_id):\n    \"\"\"\n    DELETE /api/materials/{material_id} - Delete a material and its file\n    \"\"\"\n    try:\n        material = Material.query.get(material_id)\n        if not material:\n            return not_found('Material')\n\n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n        material_path = Path(file_service.get_absolute_path(material.relative_path))\n\n        # First, delete the database record to ensure data consistency\n        db.session.delete(material)\n        db.session.commit()\n\n        # Then, attempt to delete the file. If this fails, log the error\n        # but still return a success response. This leaves an orphan file,\n        try:\n            if material_path.exists():\n                material_path.unlink(missing_ok=True)\n        except OSError as e:\n            current_app.logger.warning(f\"Failed to delete file for material {material_id} at {material_path}: {e}\")\n\n        return success_response({\"id\": material_id})\n    except Exception as e:\n        db.session.rollback()\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@material_global_bp.route('/associate', methods=['POST'])\ndef associate_materials_to_project():\n    \"\"\"\n    POST /api/materials/associate - Associate materials to a project by URLs\n\n    Request body (JSON):\n    {\n        \"project_id\": \"project_id\",\n        \"material_urls\": [\"url1\", \"url2\", ...]\n    }\n\n    Returns:\n        List of associated material IDs and count\n    \"\"\"\n    try:\n        data = request.get_json() or {}\n        project_id = data.get('project_id')\n        material_urls = data.get('material_urls', [])\n\n        if not project_id:\n            return bad_request(\"project_id is required\")\n\n        if not material_urls or not isinstance(material_urls, list):\n            return bad_request(\"material_urls must be a non-empty array\")\n\n        # Validate project exists\n        project = Project.query.get(project_id)\n        if not project:\n            return not_found('Project')\n\n        # Find materials by URLs and update their project_id\n        updated_ids = []\n        materials_to_update = Material.query.filter(\n            Material.url.in_(material_urls),\n            Material.project_id.is_(None)\n        ).all()\n        for material in materials_to_update:\n            material.project_id = project_id\n            updated_ids.append(material.id)\n\n        db.session.commit()\n\n        return success_response({\n            \"updated_ids\": updated_ids,\n            \"count\": len(updated_ids)\n        })\n\n    except Exception as e:\n        db.session.rollback()\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@material_global_bp.route('/download', methods=['POST'])\ndef download_materials_zip():\n    \"\"\"Bundle requested materials into a ZIP and stream it back.\"\"\"\n    body = request.get_json(silent=True) or {}\n    ids = body.get('material_ids')\n\n    if not ids or not isinstance(ids, list):\n        return bad_request(\"material_ids must be a non-empty list\")\n\n    MAX_BATCH = 200\n    if len(ids) > MAX_BATCH:\n        return bad_request(f\"Too many materials requested (max {MAX_BATCH})\")\n\n    rows = Material.query.filter(Material.id.in_(ids)).all()\n    if not rows:\n        return not_found('Materials')\n\n    tmp = tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024)\n    try:\n        fs = FileService(current_app.config['UPLOAD_FOLDER'])\n\n        with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zf:\n            for row in rows:\n                abs_path = Path(fs.get_absolute_path(row.relative_path))\n                if not abs_path.is_file():\n                    current_app.logger.warning(\"Skipping missing file for material %s\", row.id)\n                    continue\n                zf.write(str(abs_path), row.filename)\n\n        tmp.seek(0)\n        fname = f\"materials_{int(time.time())}.zip\"\n\n        return send_file(tmp, mimetype='application/zip',\n                         as_attachment=True, download_name=fname)\n    except Exception:\n        tmp.close()\n        current_app.logger.exception(\"Failed to build materials zip\")\n        return error_response('SERVER_ERROR', 'Failed to create zip archive', 500)\n\n"
  },
  {
    "path": "backend/controllers/page_controller.py",
    "content": "\"\"\"\nPage Controller - handles page-related endpoints\n\"\"\"\nimport logging\nfrom flask import Blueprint, request, current_app\nfrom models import db, Project, Page, PageImageVersion, Task\nfrom utils import success_response, error_response, not_found, bad_request\nfrom services import FileService, ProjectContext\nfrom services.ai_service_manager import get_ai_service\nfrom services.task_manager import task_manager, generate_single_page_image_task, edit_page_image_task\nfrom datetime import datetime\nfrom pathlib import Path\nfrom werkzeug.utils import secure_filename\nimport shutil\nimport tempfile\nimport json\n\nlogger = logging.getLogger(__name__)\n\npage_bp = Blueprint('pages', __name__, url_prefix='/api/projects')\n\n\n@page_bp.route('/<project_id>/pages', methods=['POST'])\ndef create_page(project_id):\n    \"\"\"\n    POST /api/projects/{project_id}/pages - Add new page\n    \n    Request body:\n    {\n        \"order_index\": 2,\n        \"part\": \"optional\",\n        \"outline_content\": {\"title\": \"...\", \"points\": [...]}\n    }\n    \"\"\"\n    try:\n        project = Project.query.get(project_id)\n        \n        if not project:\n            return not_found('Project')\n        \n        data = request.get_json()\n        \n        if not data or 'order_index' not in data:\n            return bad_request(\"order_index is required\")\n        \n        # Create new page\n        page = Page(\n            project_id=project_id,\n            order_index=data['order_index'],\n            part=data.get('part'),\n            status='DRAFT'\n        )\n        \n        if 'outline_content' in data:\n            page.set_outline_content(data['outline_content'])\n\n        if 'description_content' in data:\n            page.set_description_content(data['description_content'])\n            page.status = 'DESCRIPTION_GENERATED'\n\n        db.session.add(page)\n        \n        # Update other pages' order_index if necessary\n        other_pages = Page.query.filter(\n            Page.project_id == project_id,\n            Page.order_index >= data['order_index']\n        ).all()\n        \n        for p in other_pages:\n            if p.id != page.id:\n                p.order_index += 1\n        \n        project.updated_at = datetime.utcnow()\n        db.session.commit()\n        \n        return success_response(page.to_dict(), status_code=201)\n    \n    except Exception as e:\n        db.session.rollback()\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@page_bp.route('/<project_id>/pages/<page_id>', methods=['DELETE'])\ndef delete_page(project_id, page_id):\n    \"\"\"\n    DELETE /api/projects/{project_id}/pages/{page_id} - Delete page\n    \"\"\"\n    try:\n        page = Page.query.get(page_id)\n\n        if not page or page.project_id != project_id:\n            return not_found('Page')\n\n        # Delete page image if exists\n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n        file_service.delete_page_image(project_id, page_id)\n\n        # Delete page\n        db.session.delete(page)\n\n        # Update project\n        project = Project.query.get(project_id)\n        if project:\n            project.updated_at = datetime.utcnow()\n\n        db.session.commit()\n\n        return success_response(message=\"Page deleted successfully\")\n\n    except Exception as e:\n        db.session.rollback()\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@page_bp.route('/<project_id>/pages/<page_id>', methods=['PUT'])\ndef update_page(project_id, page_id):\n    \"\"\"\n    PUT /api/projects/{project_id}/pages/{page_id} - Update page fields\n\n    Request body:\n    {\n        \"part\": \"章节名\"\n    }\n    \"\"\"\n    try:\n        page = Page.query.get(page_id)\n\n        if not page or page.project_id != project_id:\n            return not_found('Page')\n\n        data = request.get_json()\n\n        if not data:\n            return bad_request(\"Request body is required\")\n\n        # Update part field if provided\n        if 'part' in data:\n            page.part = data['part']\n\n        page.updated_at = datetime.utcnow()\n\n        # Update project\n        if page.project:\n            page.project.updated_at = datetime.utcnow()\n\n        db.session.commit()\n\n        return success_response(page.to_dict())\n\n    except Exception as e:\n        db.session.rollback()\n        logger.error(f\"Failed to update page {page_id}: {e}\")\n        return error_response('SERVER_ERROR', 'An internal server error occurred', 500)\n\n\n@page_bp.route('/<project_id>/pages/<page_id>/outline', methods=['PUT'])\ndef update_page_outline(project_id, page_id):\n    \"\"\"\n    PUT /api/projects/{project_id}/pages/{page_id}/outline - Edit page outline\n    \n    Request body:\n    {\n        \"outline_content\": {\"title\": \"...\", \"points\": [...]}\n    }\n    \"\"\"\n    try:\n        page = Page.query.get(page_id)\n        \n        if not page or page.project_id != project_id:\n            return not_found('Page')\n        \n        data = request.get_json()\n        \n        if not data or 'outline_content' not in data:\n            return bad_request(\"outline_content is required\")\n        \n        page.set_outline_content(data['outline_content'])\n        page.updated_at = datetime.utcnow()\n        \n        # Update project\n        project = Project.query.get(project_id)\n        if project:\n            project.updated_at = datetime.utcnow()\n        \n        db.session.commit()\n        \n        return success_response(page.to_dict())\n    \n    except Exception as e:\n        db.session.rollback()\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@page_bp.route('/<project_id>/pages/<page_id>/description', methods=['PUT'])\ndef update_page_description(project_id, page_id):\n    \"\"\"\n    PUT /api/projects/{project_id}/pages/{page_id}/description - Edit description\n    \n    Request body:\n    {\n        \"description_content\": {\n            \"title\": \"...\",\n            \"text_content\": [\"...\", \"...\"],\n            \"extra_fields\": {\"排版布局\": \"...\"}\n        }\n    }\n    \"\"\"\n    try:\n        page = Page.query.get(page_id)\n        \n        if not page or page.project_id != project_id:\n            return not_found('Page')\n        \n        data = request.get_json()\n        \n        if not data or 'description_content' not in data:\n            return bad_request(\"description_content is required\")\n        \n        page.set_description_content(data['description_content'])\n        page.updated_at = datetime.utcnow()\n        \n        # Update project\n        project = Project.query.get(project_id)\n        if project:\n            project.updated_at = datetime.utcnow()\n        \n        db.session.commit()\n        \n        return success_response(page.to_dict())\n    \n    except Exception as e:\n        db.session.rollback()\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@page_bp.route('/<project_id>/pages/<page_id>/generate/description', methods=['POST'])\ndef generate_page_description(project_id, page_id):\n    \"\"\"\n    POST /api/projects/{project_id}/pages/{page_id}/generate/description - Generate single page description\n    \n    Request body:\n    {\n        \"force_regenerate\": false\n    }\n    \"\"\"\n    try:\n        page = Page.query.get(page_id)\n        \n        if not page or page.project_id != project_id:\n            return not_found('Page')\n        \n        project = Project.query.get(project_id)\n        if not project:\n            return not_found('Project')\n        \n        data = request.get_json() or {}\n        force_regenerate = data.get('force_regenerate', False)\n        language = data.get('language', current_app.config.get('OUTPUT_LANGUAGE', 'zh'))\n        detail_level = data.get('detail_level', 'default')\n\n        # Check if already generated\n        if page.get_description_content() and not force_regenerate:\n            return bad_request(\"Description already exists. Set force_regenerate=true to regenerate\")\n        \n        # Get outline content\n        outline_content = page.get_outline_content()\n        if not outline_content:\n            return bad_request(\"Page must have outline content first\")\n        \n        # Reconstruct full outline\n        all_pages = Page.query.filter_by(project_id=project_id).order_by(Page.order_index).all()\n        outline = []\n        for p in all_pages:\n            oc = p.get_outline_content()\n            if oc:\n                page_data = oc.copy()\n                if p.part:\n                    page_data['part'] = p.part\n                outline.append(page_data)\n        \n        # Initialize AI service\n        ai_service = get_ai_service()\n        \n        # Get reference files content and create project context\n        from controllers.project_controller import _get_project_reference_files_content\n        reference_files_content = _get_project_reference_files_content(project_id)\n        project_context = ProjectContext(project, reference_files_content)\n        \n        # Generate description\n        page_data = outline_content.copy()\n        if page.part:\n            page_data['part'] = page.part\n        \n        desc_result = ai_service.generate_page_description(\n            project_context,\n            outline,\n            page_data,\n            page.order_index + 1,\n            language=language,\n            detail_level=detail_level\n        )\n\n        # Save description (generate_page_description returns dict with text + optional extra_fields)\n        desc_content = {\n            \"text\": desc_result['text'],\n            \"generated_at\": datetime.utcnow().isoformat()\n        }\n        if desc_result.get('extra_fields'):\n            desc_content['extra_fields'] = desc_result['extra_fields']\n        \n        page.set_description_content(desc_content)\n        page.status = 'DESCRIPTION_GENERATED'\n        page.updated_at = datetime.utcnow()\n        \n        db.session.commit()\n        \n        return success_response(page.to_dict())\n    \n    except Exception as e:\n        db.session.rollback()\n        return error_response('AI_SERVICE_ERROR', str(e), 503)\n\n\n@page_bp.route('/<project_id>/pages/<page_id>/generate/image', methods=['POST'])\ndef generate_page_image(project_id, page_id):\n    \"\"\"\n    POST /api/projects/{project_id}/pages/{page_id}/generate/image - Generate single page image\n    \n    Request body:\n    {\n        \"use_template\": true,\n        \"force_regenerate\": false\n    }\n    \"\"\"\n    try:\n        page = Page.query.get(page_id)\n        \n        if not page or page.project_id != project_id:\n            return not_found('Page')\n        \n        project = Project.query.get(project_id)\n        if not project:\n            return not_found('Project')\n        \n        data = request.get_json() or {}\n        use_template = data.get('use_template', True)\n        force_regenerate = data.get('force_regenerate', False)\n        language = data.get('language', current_app.config.get('OUTPUT_LANGUAGE', 'zh'))\n        \n        # Check if already generated\n        if page.generated_image_path and not force_regenerate:\n            return bad_request(\"Image already exists. Set force_regenerate=true to regenerate\")\n        \n        # Get description content\n        desc_content = page.get_description_content()\n        if not desc_content:\n            return bad_request(\"Page must have description content first\")\n        \n        # Reconstruct full outline with part structure\n        all_pages = Page.query.filter_by(project_id=project_id).order_by(Page.order_index).all()\n        outline = []\n        current_part = None\n        current_part_pages = []\n        \n        for p in all_pages:\n            oc = p.get_outline_content()\n            if not oc:\n                continue\n                \n            page_data = oc.copy()\n            \n            # 如果当前页面属于一个 part\n            if p.part:\n                # 如果这是新的 part，先保存之前的 part（如果有）\n                if current_part and current_part != p.part:\n                    outline.append({\n                        \"part\": current_part,\n                        \"pages\": current_part_pages\n                    })\n                    current_part_pages = []\n                \n                current_part = p.part\n                # 移除 part 字段，因为它在顶层\n                if 'part' in page_data:\n                    del page_data['part']\n                current_part_pages.append(page_data)\n            else:\n                # 如果当前页面不属于任何 part，先保存之前的 part（如果有）\n                if current_part:\n                    outline.append({\n                        \"part\": current_part,\n                        \"pages\": current_part_pages\n                    })\n                    current_part = None\n                    current_part_pages = []\n                \n                # 直接添加页面\n                outline.append(page_data)\n        \n        # 保存最后一个 part（如果有）\n        if current_part:\n            outline.append({\n                \"part\": current_part,\n                \"pages\": current_part_pages\n            })\n        \n        # Initialize services\n        ai_service = get_ai_service()\n        \n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n        \n        # Get template path\n        ref_image_path = None\n        if use_template:\n            ref_image_path = file_service.get_template_path(project_id)\n        \n        # 检查是否有模板图片或风格描述\n        # 如果都没有，则返回错误\n        if not ref_image_path and not project.template_style:\n            return bad_request(\"No template image or style description found for project\")\n        \n        # Generate prompt\n        page_data = page.get_outline_content() or {}\n        if page.part:\n            page_data['part'] = page.part\n        \n        # 获取描述文本（可能是 text 字段或 text_content 数组）\n        desc_text = desc_content.get('text', '')\n        if not desc_text and desc_content.get('text_content'):\n            # 如果 text 字段不存在，尝试从 text_content 数组获取\n            text_content = desc_content.get('text_content', [])\n            if isinstance(text_content, list):\n                desc_text = '\\n'.join(text_content)\n            else:\n                desc_text = str(text_content)\n        \n        # 从当前页面的描述内容中提取图片 URL（在生成 prompt 之前提取，以便告知 AI）\n        additional_ref_images = []\n        has_material_images = False\n        \n        # 从描述文本中提取图片\n        if desc_text:\n            image_urls = ai_service.extract_image_urls_from_markdown(desc_text)\n            if image_urls:\n                logger.info(f\"Found {len(image_urls)} image(s) in page {page_id} description\")\n                additional_ref_images = image_urls\n                has_material_images = True\n        \n        # 合并额外要求和风格描述\n        combined_requirements = project.extra_requirements or \"\"\n        if project.template_style:\n            style_requirement = f\"\\n\\nppt页面风格描述：\\n\\n{project.template_style}\"\n            combined_requirements = combined_requirements + style_requirement\n        \n        # Create async task for image generation\n        task = Task(\n            project_id=project_id,\n            task_type='GENERATE_PAGE_IMAGE',\n            status='PENDING'\n        )\n        task.set_progress({\n            'total': 1,\n            'completed': 0,\n            'failed': 0\n        })\n        db.session.add(task)\n        db.session.commit()\n        \n        # Get app instance for background task\n        app = current_app._get_current_object()\n        \n        # Submit background task\n        task_manager.submit_task(\n            task.id,\n            generate_single_page_image_task,\n            project_id,\n            page_id,\n            ai_service,\n            file_service,\n            outline,\n            use_template,\n            project.image_aspect_ratio,\n            current_app.config['DEFAULT_RESOLUTION'],\n            app,\n            combined_requirements if combined_requirements.strip() else None,\n            language\n        )\n        \n        # Return task_id immediately\n        return success_response({\n            'task_id': task.id,\n            'page_id': page_id,\n            'status': 'PENDING'\n        }, status_code=202)\n    \n    except Exception as e:\n        db.session.rollback()\n        return error_response('AI_SERVICE_ERROR', str(e), 503)\n\n\n@page_bp.route('/<project_id>/pages/<page_id>/edit/image', methods=['POST'])\ndef edit_page_image(project_id, page_id):\n    \"\"\"\n    POST /api/projects/{project_id}/pages/{page_id}/edit/image - Edit page image\n    \n    Request body (JSON or multipart/form-data):\n    {\n        \"edit_instruction\": \"更改文本框样式为虚线\",\n        \"context_images\": {\n            \"use_template\": true,  // 是否使用template图片\n            \"desc_image_urls\": [\"url1\", \"url2\"],  // desc中的图片URL列表\n            \"uploaded_image_ids\": [\"file1\", \"file2\"]  // 上传的图片文件ID列表（在multipart中）\n        }\n    }\n    \n    For multipart/form-data:\n    - edit_instruction: text field\n    - use_template: text field (true/false)\n    - desc_image_urls: JSON array string\n    - context_images: file uploads (multiple files with key \"context_images\")\n    \"\"\"\n    try:\n        page = Page.query.get(page_id)\n        \n        if not page or page.project_id != project_id:\n            return not_found('Page')\n        \n        if not page.generated_image_path:\n            return bad_request(\"Page must have generated image first\")\n        \n        project = Project.query.get(project_id)\n        if not project:\n            return not_found('Project')\n        \n        # Initialize services\n        ai_service = get_ai_service()\n        \n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n        \n        # Parse request data (support both JSON and multipart/form-data)\n        if request.is_json:\n            data = request.get_json()\n            uploaded_files = []\n        else:\n            # multipart/form-data\n            data = request.form.to_dict()\n            # Get uploaded files\n            uploaded_files = request.files.getlist('context_images')\n            # Parse JSON fields\n            if 'desc_image_urls' in data and data['desc_image_urls']:\n                try:\n                    data['desc_image_urls'] = json.loads(data['desc_image_urls'])\n                except Exception:\n                    data['desc_image_urls'] = []\n            else:\n                data['desc_image_urls'] = []\n        \n        if not data or 'edit_instruction' not in data:\n            return bad_request(\"edit_instruction is required\")\n        \n        # Get current image path\n        current_image_path = file_service.get_absolute_path(page.generated_image_path)\n        \n        # Get original description if available\n        original_description = None\n        desc_content = page.get_description_content()\n        if desc_content:\n            # Extract text from description_content\n            original_description = desc_content.get('text') or ''\n            # If text is not available, try to construct from text_content\n            if not original_description and desc_content.get('text_content'):\n                if isinstance(desc_content['text_content'], list):\n                    original_description = '\\n'.join(desc_content['text_content'])\n                else:\n                    original_description = str(desc_content['text_content'])\n        \n        # Collect additional reference images\n        additional_ref_images = []\n        \n        # 1. Add template image if requested\n        context_images = data.get('context_images', {})\n        if isinstance(context_images, dict):\n            use_template = context_images.get('use_template', False)\n        else:\n            use_template = data.get('use_template', 'false').lower() == 'true'\n        \n        if use_template:\n            template_path = file_service.get_template_path(project_id)\n            if template_path:\n                additional_ref_images.append(template_path)\n        \n        # 2. Add desc image URLs if provided\n        if isinstance(context_images, dict):\n            desc_image_urls = context_images.get('desc_image_urls', [])\n        else:\n            desc_image_urls = data.get('desc_image_urls', [])\n        \n        if desc_image_urls:\n            if isinstance(desc_image_urls, str):\n                try:\n                    desc_image_urls = json.loads(desc_image_urls)\n                except Exception:\n                    desc_image_urls = []\n            if isinstance(desc_image_urls, list):\n                additional_ref_images.extend(desc_image_urls)\n        \n        # 3. Save and add uploaded files to a persistent location\n        temp_dir = None\n        if uploaded_files:\n            # Create a temporary directory in the project's upload folder\n            import tempfile\n            import shutil\n            from werkzeug.utils import secure_filename\n            temp_dir = Path(tempfile.mkdtemp(dir=current_app.config['UPLOAD_FOLDER']))\n            try:\n                for uploaded_file in uploaded_files:\n                    if uploaded_file.filename:\n                        # Save to temp directory\n                        temp_path = temp_dir / secure_filename(uploaded_file.filename)\n                        uploaded_file.save(str(temp_path))\n                        additional_ref_images.append(str(temp_path))\n            except Exception as e:\n                # Clean up temp directory on error\n                if temp_dir and temp_dir.exists():\n                    shutil.rmtree(temp_dir)\n                raise e\n        \n        # Create async task for image editing\n        task = Task(\n            project_id=project_id,\n            task_type='EDIT_PAGE_IMAGE',\n            status='PENDING'\n        )\n        task.set_progress({\n            'total': 1,\n            'completed': 0,\n            'failed': 0\n        })\n        db.session.add(task)\n        db.session.commit()\n        \n        # Get app instance for background task\n        app = current_app._get_current_object()\n        \n        # Submit background task\n        task_manager.submit_task(\n            task.id,\n            edit_page_image_task,\n            project_id,\n            page_id,\n            data['edit_instruction'],\n            ai_service,\n            file_service,\n            project.image_aspect_ratio,\n            current_app.config['DEFAULT_RESOLUTION'],\n            original_description,\n            additional_ref_images if additional_ref_images else None,\n            str(temp_dir) if temp_dir else None,\n            app\n        )\n        \n        # Return task_id immediately\n        return success_response({\n            'task_id': task.id,\n            'page_id': page_id,\n            'status': 'PENDING'\n        }, status_code=202)\n    \n    except Exception as e:\n        db.session.rollback()\n        return error_response('AI_SERVICE_ERROR', str(e), 503)\n\n\n\n@page_bp.route('/<project_id>/pages/<page_id>/image-versions', methods=['GET'])\ndef get_page_image_versions(project_id, page_id):\n    \"\"\"\n    GET /api/projects/{project_id}/pages/{page_id}/image-versions - Get all image versions for a page\n    \"\"\"\n    try:\n        page = Page.query.get(page_id)\n        \n        if not page or page.project_id != project_id:\n            return not_found('Page')\n        \n        versions = PageImageVersion.query.filter_by(page_id=page_id)\\\n            .order_by(PageImageVersion.version_number.desc()).all()\n        \n        return success_response({\n            'versions': [v.to_dict() for v in versions]\n        })\n    \n    except Exception as e:\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@page_bp.route('/<project_id>/pages/<page_id>/image-versions/<version_id>/set-current', methods=['POST'])\ndef set_current_image_version(project_id, page_id, version_id):\n    \"\"\"\n    POST /api/projects/{project_id}/pages/{page_id}/image-versions/{version_id}/set-current\n    Set a specific version as the current one\n    \"\"\"\n    try:\n        page = Page.query.get(page_id)\n        \n        if not page or page.project_id != project_id:\n            return not_found('Page')\n        \n        version = PageImageVersion.query.get(version_id)\n        \n        if not version or version.page_id != page_id:\n            return not_found('Image Version')\n        \n        # Mark all versions as not current\n        PageImageVersion.query.filter_by(page_id=page_id).update({'is_current': False})\n\n        # Set this version as current\n        version.is_current = True\n        page.generated_image_path = version.image_path\n\n        # 更新 cached_image_path，指向该版本的缓存图（如果存在）\n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n        cached_relative_path = file_service.get_cached_image_path(project_id, page_id, version.version_number)\n        if file_service.file_exists(cached_relative_path):\n            page.cached_image_path = cached_relative_path\n        else:\n            # 缓存文件不存在，设置为 None，to_dict() 会回退到原图\n            page.cached_image_path = None\n\n        page.updated_at = datetime.utcnow()\n        \n        db.session.commit()\n        \n        return success_response(page.to_dict(include_versions=True))\n\n    except Exception as e:\n        db.session.rollback()\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@page_bp.route('/<project_id>/pages/<page_id>/regenerate-renovation', methods=['POST'])\ndef regenerate_renovation_page(project_id, page_id):\n    \"\"\"\n    POST /api/projects/{project_id}/pages/{page_id}/regenerate-renovation\n\n    Re-parse the original PDF page and regenerate outline + description for PPT renovation projects.\n    This re-runs the renovation pipeline for a single page.\n    \"\"\"\n    try:\n        page = Page.query.get(page_id)\n\n        if not page or page.project_id != project_id:\n            return not_found('Page')\n\n        project = Project.query.get(project_id)\n        if not project:\n            return not_found('Project')\n\n        # Verify this is a renovation project\n        if project.creation_type != 'ppt_renovation':\n            return bad_request(\"This endpoint is only for PPT renovation projects\")\n\n        data = request.get_json() or {}\n        language = data.get('language', current_app.config.get('OUTPUT_LANGUAGE', 'zh'))\n        keep_layout = data.get('keep_layout', False)\n\n        # Find the split PDF for this page\n        project_dir = Path(current_app.config['UPLOAD_FOLDER']) / project_id\n        split_dir = project_dir / \"split_pages\"\n        page_pdf_path = split_dir / f\"page_{page.order_index + 1}.pdf\"\n\n        if not page_pdf_path.exists():\n            return bad_request(f\"Split PDF not found for page {page.order_index + 1}\")\n\n        # Initialize services\n        ai_service = get_ai_service()\n        from services.file_parser_service import FileParserService\n        file_parser_service = FileParserService(\n            mineru_api_base=current_app.config.get('MINERU_API_BASE', ''),\n            mineru_token=current_app.config.get('MINERU_TOKEN', ''),\n            google_api_key=current_app.config.get('GOOGLE_API_KEY', ''),\n            ai_provider_format=current_app.config.get('AI_PROVIDER_FORMAT', 'gemini'),\n            openai_api_key=current_app.config.get('OPENAI_API_KEY', ''),\n            openai_api_base=current_app.config.get('OPENAI_API_BASE', ''),\n            image_caption_model=current_app.config.get('IMAGE_CAPTION_MODEL', 'gemini-3-flash-preview'),\n            lazyllm_image_caption_source=current_app.config.get('IMAGE_CAPTION_MODEL_SOURCE', ''),\n            upload_folder=current_app.config.get('UPLOAD_FOLDER', 'uploads')\n        )\n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n\n        # Step 1: Parse page PDF → markdown\n        logger.info(f\"Regenerating renovation page {page.order_index + 1}: parsing PDF...\")\n        filename = f\"page_{page.order_index + 1}.pdf\"\n        _batch_id, md_text, extract_id, error_msg, _failed = file_parser_service.parse_file(\n            str(page_pdf_path), filename\n        )\n\n        if error_msg:\n            logger.warning(f\"Page {page.order_index + 1} parse warning: {error_msg}\")\n\n        md_text = md_text or ''\n\n        # Supplement with header/footer from layout.json\n        if extract_id:\n            hf_text = file_parser_service.extract_header_footer_from_layout(extract_id)\n            if hf_text:\n                md_text = hf_text + '\\n\\n' + md_text\n\n        if not md_text.strip():\n            return error_response('PARSE_ERROR', f\"Failed to extract content from page {page.order_index + 1}\", 400)\n\n        # Step 2: AI extract structured content\n        logger.info(f\"Regenerating renovation page {page.order_index + 1}: extracting content...\")\n        content = ai_service.extract_page_content(md_text, language=language)\n\n        # Step 3: Optional layout caption\n        if keep_layout:\n            try:\n                image_path = None\n                if page.cached_image_path:\n                    image_path = file_service.get_absolute_path(page.cached_image_path)\n                elif page.generated_image_path:\n                    image_path = file_service.get_absolute_path(page.generated_image_path)\n                if image_path and Path(image_path).exists():\n                    caption = ai_service.generate_layout_caption(image_path)\n                    if caption:\n                        content['description'] = content.get('description', '') + f\"\\n\\n{caption}\"\n            except Exception as e:\n                logger.error(f\"Layout caption failed for page {page.order_index + 1}: {e}\")\n\n        # Step 4: Update page in database\n        title = content.get('title', f'Page {page.order_index + 1}')\n        points = content.get('points', [])\n        description = content.get('description', '')\n\n        page.set_outline_content({\n            'title': title,\n            'points': points\n        })\n        page.set_description_content({\n            \"text\": description,\n            \"generated_at\": datetime.utcnow().isoformat()\n        })\n        page.status = 'DESCRIPTION_GENERATED'\n        page.updated_at = datetime.utcnow()\n\n        db.session.commit()\n\n        logger.info(f\"Regenerated renovation page {page.order_index + 1} successfully\")\n\n        return success_response(page.to_dict())\n\n    except Exception as e:\n        db.session.rollback()\n        logger.error(f\"Failed to regenerate renovation page: {e}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n"
  },
  {
    "path": "backend/controllers/project_controller.py",
    "content": "\"\"\"\nProject Controller - handles project-related endpoints\n\"\"\"\nimport json\nimport logging\nimport os\nimport subprocess\nimport traceback\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom flask import Blueprint, request, jsonify, current_app, Response, stream_with_context\nfrom sqlalchemy import desc\nfrom utils.validators import normalize_aspect_ratio\nfrom sqlalchemy.orm import joinedload\nfrom werkzeug.exceptions import BadRequest\nfrom werkzeug.utils import secure_filename\n\nfrom models import db, Project, Page, Task, ReferenceFile\nfrom services import ProjectContext, FileService\nfrom services.ai_service_manager import get_ai_service\nfrom services.task_manager import (\n    task_manager,\n    generate_descriptions_task,\n    generate_images_task,\n    process_ppt_renovation_task\n)\nfrom utils import (\n    success_response, error_response, not_found, bad_request,\n    parse_page_ids_from_body, get_filtered_pages\n)\n\nlogger = logging.getLogger(__name__)\n\nproject_bp = Blueprint('projects', __name__, url_prefix='/api/projects')\n\n\ndef _get_project_reference_files_content(project_id: str) -> list:\n    \"\"\"\n    Get reference files content for a project\n    \n    Args:\n        project_id: Project ID\n        \n    Returns:\n        List of dicts with 'filename' and 'content' keys\n    \"\"\"\n    reference_files = ReferenceFile.query.filter_by(\n        project_id=project_id,\n        parse_status='completed'\n    ).all()\n    \n    files_content = []\n    for ref_file in reference_files:\n        if ref_file.markdown_content:\n            files_content.append({\n                'filename': ref_file.filename,\n                'content': ref_file.markdown_content\n            })\n    \n    return files_content\n\n\ndef _reconstruct_outline_from_pages(pages: list) -> list:\n    \"\"\"\n    Reconstruct outline structure from Page objects\n    \n    Args:\n        pages: List of Page objects ordered by order_index\n        \n    Returns:\n        Outline structure (list) with optional part grouping\n    \"\"\"\n    outline = []\n    current_part = None\n    current_part_pages = []\n    \n    for page in pages:\n        outline_content = page.get_outline_content()\n        if not outline_content:\n            continue\n            \n        page_data = outline_content.copy()\n        \n        # 如果当前页面属于一个 part\n        if page.part:\n            # 如果这是新的 part，先保存之前的 part（如果有）\n            if current_part and current_part != page.part:\n                outline.append({\n                    \"part\": current_part,\n                    \"pages\": current_part_pages\n                })\n                current_part_pages = []\n            \n            current_part = page.part\n            # 移除 part 字段，因为它在顶层\n            if 'part' in page_data:\n                del page_data['part']\n            current_part_pages.append(page_data)\n        else:\n            # 如果当前页面不属于任何 part，先保存之前的 part（如果有）\n            if current_part:\n                outline.append({\n                    \"part\": current_part,\n                    \"pages\": current_part_pages\n                })\n                current_part = None\n                current_part_pages = []\n            \n            # 直接添加页面\n            outline.append(page_data)\n    \n    # 保存最后一个 part（如果有）\n    if current_part:\n        outline.append({\n            \"part\": current_part,\n            \"pages\": current_part_pages\n        })\n    \n    return outline\n\n\ndef _smart_merge_pages(project_id, pages_data):\n    \"\"\"Position-based merge: reuse existing pages by index to preserve descriptions/images.\n\n    For each new page at index i:\n      - If an old page exists at the same position, update its outline (title/points/part)\n        in place, keeping description_content and image fields untouched.\n      - If no old page at that position, create a new page.\n    Old pages beyond the new page count are deleted.\n    \"\"\"\n    old_pages = Page.query.filter_by(project_id=project_id).order_by(Page.order_index).all()\n    pages_list = []\n\n    for i, page_data in enumerate(pages_data):\n        if i < len(old_pages):\n            page = old_pages[i]\n        else:\n            page = Page(project_id=project_id, status='DRAFT')\n            db.session.add(page)\n\n        page.order_index = i\n        page.part = page_data.get('part')\n        page.set_outline_content({\n            'title': page_data.get('title'),\n            'points': page_data.get('points', [])\n        })\n        pages_list.append(page)\n\n    for p in old_pages[len(pages_data):]:\n        db.session.delete(p)\n\n    return pages_list\n\n\n@project_bp.route('', methods=['GET'])\ndef list_projects():\n    \"\"\"\n    GET /api/projects - Get all projects (for history)\n    \n    Query params:\n    - limit: number of projects to return (default: 50, max: 100)\n    - offset: offset for pagination (default: 0)\n    \"\"\"\n    try:\n        # Parameter validation\n        limit = request.args.get('limit', 50, type=int)\n        offset = request.args.get('offset', 0, type=int)\n\n        # Enforce limits to prevent performance issues\n        limit = min(max(1, limit), 100)  # Between 1-100\n        offset = max(0, offset)  # Non-negative\n\n        # Get total count for pagination\n        total = Project.query.count()\n\n        projects = Project.query\\\n            .options(joinedload(Project.pages))\\\n            .order_by(desc(Project.updated_at))\\\n            .limit(limit)\\\n            .offset(offset)\\\n            .all()\n\n        return success_response({\n            'projects': [project.to_dict(include_pages=True) for project in projects],\n            'total': total,\n            'limit': limit,\n            'offset': offset\n        })\n    \n    except Exception as e:\n        logger.error(f\"list_projects failed: {str(e)}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@project_bp.route('', methods=['POST'])\ndef create_project():\n    \"\"\"\n    POST /api/projects - Create a new project\n    \n    Request body:\n    {\n        \"creation_type\": \"idea|outline|descriptions\",\n        \"idea_prompt\": \"...\",  # required for idea type\n        \"outline_text\": \"...\",  # required for outline type\n        \"description_text\": \"...\",  # required for descriptions type\n        \"template_id\": \"optional\"\n    }\n    \"\"\"\n    try:\n        data = request.get_json()\n        \n        if not data:\n            return bad_request(\"Request body is required\")\n        \n        # creation_type is required\n        if 'creation_type' not in data:\n            return bad_request(\"creation_type is required\")\n        \n        creation_type = data.get('creation_type')\n        \n        if creation_type not in ['idea', 'outline', 'descriptions']:\n            return bad_request(\"Invalid creation_type\")\n        \n        # Validate and set aspect ratio if provided\n        image_aspect_ratio = '16:9'\n        if 'image_aspect_ratio' in data:\n            try:\n                image_aspect_ratio = normalize_aspect_ratio(data['image_aspect_ratio'])\n            except ValueError as e:\n                return bad_request(str(e))\n\n        # Create project\n        project = Project(\n            creation_type=creation_type,\n            idea_prompt=data.get('idea_prompt'),\n            outline_text=data.get('outline_text'),\n            description_text=data.get('description_text'),\n            template_style=data.get('template_style'),\n            image_aspect_ratio=image_aspect_ratio,\n            status='DRAFT'\n        )\n        \n        db.session.add(project)\n        db.session.commit()\n        \n        return success_response({\n            'project_id': project.id,\n            'status': project.status,\n            'pages': []\n        }, status_code=201)\n    \n    except BadRequest as e:\n        # Handle JSON parsing errors (invalid JSON body)\n        db.session.rollback()\n        logger.warning(f\"create_project: Invalid JSON body - {str(e)}\")\n        return bad_request(\"Invalid JSON in request body\")\n    \n    except Exception as e:\n        db.session.rollback()\n        error_trace = traceback.format_exc()\n        logger.error(f\"create_project failed: {str(e)}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@project_bp.route('/<project_id>', methods=['GET'])\ndef get_project(project_id):\n    \"\"\"\n    GET /api/projects/{project_id} - Get project details\n    \"\"\"\n    try:\n        # Use eager loading to load project and related pages\n        project = Project.query\\\n            .options(joinedload(Project.pages))\\\n            .filter(Project.id == project_id)\\\n            .first()\n        \n        if not project:\n            return not_found('Project')\n        \n        return success_response(project.to_dict(include_pages=True))\n    \n    except Exception as e:\n        logger.error(f\"get_project failed: {str(e)}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@project_bp.route('/<project_id>', methods=['PUT'])\ndef update_project(project_id):\n    \"\"\"\n    PUT /api/projects/{project_id} - Update project\n    \n    Request body:\n    {\n        \"idea_prompt\": \"...\",\n        \"pages_order\": [\"page-uuid-1\", \"page-uuid-2\", ...]\n    }\n    \"\"\"\n    try:\n        # Use eager loading to load project and pages (for page order updates)\n        project = Project.query\\\n            .options(joinedload(Project.pages))\\\n            .filter(Project.id == project_id)\\\n            .first()\n        \n        if not project:\n            return not_found('Project')\n        \n        data = request.get_json()\n        \n        # Update idea_prompt if provided\n        if 'idea_prompt' in data:\n            project.idea_prompt = data['idea_prompt']\n\n        # Update outline_text if provided\n        if 'outline_text' in data:\n            project.outline_text = data['outline_text']\n\n        # Update description_text if provided\n        if 'description_text' in data:\n            project.description_text = data['description_text']\n\n        # Update extra_requirements if provided\n        if 'extra_requirements' in data:\n            project.extra_requirements = data['extra_requirements']\n\n        # Update generation requirements if provided\n        if 'outline_requirements' in data:\n            project.outline_requirements = data['outline_requirements']\n        if 'description_requirements' in data:\n            project.description_requirements = data['description_requirements']\n        \n        # Update template_style if provided\n        if 'template_style' in data:\n            project.template_style = data['template_style']\n        \n        # Update aspect ratio if provided\n        if 'image_aspect_ratio' in data:\n            try:\n                project.image_aspect_ratio = normalize_aspect_ratio(data['image_aspect_ratio'])\n            except ValueError as e:\n                return bad_request(str(e))\n\n        # Update export settings if provided\n        if 'export_extractor_method' in data:\n            project.export_extractor_method = data['export_extractor_method']\n        if 'export_inpaint_method' in data:\n            project.export_inpaint_method = data['export_inpaint_method']\n        \n        # Update page order if provided\n        if 'pages_order' in data:\n            pages_order = data['pages_order']\n            # Optimization: batch query all pages to update, avoiding N+1 queries\n            pages_to_update = Page.query.filter(\n                Page.id.in_(pages_order),\n                Page.project_id == project_id\n            ).all()\n            \n            # Create page_id -> page mapping for O(1) lookup\n            pages_map = {page.id: page for page in pages_to_update}\n            \n            # Batch update order\n            for index, page_id in enumerate(pages_order):\n                if page_id in pages_map:\n                    pages_map[page_id].order_index = index\n        \n        project.updated_at = datetime.utcnow()\n        db.session.commit()\n        \n        return success_response(project.to_dict(include_pages=True))\n    \n    except Exception as e:\n        db.session.rollback()\n        logger.error(f\"update_project failed: {str(e)}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@project_bp.route('/<project_id>', methods=['DELETE'])\ndef delete_project(project_id):\n    \"\"\"\n    DELETE /api/projects/{project_id} - Delete project\n    \"\"\"\n    try:\n        project = Project.query.get(project_id)\n        \n        if not project:\n            return not_found('Project')\n        \n        # Delete project files\n        from services import FileService\n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n        file_service.delete_project_files(project_id)\n        \n        # Delete project from database (cascade will delete pages and tasks)\n        db.session.delete(project)\n        db.session.commit()\n        \n        return success_response(message=\"Project deleted successfully\")\n    \n    except Exception as e:\n        db.session.rollback()\n        logger.error(f\"delete_project failed: {str(e)}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@project_bp.route('/<project_id>/generate/outline', methods=['POST'])\ndef generate_outline(project_id):\n    \"\"\"\n    POST /api/projects/{project_id}/generate/outline - Generate outline\n\n    For 'idea' type: Generate outline from idea_prompt\n    For 'outline' type: Parse outline_text into structured format\n    For 'descriptions' type: Extract outline structure from description_text\n\n    Request body (optional):\n    {\n        \"idea_prompt\": \"...\",  # for idea type\n        \"language\": \"zh\"  # output language: zh, en, ja, auto\n    }\n    \"\"\"\n    try:\n        project = Project.query.get(project_id)\n        \n        if not project:\n            return not_found('Project')\n        \n        # Get singleton AI service instance\n        ai_service = get_ai_service()\n        \n        # Get request data and language parameter\n        data = request.get_json() or {}\n        language = data.get('language', current_app.config.get('OUTPUT_LANGUAGE', 'zh'))\n        \n        # Get reference files content and create project context\n        reference_files_content = _get_project_reference_files_content(project_id)\n        if reference_files_content:\n            logger.info(f\"Found {len(reference_files_content)} reference files for project {project_id}\")\n            for rf in reference_files_content:\n                logger.info(f\"  - {rf['filename']}: {len(rf['content'])} characters\")\n        else:\n            logger.info(f\"No reference files found for project {project_id}\")\n        \n        # 根据项目类型选择不同的处理方式\n        if project.creation_type == 'outline':\n            # 从大纲生成：解析用户输入的大纲文本\n            if not project.outline_text:\n                return bad_request(\"outline_text is required for outline type project\")\n            \n            # Create project context and parse outline text into structured format\n            project_context = ProjectContext(project, reference_files_content)\n            outline = ai_service.parse_outline_text(project_context, language=language)\n        elif project.creation_type == 'descriptions':\n            # 从描述生成：从 description_text 提取大纲结构（仅大纲，不含页面描述）\n            if not project.description_text:\n                return bad_request(\"description_text is required for descriptions type project\")\n\n            project_context = ProjectContext(project, reference_files_content)\n            outline = ai_service.parse_description_to_outline(project_context, language=language)\n        else:\n            # 一句话生成：从idea生成大纲\n            idea_prompt = data.get('idea_prompt') or project.idea_prompt\n            \n            if not idea_prompt:\n                return bad_request(\"idea_prompt is required\")\n            \n            project.idea_prompt = idea_prompt\n            \n            # Create project context and generate outline from idea\n            project_context = ProjectContext(project, reference_files_content)\n            outline = ai_service.generate_outline(project_context, language=language)\n        \n        # Flatten outline to pages and smart merge with existing\n        pages_data = ai_service.flatten_outline(outline)\n        pages_list = _smart_merge_pages(project_id, pages_data)\n\n        # Update project status (don't downgrade if all pages already have content)\n        if all(p.description_content for p in pages_list) and pages_list:\n            project.status = 'DESCRIPTIONS_GENERATED'\n        else:\n            project.status = 'OUTLINE_GENERATED'\n        project.updated_at = datetime.utcnow()\n        \n        db.session.commit()\n        \n        logger.info(f\"大纲生成完成: 项目 {project_id}, 创建了 {len(pages_list)} 个页面\")\n        \n        # Return pages\n        return success_response({\n            'pages': [page.to_dict() for page in pages_list]\n        })\n    \n    except Exception as e:\n        db.session.rollback()\n        logger.error(f\"generate_outline failed: {str(e)}\", exc_info=True)\n        return error_response('AI_SERVICE_ERROR', str(e), 503)\n\n\n@project_bp.route('/<project_id>/generate/outline/stream', methods=['POST'])\ndef generate_outline_stream(project_id):\n    \"\"\"\n    POST /api/projects/{project_id}/generate/outline/stream - Stream outline generation via SSE\n\n    Streams pages one-by-one as they are generated. Each page is sent as an SSE event.\n    After all pages are streamed, saves them to the database.\n\n    SSE events:\n      event: page    — a single page object {index, title, points, part?}\n      event: done    — generation complete {total, pages: [...with ids...]}\n      event: error   — error occurred {message}\n    \"\"\"\n    # Validate project exists before entering the generator\n    project = Project.query.get(project_id)\n    if not project:\n        return not_found('Project')\n\n    data = request.get_json() or {}\n    language = data.get('language', current_app.config.get('OUTPUT_LANGUAGE', 'zh'))\n\n    # Capture app reference for use inside the generator (which runs outside request context)\n    app = current_app._get_current_object()\n\n    def sse_generate():\n        with app.app_context():\n            try:\n                # Re-fetch project inside app context to attach to this session\n                proj = db.session.get(Project, project_id)\n                ai_service = get_ai_service()\n                reference_files_content = _get_project_reference_files_content(project_id)\n\n                # Validate input based on creation type\n                if proj.creation_type == 'outline' and not proj.outline_text:\n                    yield _sse_event('error', {'message': 'outline_text is required'})\n                    return\n                if proj.creation_type == 'descriptions' and not proj.description_text:\n                    yield _sse_event('error', {'message': 'description_text is required'})\n                    return\n\n                # Update idea_prompt if provided\n                if proj.creation_type not in ('outline', 'descriptions'):\n                    idea_prompt = data.get('idea_prompt') or proj.idea_prompt\n                    if not idea_prompt:\n                        yield _sse_event('error', {'message': 'idea_prompt is required'})\n                        return\n                    proj.idea_prompt = idea_prompt\n\n                project_context = ProjectContext(proj, reference_files_content)\n\n                # Stream pages from AI\n                streamed_pages = []\n                stream_complete = False\n                for page_data in ai_service.generate_outline_stream(project_context, language=language):\n                    # Check for completion sentinel\n                    if '__stream_complete__' in page_data:\n                        stream_complete = page_data['__stream_complete__']\n                        continue\n                    i = len(streamed_pages)\n                    streamed_pages.append(page_data)\n                    yield _sse_event('page', {\n                        'index': i,\n                        'title': page_data.get('title', ''),\n                        'points': page_data.get('points', []),\n                        'part': page_data.get('part'),\n                    })\n\n                # Handle lock_page_count: pad with blank pages if needed\n                lock_page_count = data.get('lock_page_count', False)\n                if lock_page_count:\n                    old_pages = Page.query.filter_by(project_id=project_id).order_by(Page.order_index).all()\n                    old_count = len(old_pages)\n                    new_count = len(streamed_pages)\n                    if new_count < old_count:\n                        for _ in range(old_count - new_count):\n                            streamed_pages.append({'title': '', 'points': []})\n\n                # Save all pages to database\n                pages_list = _smart_merge_pages(project_id, streamed_pages)\n\n                if all(p.description_content for p in pages_list) and pages_list:\n                    proj.status = 'DESCRIPTIONS_GENERATED'\n                else:\n                    proj.status = 'OUTLINE_GENERATED'\n                proj.updated_at = datetime.utcnow()\n                db.session.commit()\n\n                logger.info(f\"流式大纲生成完成: 项目 {project_id}, {len(pages_list)} 个页面\")\n\n                yield _sse_event('done', {\n                    'total': len(pages_list),\n                    'pages': [p.to_dict() for p in pages_list],\n                    'complete': stream_complete,\n                })\n\n            except Exception as e:\n                try:\n                    db.session.rollback()\n                except Exception as rollback_exc:\n                    logger.warning(f\"Session rollback failed: {rollback_exc}\", exc_info=True)\n                logger.error(f\"generate_outline_stream failed: {str(e)}\", exc_info=True)\n                yield _sse_event('error', {'message': '生成过程中发生内部错误'})\n\n    return Response(\n        stream_with_context(sse_generate()),\n        mimetype='text/event-stream',\n        headers={\n            'Cache-Control': 'no-cache, no-transform',\n            'X-Accel-Buffering': 'no',\n            'Connection': 'keep-alive',\n        },\n    )\n\n\ndef _sse_event(event: str, data: dict) -> str:\n    \"\"\"Format a single SSE event.\"\"\"\n    return f\"event: {event}\\ndata: {json.dumps(data, ensure_ascii=False)}\\n\\n\"\n\n\n@project_bp.route('/<project_id>/generate/from-description', methods=['POST'])\ndef generate_from_description(project_id):\n    \"\"\"\n    POST /api/projects/{project_id}/generate/from-description - Generate outline and page descriptions from description text\n    \n    This endpoint:\n    1. Parses the description_text to extract outline structure\n    2. Splits the description_text into individual page descriptions\n    3. Creates pages with both outline and description content filled\n    4. Sets project status to DESCRIPTIONS_GENERATED\n    \n    Request body (optional):\n    {\n        \"description_text\": \"...\",  # if not provided, uses project.description_text\n        \"language\": \"zh\"  # output language: zh, en, ja, auto\n    }\n    \"\"\"\n    \n    try:\n        project = Project.query.get(project_id)\n        \n        if not project:\n            return not_found('Project')\n        \n        if project.creation_type != 'descriptions':\n            return bad_request(\"This endpoint is only for descriptions type projects\")\n        \n        # Get description text and language\n        data = request.get_json() or {}\n        description_text = data.get('description_text') or project.description_text\n        language = data.get('language', current_app.config.get('OUTPUT_LANGUAGE', 'zh'))\n        \n        if not description_text:\n            return bad_request(\"description_text is required\")\n        \n        project.description_text = description_text\n        \n        # Get singleton AI service instance\n        ai_service = get_ai_service()\n        \n        # Get reference files content and create project context\n        reference_files_content = _get_project_reference_files_content(project_id)\n        project_context = ProjectContext(project, reference_files_content)\n        \n        logger.info(f\"开始从描述生成大纲和页面描述: 项目 {project_id}\")\n        \n        # Step 1: Parse description to outline\n        logger.info(\"Step 1: 解析描述文本到大纲结构...\")\n        outline = ai_service.parse_description_to_outline(project_context, language=language)\n        logger.info(f\"大纲解析完成，共 {len(ai_service.flatten_outline(outline))} 页\")\n        \n        # Step 2: Split description into page descriptions\n        logger.info(\"Step 2: 切分描述文本到每页描述...\")\n        page_descriptions = ai_service.parse_description_to_page_descriptions(project_context, outline, language=language)\n        logger.info(f\"描述切分完成，共 {len(page_descriptions)} 页\")\n        \n        # Step 3: Flatten outline to pages\n        pages_data = ai_service.flatten_outline(outline)\n        \n        if len(pages_data) != len(page_descriptions):\n            logger.warning(f\"页面数量不匹配: 大纲 {len(pages_data)} 页, 描述 {len(page_descriptions)} 页\")\n            # 取较小的数量，避免索引错误\n            min_count = min(len(pages_data), len(page_descriptions))\n            pages_data = pages_data[:min_count]\n            page_descriptions = page_descriptions[:min_count]\n        \n        # Step 4: Delete existing pages (using ORM session to trigger cascades)\n        old_pages = Page.query.filter_by(project_id=project_id).all()\n        for old_page in old_pages:\n            db.session.delete(old_page)\n        \n        # Step 5: Create pages with both outline and description\n        pages_list = []\n        for i, (page_data, page_desc) in enumerate(zip(pages_data, page_descriptions)):\n            page = Page(\n                project_id=project_id,\n                order_index=i,\n                part=page_data.get('part'),\n                status='DESCRIPTION_GENERATED'  # 直接设置为已生成描述\n            )\n            \n            # Set outline content\n            page.set_outline_content({\n                'title': page_data.get('title'),\n                'points': page_data.get('points', [])\n            })\n            \n            # Set description content\n            desc_content = {\n                \"text\": page_desc,\n                \"generated_at\": datetime.utcnow().isoformat()\n            }\n            page.set_description_content(desc_content)\n            \n            db.session.add(page)\n            pages_list.append(page)\n        \n        # Update project status\n        project.status = 'DESCRIPTIONS_GENERATED'\n        project.updated_at = datetime.utcnow()\n        \n        db.session.commit()\n        \n        logger.info(f\"从描述生成完成: 项目 {project_id}, 创建了 {len(pages_list)} 个页面，已填充大纲和描述\")\n        \n        # Return pages\n        return success_response({\n            'pages': [page.to_dict() for page in pages_list],\n            'status': 'DESCRIPTIONS_GENERATED'\n        })\n    \n    except Exception as e:\n        db.session.rollback()\n        logger.error(f\"generate_from_description failed: {str(e)}\", exc_info=True)\n        return error_response('AI_SERVICE_ERROR', str(e), 503)\n\n\n@project_bp.route('/<project_id>/generate/descriptions', methods=['POST'])\ndef generate_descriptions(project_id):\n    \"\"\"\n    POST /api/projects/{project_id}/generate/descriptions - Generate descriptions\n    \n    Request body:\n    {\n        \"max_workers\": 5,\n        \"language\": \"zh\"  # output language: zh, en, ja, auto\n    }\n    \"\"\"\n    try:\n        project = Project.query.get(project_id)\n        \n        if not project:\n            return not_found('Project')\n        \n        if not project.pages:\n            return bad_request(\"Project must have outline generated first\")\n\n        # IMPORTANT: Expire cached objects to ensure fresh data\n        db.session.expire_all()\n        \n        # Get pages\n        pages = Page.query.filter_by(project_id=project_id).order_by(Page.order_index).all()\n        \n        if not pages:\n            return bad_request(\"No pages found for project\")\n        \n        # Reconstruct outline from pages with part structure\n        outline = _reconstruct_outline_from_pages(pages)\n        \n        data = request.get_json() or {}\n        # 从配置中读取默认并发数，如果请求中提供了则使用请求的值\n        max_workers = data.get('max_workers', current_app.config.get('MAX_DESCRIPTION_WORKERS', 5))\n        language = data.get('language', current_app.config.get('OUTPUT_LANGUAGE', 'zh'))\n        detail_level = data.get('detail_level', 'default')\n        \n        # Create task\n        task = Task(\n            project_id=project_id,\n            task_type='GENERATE_DESCRIPTIONS',\n            status='PENDING'\n        )\n        task.set_progress({\n            'total': len(pages),\n            'completed': 0,\n            'failed': 0\n        })\n        \n        db.session.add(task)\n        db.session.commit()\n        \n        # Get singleton AI service instance\n        ai_service = get_ai_service()\n        \n        # Get reference files content and create project context\n        reference_files_content = _get_project_reference_files_content(project_id)\n        project_context = ProjectContext(project, reference_files_content)\n        \n        # Get app instance for background task\n        app = current_app._get_current_object()\n        \n        # Submit background task\n        task_manager.submit_task(\n            task.id,\n            generate_descriptions_task,\n            project_id,\n            ai_service,\n            project_context,\n            outline,\n            max_workers,\n            app,\n            language,\n            detail_level\n        )\n        \n        # Update project status\n        project.status = 'GENERATING_DESCRIPTIONS'\n        db.session.commit()\n        \n        return success_response({\n            'task_id': task.id,\n            'status': 'GENERATING_DESCRIPTIONS',\n            'total_pages': len(pages)\n        }, status_code=202)\n    \n    except Exception as e:\n        db.session.rollback()\n        logger.error(f\"generate_descriptions failed: {str(e)}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@project_bp.route('/<project_id>/generate/descriptions/stream', methods=['POST'])\ndef generate_descriptions_stream(project_id):\n    \"\"\"\n    POST /api/projects/{project_id}/generate/descriptions/stream - Stream description generation via SSE\n\n    Streams page descriptions one-by-one as they are generated by a single AI call.\n\n    SSE events:\n      event: description — {page_index, page_id, text, extra_fields?}\n      event: done        — {total, pages: [...]}\n      event: error       — {message}\n    \"\"\"\n    project = Project.query.get(project_id)\n    if not project:\n        return not_found('Project')\n\n    if not project.pages:\n        return bad_request(\"Project must have outline generated first\")\n\n    data = request.get_json() or {}\n    language = data.get('language', current_app.config.get('OUTPUT_LANGUAGE', 'zh'))\n    detail_level = data.get('detail_level', 'default')\n\n    app = current_app._get_current_object()\n\n    def sse_generate():\n        with app.app_context():\n            try:\n                proj = db.session.get(Project, project_id)\n                ai_service = get_ai_service()\n                reference_files_content = _get_project_reference_files_content(project_id)\n                project_context = ProjectContext(proj, reference_files_content)\n\n                pages = Page.query.filter_by(project_id=project_id).order_by(Page.order_index).all()\n                if not pages:\n                    yield _sse_event('error', {'message': 'No pages found for project'})\n                    return\n\n                outline = _reconstruct_outline_from_pages(pages)\n                flat_pages = ai_service.flatten_outline(outline)\n\n                # Set all pages to GENERATING_DESCRIPTION\n                for page in pages:\n                    page.status = 'GENERATING_DESCRIPTION'\n                proj.status = 'GENERATING_DESCRIPTIONS'\n                db.session.commit()\n\n                # Stream descriptions\n                for result in ai_service.generate_descriptions_stream(\n                    project_context, outline, flat_pages,\n                    language=language, detail_level=detail_level\n                ):\n                    if '__stream_complete__' in result:\n                        continue\n\n                    idx = result.get('page_index', -1)\n                    if idx < 0 or idx >= len(pages):\n                        continue\n\n                    page = pages[idx]\n                    desc_content = {\n                        'text': result.get('description_text', ''),\n                        'generated_at': datetime.utcnow().isoformat(),\n                    }\n                    if result.get('extra_fields'):\n                        desc_content['extra_fields'] = result['extra_fields']\n\n                    page.set_description_content(desc_content)\n                    page.status = 'DESCRIPTION_GENERATED'\n                    page.updated_at = datetime.utcnow()\n                    db.session.commit()\n\n                    yield _sse_event('description', {\n                        'page_index': idx,\n                        'page_id': page.id,\n                        'text': desc_content['text'],\n                        'extra_fields': result.get('extra_fields'),\n                    })\n\n                # 检查是否所有页面都已生成描述\n                missing = [p for p in pages if p.status == 'GENERATING_DESCRIPTION']\n                if missing:\n                    for p in missing:\n                        # 有旧描述的保留，无描述的恢复 DRAFT\n                        p.status = 'DESCRIPTION_GENERATED' if p.description_content else 'DRAFT'\n                        p.updated_at = datetime.utcnow()\n                    logger.warning(f\"流式描述生成不完整: {len(missing)}/{len(pages)} 页未生成\")\n\n                proj.status = 'DESCRIPTIONS_GENERATED'\n                proj.updated_at = datetime.utcnow()\n                db.session.commit()\n\n                # Re-fetch pages for final response\n                pages = Page.query.filter_by(project_id=project_id).order_by(Page.order_index).all()\n                yield _sse_event('done', {\n                    'total': len(pages),\n                    'pages': [p.to_dict() for p in pages],\n                    **(({'warning': f'{len(missing)} 页描述未生成，请重试'}) if missing else {}),\n                })\n\n            except Exception as e:\n                try:\n                    db.session.rollback()\n                except Exception as rollback_exc:\n                    logger.warning(f\"Session rollback failed: {rollback_exc}\", exc_info=True)\n                logger.error(f\"generate_descriptions_stream failed: {str(e)}\", exc_info=True)\n\n                # 恢复未完成页面的状态：已生成描述的保留，未生成的恢复为 DRAFT\n                try:\n                    pages = Page.query.filter_by(project_id=project_id).order_by(Page.order_index).all()\n                    proj = db.session.get(Project, project_id)\n                    has_any_desc = False\n                    for page in pages:\n                        if page.status == 'GENERATING_DESCRIPTION':\n                            # 如果之前就有描述内容，恢复为 DESCRIPTION_GENERATED\n                            if page.description_content:\n                                page.status = 'DESCRIPTION_GENERATED'\n                                has_any_desc = True\n                            else:\n                                page.status = 'DRAFT'\n                        elif page.status == 'DESCRIPTION_GENERATED':\n                            has_any_desc = True\n                    if proj:\n                        proj.status = 'DESCRIPTIONS_GENERATED' if has_any_desc else 'OUTLINE_GENERATED'\n                        proj.updated_at = datetime.utcnow()\n                    db.session.commit()\n                except Exception as recover_exc:\n                    logger.warning(f\"Failed to recover page statuses: {recover_exc}\", exc_info=True)\n\n                yield _sse_event('error', {'message': '生成过程中发生内部错误'})\n\n    return Response(\n        stream_with_context(sse_generate()),\n        mimetype='text/event-stream',\n        headers={\n            'Cache-Control': 'no-cache, no-transform',\n            'X-Accel-Buffering': 'no',\n            'Connection': 'keep-alive',\n        },\n    )\n\n\n@project_bp.route('/<project_id>/generate/images', methods=['POST'])\ndef generate_images(project_id):\n    \"\"\"\n    POST /api/projects/{project_id}/generate/images - Generate images\n\n    Request body:\n    {\n        \"max_workers\": 8,\n        \"use_template\": true,\n        \"language\": \"zh\",  # output language: zh, en, ja, auto\n        \"page_ids\": [\"id1\", \"id2\"]  # optional: specific page IDs to generate (if not provided, generates all)\n    }\n    \"\"\"\n    try:\n        project = Project.query.get(project_id)\n        \n        if not project:\n            return not_found('Project')\n        \n        # if project.status not in ['DESCRIPTIONS_GENERATED', 'OUTLINE_GENERATED']:\n        #     return bad_request(\"Project must have descriptions generated first\")\n        \n        # IMPORTANT: Expire cached objects to ensure fresh data\n        db.session.expire_all()\n        \n        data = request.get_json() or {}\n        \n        # Get page_ids from request body and fetch filtered pages\n        selected_page_ids = parse_page_ids_from_body(data)\n        pages = get_filtered_pages(project_id, selected_page_ids if selected_page_ids else None)\n        \n        if not pages:\n            return bad_request(\"No pages found for project\")\n        \n        # 检查是否有模板图片或风格描述\n        from services import FileService\n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n        use_template = data.get('use_template', True)\n        ref_image_path = None\n        if use_template:\n            ref_image_path = file_service.get_template_path(project_id)\n        \n        if not ref_image_path and not project.template_style:\n            return bad_request(\"请先上传模板图片或添加风格描述。\")\n        \n        # Reconstruct outline from pages with part structure\n        outline = _reconstruct_outline_from_pages(pages)\n        \n        # 从配置中读取默认并发数，如果请求中提供了则使用请求的值\n        max_workers = data.get('max_workers', current_app.config.get('MAX_IMAGE_WORKERS', 8))\n        use_template = data.get('use_template', True)\n        language = data.get('language', current_app.config.get('OUTPUT_LANGUAGE', 'zh'))\n        \n        # Create task\n        task = Task(\n            project_id=project_id,\n            task_type='GENERATE_IMAGES',\n            status='PENDING'\n        )\n        task.set_progress({\n            'total': len(pages),\n            'completed': 0,\n            'failed': 0\n        })\n        \n        db.session.add(task)\n        db.session.commit()\n        \n        # Get singleton AI service instance\n        ai_service = get_ai_service()\n        \n        # 合并额外要求和风格描述\n        combined_requirements = project.extra_requirements or \"\"\n        if project.template_style:\n            style_requirement = f\"\\n\\nppt页面风格描述：\\n\\n{project.template_style}\"\n            combined_requirements = combined_requirements + style_requirement\n        \n        # Set all target pages to QUEUED before submitting background task\n        # This ensures the status is visible to frontend immediately after API returns\n        for page in pages:\n            page.status = 'QUEUED'\n        db.session.commit()\n\n        # Get app instance for background task\n        app = current_app._get_current_object()\n\n        # Submit background task\n        task_manager.submit_task(\n            task.id,\n            generate_images_task,\n            project_id,\n            ai_service,\n            file_service,\n            outline,\n            use_template,\n            max_workers,\n            project.image_aspect_ratio,\n            current_app.config['DEFAULT_RESOLUTION'],\n            app,\n            combined_requirements if combined_requirements.strip() else None,\n            language,\n            selected_page_ids if selected_page_ids else None\n        )\n        \n        # Update project status\n        project.status = 'GENERATING_IMAGES'\n        db.session.commit()\n        \n        return success_response({\n            'task_id': task.id,\n            'status': 'GENERATING_IMAGES',\n            'total_pages': len(pages)\n        }, status_code=202)\n    \n    except Exception as e:\n        db.session.rollback()\n        logger.error(f\"generate_images failed: {str(e)}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@project_bp.route('/<project_id>/tasks/<task_id>', methods=['GET'])\ndef get_task_status(project_id, task_id):\n    \"\"\"\n    GET /api/projects/{project_id}/tasks/{task_id} - Get task status\n    \"\"\"\n    try:\n        task = Task.query.get(task_id)\n        \n        if not task or task.project_id != project_id:\n            return not_found('Task')\n        \n        return success_response(task.to_dict())\n    \n    except Exception as e:\n        logger.error(f\"get_task_status failed: {str(e)}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@project_bp.route('/<project_id>/refine/outline', methods=['POST'])\ndef refine_outline(project_id):\n    \"\"\"\n    POST /api/projects/{project_id}/refine/outline - Refine outline based on user requirements\n    \n    Request body:\n    {\n        \"user_requirement\": \"用户要求，例如：增加一页关于XXX的内容\",\n        \"language\": \"zh\"  # output language: zh, en, ja, auto\n    }\n    \"\"\"\n    try:\n        project = Project.query.get(project_id)\n        \n        if not project:\n            return not_found('Project')\n        \n        data = request.get_json()\n        \n        if not data or not data.get('user_requirement'):\n            return bad_request(\"user_requirement is required\")\n        \n        user_requirement = data['user_requirement']\n        \n        # IMPORTANT: Expire all cached objects to ensure we get fresh data from database\n        # This prevents issues when multiple refine operations are called in sequence\n        db.session.expire_all()\n        \n        # Get current outline from pages\n        pages = Page.query.filter_by(project_id=project_id).order_by(Page.order_index).all()\n        \n        # Reconstruct current outline from pages (如果没有页面，使用空列表)\n        if not pages:\n            logger.info(f\"项目 {project_id} 当前没有页面，将从空开始生成\")\n            current_outline = []  # 空大纲\n        else:\n            current_outline = _reconstruct_outline_from_pages(pages)\n        \n        # Get singleton AI service instance\n        ai_service = get_ai_service()\n        \n        # Get reference files content and create project context\n        reference_files_content = _get_project_reference_files_content(project_id)\n        if reference_files_content:\n            logger.info(f\"Found {len(reference_files_content)} reference files for refine_outline\")\n            for rf in reference_files_content:\n                logger.info(f\"  - {rf['filename']}: {len(rf['content'])} characters\")\n        else:\n            logger.info(f\"No reference files found for project {project_id}\")\n        \n        project_context = ProjectContext(project.to_dict(), reference_files_content)\n        \n        # Get previous requirements and language from request\n        previous_requirements = data.get('previous_requirements', [])\n        language = data.get('language', current_app.config.get('OUTPUT_LANGUAGE', 'zh'))\n        \n        # Refine outline\n        logger.info(f\"开始修改大纲: 项目 {project_id}, 用户要求: {user_requirement}, 历史要求数: {len(previous_requirements)}\")\n        refined_outline = ai_service.refine_outline(\n            current_outline=current_outline,\n            user_requirement=user_requirement,\n            project_context=project_context,\n            previous_requirements=previous_requirements,\n            language=language\n        )\n        \n        # Flatten outline to pages and smart merge with existing\n        pages_data = ai_service.flatten_outline(refined_outline)\n        pages_list = _smart_merge_pages(project_id, pages_data)\n\n        preserved_count = sum(1 for p in pages_list if p.description_content)\n        new_count = len(pages_list) - preserved_count\n        logger.info(f\"描述匹配完成: 保留了 {preserved_count} 个页面的描述, {new_count} 个页面需要重新生成描述\")\n\n        # Update project status\n        if preserved_count and all(p.description_content for p in pages_list):\n            project.status = 'DESCRIPTIONS_GENERATED'\n        else:\n            project.status = 'OUTLINE_GENERATED'\n        project.updated_at = datetime.utcnow()\n        \n        db.session.commit()\n        \n        logger.info(f\"大纲修改完成: 项目 {project_id}, 创建了 {len(pages_list)} 个页面\")\n        \n        # Return pages\n        return success_response({\n            'pages': [page.to_dict() for page in pages_list],\n            'message': '大纲修改成功'\n        })\n    \n    except Exception as e:\n        db.session.rollback()\n        logger.error(f\"refine_outline failed: {str(e)}\", exc_info=True)\n        return error_response('AI_SERVICE_ERROR', str(e), 503)\n\n\n@project_bp.route('/<project_id>/refine/descriptions', methods=['POST'])\ndef refine_descriptions(project_id):\n    \"\"\"\n    POST /api/projects/{project_id}/refine/descriptions - Refine page descriptions based on user requirements\n    \n    Request body:\n    {\n        \"user_requirement\": \"用户要求，例如：让描述更详细一些\",\n        \"language\": \"zh\"  # output language: zh, en, ja, auto\n    }\n    \"\"\"\n    try:\n        project = Project.query.get(project_id)\n        \n        if not project:\n            return not_found('Project')\n        \n        data = request.get_json()\n        \n        if not data or not data.get('user_requirement'):\n            return bad_request(\"user_requirement is required\")\n        \n        user_requirement = data['user_requirement']\n        \n        db.session.expire_all()\n        \n        # Get current pages\n        pages = Page.query.filter_by(project_id=project_id).order_by(Page.order_index).all()\n        \n        if not pages:\n            logger.info(f\"项目 {project_id} 当前没有页面，无法修改描述\")\n            return bad_request(\"No pages found for project. Please generate outline first.\")\n        \n        # Check if pages have descriptions (允许没有描述，从空开始)\n        has_descriptions = any(page.description_content for page in pages)\n        if not has_descriptions:\n            logger.info(f\"项目 {project_id} 当前没有描述，将基于大纲生成新描述\")\n        \n        # Reconstruct outline from pages\n        outline = _reconstruct_outline_from_pages(pages)\n        \n        # Prepare current descriptions\n        current_descriptions = []\n        for i, page in enumerate(pages):\n            outline_content = page.get_outline_content()\n            desc_content = page.get_description_content()\n            \n            current_descriptions.append({\n                'index': i,\n                'title': outline_content.get('title', '未命名') if outline_content else '未命名',\n                'description_content': desc_content if desc_content else ''\n            })\n        \n        # Get singleton AI service instance\n        ai_service = get_ai_service()\n        \n        # Get reference files content and create project context\n        reference_files_content = _get_project_reference_files_content(project_id)\n        if reference_files_content:\n            logger.info(f\"Found {len(reference_files_content)} reference files for refine_descriptions\")\n            for rf in reference_files_content:\n                logger.info(f\"  - {rf['filename']}: {len(rf['content'])} characters\")\n        else:\n            logger.info(f\"No reference files found for project {project_id}\")\n        \n        project_context = ProjectContext(project.to_dict(), reference_files_content)\n        \n        # Get previous requirements and language from request\n        previous_requirements = data.get('previous_requirements', [])\n        language = data.get('language', current_app.config.get('OUTPUT_LANGUAGE', 'zh'))\n        \n        # Refine descriptions\n        logger.info(f\"开始修改页面描述: 项目 {project_id}, 用户要求: {user_requirement}, 历史要求数: {len(previous_requirements)}\")\n        refined_descriptions = ai_service.refine_descriptions(\n            current_descriptions=current_descriptions,\n            user_requirement=user_requirement,\n            project_context=project_context,\n            outline=outline,\n            previous_requirements=previous_requirements,\n            language=language\n        )\n        \n        # 验证返回的描述数量\n        if len(refined_descriptions) != len(pages):\n            error_msg = \"\"\n            logger.error(f\"AI 返回的描述数量不匹配: 期望 {len(pages)} 个页面，实际返回 {len(refined_descriptions)} 个描述。\")\n            \n            # 如果 AI 试图增删页面，给出明确提示\n            if len(refined_descriptions) > len(pages):\n                error_msg += \" 提示：如需增加页面，请在大纲页面进行操作。\"\n            elif len(refined_descriptions) < len(pages):\n                error_msg += \" 提示：如需删除页面，请在大纲页面进行操作。\"\n            \n            return bad_request(error_msg)\n        \n        # Update pages with refined descriptions\n        for page, refined_desc in zip(pages, refined_descriptions):\n            desc_content = {\n                \"text\": refined_desc,\n                \"generated_at\": datetime.utcnow().isoformat()\n            }\n            page.set_description_content(desc_content)\n            page.status = 'DESCRIPTION_GENERATED'\n        \n        # Update project status\n        project.status = 'DESCRIPTIONS_GENERATED'\n        project.updated_at = datetime.utcnow()\n        \n        db.session.commit()\n        \n        logger.info(f\"页面描述修改完成: 项目 {project_id}, 更新了 {len(pages)} 个页面\")\n        \n        # Return pages\n        return success_response({\n            'pages': [page.to_dict() for page in pages],\n            'message': '页面描述修改成功'\n        })\n    \n    except Exception as e:\n        db.session.rollback()\n        logger.error(f\"refine_descriptions failed: {str(e)}\", exc_info=True)\n        return error_response('AI_SERVICE_ERROR', str(e), 503)\n\n\n@project_bp.route('/renovation', methods=['POST'])\ndef create_ppt_renovation_project():\n    \"\"\"\n    POST /api/projects/renovation - Create a PPT renovation project\n\n    Accepts a PDF/PPTX file upload, creates project with pages from PDF images,\n    then submits an async task to parse content and fill outline + descriptions.\n\n    Content-Type: multipart/form-data\n    Form:\n        file: PDF or PPTX file (required)\n        keep_layout: \"true\"/\"false\" - whether to preserve layout via caption model (optional, default false)\n        template_style: style description text (optional)\n\n    Returns:\n        {project_id, task_id, page_count}\n    \"\"\"\n    try:\n        # Validate file\n        if 'file' not in request.files:\n            return bad_request(\"No file uploaded\")\n\n        file = request.files['file']\n        if file.filename == '':\n            return bad_request(\"No file selected\")\n\n        # Check file extension\n        filename = file.filename.lower()\n        if not (filename.endswith('.pdf') or filename.endswith('.pptx') or filename.endswith('.ppt')):\n            return bad_request(\"Only PDF and PPTX files are supported\")\n\n        keep_layout = request.form.get('keep_layout', 'false').lower() == 'true'\n        template_style = request.form.get('template_style', '').strip() or None\n\n        # Create project\n        project = Project(\n            creation_type='ppt_renovation',\n            template_style=template_style,\n            status='DRAFT'\n        )\n        db.session.add(project)\n        db.session.commit()\n\n        project_id = project.id\n\n        # Save uploaded file\n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n        project_dir = Path(current_app.config['UPLOAD_FOLDER']) / project_id\n        template_dir = project_dir / \"template\"\n        template_dir.mkdir(parents=True, exist_ok=True)\n\n        # Save original file\n        safe_name = secure_filename(file.filename)\n        safe_name = secure_filename(file.filename)\n        original_path = template_dir / safe_name\n        file.save(str(original_path))\n\n        # Convert PPTX to PDF if needed\n        pdf_path = str(original_path)\n        if safe_name.lower().endswith(('.pptx', '.ppt')):\n            try:\n                subprocess.run(\n                    ['libreoffice', '--headless', '--convert-to', 'pdf', '--outdir', str(template_dir), str(original_path)],\n                    check=True, timeout=120, capture_output=True\n                )\n                pdf_name = safe_name.rsplit('.', 1)[0] + '.pdf'\n                pdf_path = str(template_dir / pdf_name)\n                if not os.path.exists(pdf_path):\n                    raise ValueError(\"PDF conversion failed - output file not found\")\n                logger.info(f\"Converted PPTX to PDF: {pdf_path}\")\n            except subprocess.TimeoutExpired:\n                raise ValueError(\"PPTX to PDF conversion timed out\")\n            except FileNotFoundError:\n                raise ValueError(\"PPTX conversion requires LibreOffice, which is not installed. Please convert your PPTX to PDF locally before uploading.\")\n\n        # Convert PDF to page images using PyMuPDF or pdf2image\n        pages_dir = project_dir / \"pages\"\n        pages_dir.mkdir(parents=True, exist_ok=True)\n\n        page_image_paths = []\n        pdf_page_width = None\n        pdf_page_height = None\n        try:\n            import fitz  # PyMuPDF\n            doc = fitz.open(pdf_path)\n            # Extract page dimensions from the first page before rendering\n            if len(doc) > 0:\n                rect = doc[0].rect\n                pdf_page_width = rect.width\n                pdf_page_height = rect.height\n            for i, fitz_page in enumerate(doc):\n                try:\n                    mat = fitz.Matrix(2, 2)\n                    pix = fitz_page.get_pixmap(matrix=mat)\n                    img_path = str(pages_dir / f\"page_{i + 1}_original.png\")\n                    pix.save(img_path)\n                    page_image_paths.append(img_path)\n                except Exception as e:\n                    logger.error(f\"Failed to render page {i + 1} with PyMuPDF: {e}\")\n                    page_image_paths.append(None)\n            doc.close()\n        except ImportError:\n            # Fallback: use pdf2image\n            try:\n                from pdf2image import convert_from_path\n                images = convert_from_path(pdf_path, dpi=200)\n                for i, img in enumerate(images):\n                    try:\n                        # Extract page dimensions from the first image\n                        if pdf_page_width is None:\n                            pdf_page_width = img.width\n                            pdf_page_height = img.height\n                        img_path = str(pages_dir / f\"page_{i + 1}_original.png\")\n                        img.save(img_path, 'PNG')\n                        page_image_paths.append(img_path)\n                    except Exception as e:\n                        logger.error(f\"Failed to render page {i + 1} with pdf2image: {e}\")\n                        page_image_paths.append(None)\n            except ImportError:\n                raise ValueError(\"Neither PyMuPDF nor pdf2image is available for PDF rendering\")\n\n        # Fail-fast if no pages rendered at all\n        valid_pages = [p for p in page_image_paths if p is not None]\n        if not valid_pages:\n            raise ValueError(\"All pages failed to render from PDF\")\n\n        logger.info(f\"Rendered {len(valid_pages)}/{len(page_image_paths)} page images from PDF\")\n\n        # Set project aspect ratio from PDF page dimensions\n        if pdf_page_width and pdf_page_height and pdf_page_width > 0 and pdf_page_height > 0:\n            try:\n                raw_ratio = f\"{int(round(pdf_page_width))}:{int(round(pdf_page_height))}\"\n                project.image_aspect_ratio = normalize_aspect_ratio(raw_ratio)\n                logger.info(f\"Set project aspect ratio from PDF: {pdf_page_width}x{pdf_page_height} -> {project.image_aspect_ratio}\")\n            except (ValueError, OverflowError) as e:\n                logger.warning(f\"Could not normalize PDF aspect ratio ({pdf_page_width}x{pdf_page_height}): {e}, keeping default 16:9\")\n\n        # Create Page records with initial images\n        from services.task_manager import save_image_with_version\n        from PIL import Image as PILImage\n\n        pages_list = []\n        for i, img_path in enumerate(page_image_paths):\n            if img_path is None:\n                logger.warning(f\"Skipping page {i + 1}: render failed\")\n                continue\n\n            page = Page(\n                project_id=project_id,\n                order_index=len(pages_list),\n                status='DRAFT'\n            )\n            page.set_outline_content({\n                'title': f'Page {i + 1}',\n                'points': []\n            })\n            db.session.add(page)\n            db.session.flush()  # Get page.id\n\n            # Save the PDF page image as initial version\n            img = PILImage.open(img_path)\n            image_path, _version = save_image_with_version(\n                img, project_id, page.id, file_service, page_obj=page\n            )\n            img.close()\n\n            pages_list.append(page)\n\n        db.session.commit()\n\n        # Create async task\n        task = Task(\n            project_id=project_id,\n            task_type='PPT_RENOVATION',\n            status='PENDING'\n        )\n        task.set_progress({\n            'total': len(pages_list),\n            'completed': 0,\n            'failed': 0,\n            'current_step': 'queued'\n        })\n        db.session.add(task)\n        db.session.commit()\n\n        # Get services\n        ai_service = get_ai_service()\n        from services.file_parser_service import FileParserService\n        file_parser_service = FileParserService(\n            mineru_token=current_app.config['MINERU_TOKEN'],\n            mineru_api_base=current_app.config['MINERU_API_BASE'],\n            google_api_key=current_app.config.get('GOOGLE_API_KEY', ''),\n            google_api_base=current_app.config.get('GOOGLE_API_BASE', ''),\n            openai_api_key=current_app.config.get('OPENAI_API_KEY', ''),\n            openai_api_base=current_app.config.get('OPENAI_API_BASE', ''),\n            image_caption_model=current_app.config['IMAGE_CAPTION_MODEL'],\n            provider_format=current_app.config.get('AI_PROVIDER_FORMAT', 'gemini'),\n            lazyllm_image_caption_source=current_app.config.get('IMAGE_CAPTION_MODEL_SOURCE', 'doubao'),\n        )\n\n        language = request.form.get('language', current_app.config.get('OUTPUT_LANGUAGE', 'zh'))\n        app = current_app._get_current_object()\n\n        # Submit async task\n        task_manager.submit_task(\n            task.id,\n            process_ppt_renovation_task,\n            project_id,\n            ai_service,\n            file_service,\n            file_parser_service,\n            keep_layout,\n            5,  # max_workers\n            app,\n            language\n        )\n\n        project.status = 'PROCESSING'\n        db.session.commit()\n\n        return success_response({\n            'project_id': project_id,\n            'task_id': task.id,\n            'page_count': len(pages_list)\n        }, status_code=202)\n\n    except Exception as e:\n        db.session.rollback()\n        logger.error(f\"create_ppt_renovation_project failed: {str(e)}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n# Style extraction blueprint (not bound to any project)\nstyle_bp = Blueprint('style', __name__, url_prefix='/api')\n\n\n@style_bp.route('/extract-style', methods=['POST'])\ndef extract_style():\n    \"\"\"\n    POST /api/extract-style - Extract style description from an image\n\n    Content-Type: multipart/form-data\n    Form:\n        image: Image file (required)\n\n    Returns:\n        {style_description: \"...\"}\n    \"\"\"\n    try:\n        if 'image' not in request.files:\n            return bad_request(\"No image file uploaded\")\n\n        file = request.files['image']\n        if file.filename == '':\n            return bad_request(\"No file selected\")\n\n        # Save to temp location\n        import tempfile\n\n        ext = secure_filename(file.filename).rsplit('.', 1)[-1].lower() if '.' in file.filename else 'png'\n        with tempfile.NamedTemporaryFile(suffix=f'.{ext}', delete=False) as tmp:\n            file.save(tmp.name)\n            tmp_path = tmp.name\n\n        try:\n            ai_service = get_ai_service()\n            style_description = ai_service.extract_style_description(tmp_path)\n\n            return success_response({\n                'style_description': style_description\n            })\n        finally:\n            os.unlink(tmp_path)\n\n    except Exception as e:\n        logger.error(f\"extract_style failed: {str(e)}\", exc_info=True)\n        return error_response('AI_SERVICE_ERROR', str(e), 503)\n"
  },
  {
    "path": "backend/controllers/reference_file_controller.py",
    "content": "\"\"\"\nReference File Controller - handles file upload and parsing\n\"\"\"\nimport os\nimport logging\nimport re\nimport uuid\nfrom flask import Blueprint, request, current_app\nfrom werkzeug.utils import secure_filename\nfrom pathlib import Path\nfrom config import Config\nfrom datetime import datetime\nfrom urllib.parse import unquote\nimport threading\n\nfrom models import db, ReferenceFile, Project\nfrom utils.response import success_response, error_response, bad_request, not_found\nfrom services.file_parser_service import FileParserService\n\nlogger = logging.getLogger(__name__)\n\nreference_file_bp = Blueprint('reference_file', __name__)\n\n\ndef _allowed_file(filename: str, allowed_extensions: set) -> bool:\n    \"\"\"Check if file extension is allowed\"\"\"\n    return '.' in filename and \\\n           filename.rsplit('.', 1)[1].lower() in allowed_extensions\n\n\ndef _get_file_type(filename: str) -> str:\n    \"\"\"Get file type from filename\"\"\"\n    if '.' in filename:\n        return filename.rsplit('.', 1)[1].lower()\n    return 'unknown'\n\n\ndef _parse_file_async(file_id: str, file_path: str, filename: str, app):\n    \"\"\"\n    Parse file asynchronously in background\n    \n    Args:\n        file_id: Reference file ID\n        file_path: Path to the uploaded file\n        filename: Original filename\n        app: Flask app instance (for app context)\n    \"\"\"\n    with app.app_context():\n        try:\n            reference_file = ReferenceFile.query.get(file_id)\n            if not reference_file:\n                logger.error(f\"Reference file {file_id} not found\")\n                return\n            \n            # Update status to parsing\n            reference_file.parse_status = 'parsing'\n            db.session.commit()\n            \n            # Initialize parser service\n            parser = FileParserService(\n                mineru_token=current_app.config['MINERU_TOKEN'],\n                mineru_api_base=current_app.config['MINERU_API_BASE'],\n                google_api_key=current_app.config.get('GOOGLE_API_KEY', ''),\n                google_api_base=current_app.config.get('GOOGLE_API_BASE', ''),\n                openai_api_key=current_app.config.get('OPENAI_API_KEY', ''),\n                openai_api_base=current_app.config.get('OPENAI_API_BASE', ''),\n                image_caption_model=current_app.config['IMAGE_CAPTION_MODEL'],\n                provider_format=current_app.config.get('AI_PROVIDER_FORMAT', 'gemini'),\n                lazyllm_image_caption_source=current_app.config.get('IMAGE_CAPTION_MODEL_SOURCE', 'doubao'),\n            )\n            \n            # Parse file\n            logger.info(f\"Starting to parse file: {filename}\")\n            batch_id, markdown_content, extract_id, error_message, failed_image_count = parser.parse_file(file_path, filename)\n            \n            # Update database\n            reference_file.mineru_batch_id = batch_id\n            if error_message:\n                reference_file.parse_status = 'failed'\n                reference_file.error_message = error_message\n                logger.error(f\"File parsing failed: {error_message}\")\n            else:\n                reference_file.parse_status = 'completed'\n                reference_file.markdown_content = markdown_content\n                if failed_image_count > 0:\n                    logger.warning(f\"File parsing completed: {filename}, but {failed_image_count} images failed to generate captions\")\n                else:\n                    logger.info(f\"File parsing completed: {filename}\")\n            \n            reference_file.updated_at = datetime.utcnow()\n            db.session.commit()\n            \n        except Exception as e:\n            logger.error(f\"Error in async file parsing: {str(e)}\", exc_info=True)\n            try:\n                reference_file = ReferenceFile.query.get(file_id)\n                if reference_file:\n                    reference_file.parse_status = 'failed'\n                    reference_file.error_message = f\"Parsing error: {str(e)}\"\n                    reference_file.updated_at = datetime.utcnow()\n                    db.session.commit()\n            except Exception as db_error:\n                logger.error(f\"Failed to update error status: {str(db_error)}\")\n\n\n@reference_file_bp.route('/upload', methods=['POST'])\ndef upload_reference_file():\n    \"\"\"\n    POST /api/reference-files/upload - Upload a reference file\n    \n    Supports multipart/form-data:\n    - file: The file to upload (required)\n    - project_id: Project ID to associate with (optional, 'none' for global files)\n    \n    Returns:\n        Reference file information with status\n    \"\"\"\n    try:\n        # Check if file is in request\n        if 'file' not in request.files:\n            return bad_request(\"No file provided\")\n        \n        file = request.files['file']\n        \n        # Get filename - handle encoding issues with non-ASCII characters\n        original_filename = file.filename\n        if not original_filename or original_filename == '':\n            # Try to get filename from Content-Disposition header\n            content_disposition = request.headers.get('Content-Disposition', '')\n            if content_disposition:\n                filename_match = re.search(r'filename[^;=\\n]*=(([\\'\"]).*?\\2|[^;\\n]*)', content_disposition)\n                if filename_match:\n                    original_filename = filename_match.group(1).strip('\"\\'')\n                    # Decode if URL encoded\n                    try:\n                        original_filename = unquote(original_filename)\n                    except Exception:\n                        pass\n        \n        if not original_filename or original_filename == '':\n            return bad_request(\"No file selected or filename could not be determined\")\n        \n        logger.info(f\"Received file upload: {original_filename}\")\n        \n        # Check file extension\n        \n        allowed_extensions = current_app.config.get('ALLOWED_REFERENCE_FILE_EXTENSIONS', Config.ALLOWED_REFERENCE_FILE_EXTENSIONS)\n        if not _allowed_file(original_filename, allowed_extensions):\n            return bad_request(f\"File type not allowed. Allowed types: {', '.join(allowed_extensions)}\")\n        \n        # Get project_id (optional)\n        project_id = request.form.get('project_id')\n        if project_id == 'none' or not project_id:\n            project_id = None\n        else:\n            # Verify project exists\n            project = Project.query.get(project_id)\n            if not project:\n                return not_found('Project')\n        \n        # Secure filename for filesystem (but keep original for database)\n        # secure_filename removes non-ASCII chars, so we need to handle Chinese characters\n        filename = secure_filename(original_filename)\n        \n        # If secure_filename removed everything (e.g., all Chinese chars), use a fallback\n        if not filename or filename == '':\n            # Extract extension from original filename\n            ext = _get_file_type(original_filename)\n            if ext == 'unknown':\n                ext = 'file'\n            filename = f\"file_{uuid.uuid4().hex[:8]}.{ext}\"\n            logger.warning(f\"Original filename '{original_filename}' was sanitized to '{filename}'\")\n        \n        # Create upload directory structure\n        upload_folder = current_app.config['UPLOAD_FOLDER']\n        reference_files_dir = Path(upload_folder) / 'reference_files'\n        reference_files_dir.mkdir(parents=True, exist_ok=True)\n        \n        # Generate unique filename to avoid conflicts\n        unique_id = str(uuid.uuid4())[:8]\n        file_type = _get_file_type(original_filename)  # Use original filename for type detection\n        unique_filename = f\"{unique_id}_{filename}\"\n        file_path = reference_files_dir / unique_filename\n        \n        # Save file\n        file.save(str(file_path))\n        file_size = os.path.getsize(file_path)\n        \n        # Create database record\n        reference_file = ReferenceFile(\n            project_id=project_id,\n            filename=original_filename,\n            file_path=str(file_path.relative_to(upload_folder)),\n            file_size=file_size,\n            file_type=file_type,\n            parse_status='pending'\n        )\n        \n        db.session.add(reference_file)\n        db.session.commit()\n        \n        logger.info(f\"File uploaded: {original_filename} (ID: {reference_file.id})\")\n        \n        # Lazy parsing: 不立即解析，等待用户选择确定后再解析\n        # 解析将在用户选择文件并确认时触发\n        \n        return success_response({'file': reference_file.to_dict()})\n        \n    except Exception as e:\n        logger.error(f\"Error uploading reference file: {str(e)}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@reference_file_bp.route('/<file_id>', methods=['GET'])\ndef get_reference_file(file_id):\n    \"\"\"\n    GET /api/reference-files/<file_id> - Get reference file information\n    \n    Returns:\n        Reference file information including parse status\n    \"\"\"\n    try:\n        reference_file = ReferenceFile.query.get(file_id)\n        if not reference_file:\n            return not_found('Reference file')\n        \n        # 单个文件查询时包含内容和失败计数（会在 to_dict 中根据状态判断是否计算）\n        return success_response({'file': reference_file.to_dict(include_content=True, include_failed_count=True)})\n        \n    except Exception as e:\n        logger.error(f\"Error getting reference file: {str(e)}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@reference_file_bp.route('/<file_id>', methods=['DELETE'])\ndef delete_reference_file(file_id):\n    \"\"\"\n    DELETE /api/reference-files/<file_id> - Delete a reference file\n    \n    Returns:\n        Success message\n    \"\"\"\n    try:\n        reference_file = ReferenceFile.query.get(file_id)\n        if not reference_file:\n            return not_found('Reference file')\n        \n        # Delete file from disk\n        try:\n            upload_folder = current_app.config['UPLOAD_FOLDER']\n            file_path = Path(upload_folder) / reference_file.file_path\n            if file_path.exists():\n                file_path.unlink()\n                logger.info(f\"Deleted file from disk: {file_path}\")\n        except Exception as e:\n            logger.warning(f\"Failed to delete file from disk: {str(e)}\")\n        \n        # Delete from database\n        db.session.delete(reference_file)\n        db.session.commit()\n        \n        logger.info(f\"Deleted reference file: {file_id}\")\n        \n        return success_response({'message': 'File deleted successfully'})\n        \n    except Exception as e:\n        logger.error(f\"Error deleting reference file: {str(e)}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@reference_file_bp.route('/project/<project_id>', methods=['GET'])\ndef list_project_reference_files(project_id):\n    \"\"\"\n    GET /api/reference-files/project/<project_id> - List all reference files for a project\n    \n    Special values:\n    - 'all': List all reference files (global + all projects)\n    - 'global' or 'none': List only global files (not associated with any project)\n    - project_id: List files for specific project\n    \n    Returns:\n        List of reference files\n    \"\"\"\n    try:\n        # Special case: 'all' means list all files\n        if project_id == 'all':\n            reference_files = ReferenceFile.query.all()\n        # Special case: 'global' or 'none' means list global files (not associated with any project)\n        elif project_id in ['global', 'none']:\n            reference_files = ReferenceFile.query.filter_by(project_id=None).all()\n        else:\n            # Verify project exists\n            project = Project.query.get(project_id)\n            if not project:\n                return not_found('Project')\n            \n            reference_files = ReferenceFile.query.filter_by(project_id=project_id).all()\n        \n        # 列表查询时不包含 markdown_content 和失败计数，加快响应速度\n        return success_response({\n            'files': [f.to_dict(include_content=False) for f in reference_files]\n        })\n        \n    except Exception as e:\n        logger.error(f\"Error listing reference files: {str(e)}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@reference_file_bp.route('/<file_id>/parse', methods=['POST'])\ndef trigger_file_parse(file_id):\n    \"\"\"\n    POST /api/reference-files/<file_id>/parse - Trigger parsing for a reference file\n    \n    Returns:\n        Updated reference file information\n    \"\"\"\n    try:\n        reference_file = ReferenceFile.query.get(file_id)\n        if not reference_file:\n            return not_found('Reference file')\n        \n        # 如果正在解析，直接返回\n        if reference_file.parse_status == 'parsing':\n            return success_response({\n                'file': reference_file.to_dict(),\n                'message': 'File is already being parsed'\n            })\n        \n        # 如果解析完成或失败，可以重新解析\n        if reference_file.parse_status in ['completed', 'failed']:\n            reference_file.parse_status = 'pending'\n            reference_file.error_message = None\n            # 清空之前的解析结果，以便重新解析\n            reference_file.markdown_content = None\n            reference_file.mineru_batch_id = None\n            db.session.commit()\n        \n        # 获取文件路径\n        upload_folder = current_app.config['UPLOAD_FOLDER']\n        file_path = Path(upload_folder) / reference_file.file_path\n        \n        if not file_path.exists():\n            return error_response('FILE_NOT_FOUND', f'File not found: {file_path}', 404)\n        \n        # 启动异步解析\n        thread = threading.Thread(\n            target=_parse_file_async,\n            args=(reference_file.id, str(file_path), reference_file.filename, current_app._get_current_object())\n        )\n        thread.daemon = True\n        thread.start()\n        \n        logger.info(f\"Triggered parsing for file: {reference_file.filename} (ID: {file_id})\")\n        \n        return success_response({\n            'file': reference_file.to_dict(),\n            'message': 'Parsing started'\n        })\n        \n    except Exception as e:\n        logger.error(f\"Error triggering file parse: {str(e)}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@reference_file_bp.route('/<file_id>/associate', methods=['POST'])\ndef associate_file_to_project(file_id):\n    \"\"\"\n    POST /api/reference-files/<file_id>/associate - Associate a reference file to a project\n    \n    Request body:\n    {\n        \"project_id\": \"project-id-here\"\n    }\n    \n    Returns:\n        Updated reference file information\n    \"\"\"\n    try:\n        reference_file = ReferenceFile.query.get(file_id)\n        if not reference_file:\n            return not_found('Reference file')\n        \n        data = request.get_json() or {}\n        project_id = data.get('project_id')\n        \n        if not project_id:\n            return bad_request(\"project_id is required\")\n        \n        # Verify project exists\n        project = Project.query.get(project_id)\n        if not project:\n            return not_found('Project')\n        \n        # Update file's project_id\n        reference_file.project_id = project_id\n        reference_file.updated_at = datetime.utcnow()\n        db.session.commit()\n        \n        logger.info(f\"Associated reference file {file_id} to project {project_id}\")\n        \n        return success_response({'file': reference_file.to_dict()})\n        \n    except Exception as e:\n        logger.error(f\"Error associating reference file: {str(e)}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@reference_file_bp.route('/<file_id>/dissociate', methods=['POST'])\ndef dissociate_file_from_project(file_id):\n    \"\"\"\n    POST /api/reference-files/<file_id>/dissociate - Remove a reference file from its project\n    \n    This sets the file's project_id to None, effectively making it a global file.\n    The file itself is not deleted.\n    \n    Returns:\n        Updated reference file information\n    \"\"\"\n    try:\n        reference_file = ReferenceFile.query.get(file_id)\n        if not reference_file:\n            return not_found('Reference file')\n        \n        # Remove project association\n        reference_file.project_id = None\n        reference_file.updated_at = datetime.utcnow()\n        db.session.commit()\n        \n        logger.info(f\"Dissociated reference file {file_id} from project\")\n        \n        return success_response({'file': reference_file.to_dict(), 'message': 'File removed from project'})\n        \n    except Exception as e:\n        logger.error(f\"Error dissociating reference file: {str(e)}\", exc_info=True)\n        return error_response('SERVER_ERROR', str(e), 500)\n\n"
  },
  {
    "path": "backend/controllers/settings_controller.py",
    "content": "\"\"\"Settings Controller - handles application settings endpoints\"\"\"\n\nimport json\nimport logging\nimport os\nimport shutil\nimport tempfile\nfrom pathlib import Path\nfrom datetime import datetime, timezone\nfrom contextlib import contextmanager\nfrom flask import Blueprint, request, current_app\nfrom PIL import Image\nfrom models import db, Settings, Task\nfrom utils import success_response, error_response, bad_request\nfrom config import Config, PROJECT_ROOT\nfrom services.ai_service import AIService\nfrom services.file_parser_service import FileParserService\nfrom services.ai_providers.ocr.baidu_accurate_ocr_provider import create_baidu_accurate_ocr_provider\nfrom services.ai_providers.image.baidu_inpainting_provider import create_baidu_inpainting_provider\nfrom services.ai_providers import LAZYLLM_VENDORS\nfrom services.task_manager import task_manager\n\nlogger = logging.getLogger(__name__)\nALLOWED_PROVIDER_FORMATS = {\"openai\", \"gemini\", \"lazyllm\"} | LAZYLLM_VENDORS\n\nsettings_bp = Blueprint(\n    \"settings\", __name__, url_prefix=\"/api/settings\"\n)\n\n\n@contextmanager\ndef temporary_settings_override(settings_override: dict):\n    \"\"\"\n    临时应用设置覆盖的上下文管理器\n\n    使用示例:\n        with temporary_settings_override({\"api_key\": \"test-key\"}):\n            # 在这里使用临时设置\n            result = some_test_function()\n\n    Args:\n        settings_override: 要临时应用的设置字典\n\n    Yields:\n        None\n    \"\"\"\n    original_values = {}\n\n    try:\n        # 应用覆盖设置\n        if settings_override.get(\"api_key\"):\n            original_values[\"GOOGLE_API_KEY\"] = current_app.config.get(\"GOOGLE_API_KEY\")\n            original_values[\"OPENAI_API_KEY\"] = current_app.config.get(\"OPENAI_API_KEY\")\n            current_app.config[\"GOOGLE_API_KEY\"] = settings_override[\"api_key\"]\n            current_app.config[\"OPENAI_API_KEY\"] = settings_override[\"api_key\"]\n\n        if settings_override.get(\"api_base_url\"):\n            original_values[\"GOOGLE_API_BASE\"] = current_app.config.get(\"GOOGLE_API_BASE\")\n            original_values[\"OPENAI_API_BASE\"] = current_app.config.get(\"OPENAI_API_BASE\")\n            current_app.config[\"GOOGLE_API_BASE\"] = settings_override[\"api_base_url\"]\n            current_app.config[\"OPENAI_API_BASE\"] = settings_override[\"api_base_url\"]\n\n        if settings_override.get(\"ai_provider_format\"):\n            original_values[\"AI_PROVIDER_FORMAT\"] = current_app.config.get(\"AI_PROVIDER_FORMAT\")\n            current_app.config[\"AI_PROVIDER_FORMAT\"] = settings_override[\"ai_provider_format\"]\n\n        if settings_override.get(\"text_model\"):\n            original_values[\"TEXT_MODEL\"] = current_app.config.get(\"TEXT_MODEL\")\n            current_app.config[\"TEXT_MODEL\"] = settings_override[\"text_model\"]\n\n        if settings_override.get(\"image_model\"):\n            original_values[\"IMAGE_MODEL\"] = current_app.config.get(\"IMAGE_MODEL\")\n            current_app.config[\"IMAGE_MODEL\"] = settings_override[\"image_model\"]\n\n        if settings_override.get(\"image_caption_model\"):\n            original_values[\"IMAGE_CAPTION_MODEL\"] = current_app.config.get(\"IMAGE_CAPTION_MODEL\")\n            current_app.config[\"IMAGE_CAPTION_MODEL\"] = settings_override[\"image_caption_model\"]\n\n        # Per-model source overrides (empty string = clear, to fall back to global config)\n        for source_field, config_key in [\n            (\"text_model_source\", \"TEXT_MODEL_SOURCE\"),\n            (\"image_model_source\", \"IMAGE_MODEL_SOURCE\"),\n            (\"image_caption_model_source\", \"IMAGE_CAPTION_MODEL_SOURCE\"),\n        ]:\n            if source_field in settings_override:\n                original_values[config_key] = current_app.config.get(config_key)\n                val = settings_override[source_field]\n                if val:\n                    current_app.config[config_key] = val\n                else:\n                    current_app.config.pop(config_key, None)\n\n        # Per-model API credentials override\n        for model_type in ('text', 'image', 'image_caption'):\n            prefix = model_type.upper()\n            key_field = f'{model_type}_api_key'\n            base_field = f'{model_type}_api_base_url'\n            if settings_override.get(key_field):\n                config_key = f'{prefix}_API_KEY'\n                original_values[config_key] = current_app.config.get(config_key)\n                current_app.config[config_key] = settings_override[key_field]\n            if settings_override.get(base_field):\n                config_key = f'{prefix}_API_BASE'\n                original_values[config_key] = current_app.config.get(config_key)\n                current_app.config[config_key] = settings_override[base_field]\n\n        if settings_override.get(\"mineru_api_base\"):\n            original_values[\"MINERU_API_BASE\"] = current_app.config.get(\"MINERU_API_BASE\")\n            current_app.config[\"MINERU_API_BASE\"] = settings_override[\"mineru_api_base\"]\n\n        if settings_override.get(\"mineru_token\"):\n            original_values[\"MINERU_TOKEN\"] = current_app.config.get(\"MINERU_TOKEN\")\n            current_app.config[\"MINERU_TOKEN\"] = settings_override[\"mineru_token\"]\n\n        if settings_override.get(\"baidu_api_key\"):\n            original_values[\"BAIDU_API_KEY\"] = current_app.config.get(\"BAIDU_API_KEY\")\n            current_app.config[\"BAIDU_API_KEY\"] = settings_override[\"baidu_api_key\"]\n\n        if settings_override.get(\"image_resolution\"):\n            original_values[\"DEFAULT_RESOLUTION\"] = current_app.config.get(\"DEFAULT_RESOLUTION\")\n            current_app.config[\"DEFAULT_RESOLUTION\"] = settings_override[\"image_resolution\"]\n\n        if \"enable_text_reasoning\" in settings_override:\n            original_values[\"ENABLE_TEXT_REASONING\"] = current_app.config.get(\"ENABLE_TEXT_REASONING\")\n            current_app.config[\"ENABLE_TEXT_REASONING\"] = settings_override[\"enable_text_reasoning\"]\n\n        if \"text_thinking_budget\" in settings_override:\n            original_values[\"TEXT_THINKING_BUDGET\"] = current_app.config.get(\"TEXT_THINKING_BUDGET\")\n            current_app.config[\"TEXT_THINKING_BUDGET\"] = settings_override[\"text_thinking_budget\"]\n\n        if \"enable_image_reasoning\" in settings_override:\n            original_values[\"ENABLE_IMAGE_REASONING\"] = current_app.config.get(\"ENABLE_IMAGE_REASONING\")\n            current_app.config[\"ENABLE_IMAGE_REASONING\"] = settings_override[\"enable_image_reasoning\"]\n\n        if \"image_thinking_budget\" in settings_override:\n            original_values[\"IMAGE_THINKING_BUDGET\"] = current_app.config.get(\"IMAGE_THINKING_BUDGET\")\n            current_app.config[\"IMAGE_THINKING_BUDGET\"] = settings_override[\"image_thinking_budget\"]\n\n        yield\n\n    finally:\n        # 恢复原始配置\n        for key, value in original_values.items():\n            if value is not None:\n                current_app.config[key] = value\n            else:\n                current_app.config.pop(key, None)\n\n\n@settings_bp.route(\"/\", methods=[\"GET\"], strict_slashes=False)\ndef get_settings():\n    \"\"\"\n    GET /api/settings - Get application settings\n    \"\"\"\n    try:\n        settings = Settings.get_settings()\n        return success_response(settings.to_dict())\n    except Exception as e:\n        logger.error(f\"Error getting settings: {str(e)}\")\n        return error_response(\n            \"GET_SETTINGS_ERROR\",\n            f\"Failed to get settings: {str(e)}\",\n            500,\n        )\n\n\n@settings_bp.route(\"/\", methods=[\"PUT\"], strict_slashes=False)\ndef update_settings():\n    \"\"\"\n    PUT /api/settings - Update application settings\n\n    Request Body:\n        {\n            \"api_base_url\": \"https://api.example.com\",\n            \"api_key\": \"your-api-key\",\n            \"image_resolution\": \"2K\",\n            \"image_aspect_ratio\": \"16:9\"\n        }\n    \"\"\"\n    try:\n        data = request.get_json()\n        if not data:\n            return bad_request(\"Request body is required\")\n\n        settings = Settings.get_settings()\n\n        # Update AI provider format configuration\n        if \"ai_provider_format\" in data:\n            provider_format = data[\"ai_provider_format\"]\n            if provider_format not in ALLOWED_PROVIDER_FORMATS:\n                allowed_values = \"', '\".join(sorted(ALLOWED_PROVIDER_FORMATS))\n                return bad_request(f\"AI provider format must be one of '{allowed_values}'\")\n            settings.ai_provider_format = provider_format\n\n        # Update API configuration\n        if \"api_base_url\" in data:\n            raw_base_url = data[\"api_base_url\"]\n            # Empty string from frontend means \"clear override, fall back to env/default\"\n            if raw_base_url is None:\n                settings.api_base_url = None\n            else:\n                value = str(raw_base_url).strip()\n                settings.api_base_url = value if value != \"\" else None\n\n        if \"api_key\" in data:\n            settings.api_key = data[\"api_key\"]\n\n        # Update image generation configuration\n        if \"image_resolution\" in data:\n            resolution = data[\"image_resolution\"]\n            if resolution not in [\"1K\", \"2K\", \"4K\"]:\n                return bad_request(\"Resolution must be 1K, 2K, or 4K\")\n            settings.image_resolution = resolution\n\n        if \"image_aspect_ratio\" in data:\n            aspect_ratio = data[\"image_aspect_ratio\"]\n            settings.image_aspect_ratio = aspect_ratio\n\n        # Update worker configuration\n        if \"max_description_workers\" in data:\n            workers = int(data[\"max_description_workers\"])\n            if workers < 1 or workers > 20:\n                return bad_request(\n                    \"Max description workers must be between 1 and 20\"\n                )\n            settings.max_description_workers = workers\n\n        if \"max_image_workers\" in data:\n            workers = int(data[\"max_image_workers\"])\n            if workers < 1 or workers > 20:\n                return bad_request(\n                    \"Max image workers must be between 1 and 20\"\n                )\n            settings.max_image_workers = workers\n\n        # Update model & MinerU configuration (optional, empty values fall back to Config)\n        if \"text_model\" in data:\n            settings.text_model = (data[\"text_model\"] or \"\").strip() or None\n\n        if \"image_model\" in data:\n            settings.image_model = (data[\"image_model\"] or \"\").strip() or None\n\n        if \"mineru_api_base\" in data:\n            settings.mineru_api_base = (data[\"mineru_api_base\"] or \"\").strip() or None\n\n        if \"mineru_token\" in data:\n            settings.mineru_token = data[\"mineru_token\"]\n\n        if \"image_caption_model\" in data:\n            settings.image_caption_model = (data[\"image_caption_model\"] or \"\").strip() or None\n\n        if \"output_language\" in data:\n            language = data[\"output_language\"]\n            if language in [\"zh\", \"en\", \"ja\", \"auto\"]:\n                settings.output_language = language\n            else:\n                return bad_request(\"Output language must be 'zh', 'en', 'ja', or 'auto'\")\n\n        # Update description generation mode\n        if \"description_generation_mode\" in data:\n            mode = data[\"description_generation_mode\"]\n            if mode not in (\"streaming\", \"parallel\"):\n                return bad_request(\"description_generation_mode must be 'streaming' or 'parallel'\")\n            settings.description_generation_mode = mode\n\n        # Update description extra fields\n        if \"description_extra_fields\" in data:\n            fields = data[\"description_extra_fields\"]\n            if not isinstance(fields, list) or not fields:\n                return bad_request(\"description_extra_fields must be a non-empty array of strings\")\n            if len(fields) > 10:\n                return bad_request(\"description_extra_fields allows at most 10 items\")\n            if not all(isinstance(f, str) and f.strip() for f in fields):\n                return bad_request(\"Each extra field must be a non-empty string\")\n            settings.description_extra_fields = json.dumps([f.strip() for f in fields], ensure_ascii=False)\n\n        if \"image_prompt_extra_fields\" in data:\n            fields = data[\"image_prompt_extra_fields\"]\n            if not isinstance(fields, list):\n                return bad_request(\"image_prompt_extra_fields must be an array of strings\")\n            # 空数组表示不传任何额外字段给图片生成\n            settings.image_prompt_extra_fields = json.dumps([f.strip() for f in fields if isinstance(f, str) and f.strip()], ensure_ascii=False)\n\n        # Update reasoning mode configuration (separate for text and image)\n        if \"enable_text_reasoning\" in data:\n            settings.enable_text_reasoning = bool(data[\"enable_text_reasoning\"])\n        \n        if \"text_thinking_budget\" in data:\n            budget = int(data[\"text_thinking_budget\"])\n            if budget < 1 or budget > 8192:\n                return bad_request(\"Text thinking budget must be between 1 and 8192\")\n            settings.text_thinking_budget = budget\n        \n        if \"enable_image_reasoning\" in data:\n            settings.enable_image_reasoning = bool(data[\"enable_image_reasoning\"])\n        \n        if \"image_thinking_budget\" in data:\n            budget = int(data[\"image_thinking_budget\"])\n            if budget < 1 or budget > 8192:\n                return bad_request(\"Image thinking budget must be between 1 and 8192\")\n            settings.image_thinking_budget = budget\n\n        # Update Baidu OCR configuration\n        if \"baidu_api_key\" in data:\n            settings.baidu_api_key = data[\"baidu_api_key\"] or None\n\n        # Update per-model provider source configuration\n        if \"text_model_source\" in data:\n            settings.text_model_source = (data[\"text_model_source\"] or \"\").strip() or None\n\n        if \"image_model_source\" in data:\n            settings.image_model_source = (data[\"image_model_source\"] or \"\").strip() or None\n\n        if \"image_caption_model_source\" in data:\n            settings.image_caption_model_source = (data[\"image_caption_model_source\"] or \"\").strip() or None\n\n        # Update per-model API credentials (for gemini/openai per-model overrides)\n        for model_type in ('text', 'image', 'image_caption'):\n            key_field = f'{model_type}_api_key'\n            base_field = f'{model_type}_api_base_url'\n\n            if key_field in data:\n                setattr(settings, key_field, data[key_field] or None)\n\n            if base_field in data:\n                setattr(settings, base_field, (data[base_field] or \"\").strip() or None)\n\n        if \"lazyllm_api_keys\" in data:\n            keys_data = data[\"lazyllm_api_keys\"]\n            if isinstance(keys_data, dict):\n                # Merge with existing keys (only update non-empty values)\n                existing = settings.get_lazyllm_api_keys_dict()\n                for vendor, key in keys_data.items():\n                    if key:  # Only update if a new value is provided\n                        existing[vendor] = key\n                settings.lazyllm_api_keys = json.dumps(existing) if existing else None\n            elif keys_data is None:\n                settings.lazyllm_api_keys = None\n\n        settings.updated_at = datetime.now(timezone.utc)\n        db.session.commit()\n\n        # Sync to app.config\n        _sync_settings_to_config(settings)\n\n        logger.info(\"Settings updated successfully\")\n        return success_response(\n            settings.to_dict(), \"Settings updated successfully\"\n        )\n\n    except Exception as e:\n        db.session.rollback()\n        logger.error(f\"Error updating settings: {str(e)}\")\n        return error_response(\n            \"UPDATE_SETTINGS_ERROR\",\n            f\"Failed to update settings: {str(e)}\",\n            500,\n        )\n\n\n@settings_bp.route(\"/reset\", methods=[\"POST\"], strict_slashes=False)\ndef reset_settings():\n    \"\"\"\n    POST /api/settings/reset - Reset settings to default values\n    \"\"\"\n    try:\n        settings = Settings.get_settings()\n\n        # Reset all fields to NULL so .env defaults take over via to_dict()\n        settings.ai_provider_format = None\n        settings.api_base_url = None\n        settings.api_key = None\n        settings.text_model = None\n        settings.image_model = None\n        settings.mineru_api_base = None\n        settings.mineru_token = None\n        settings.image_caption_model = None\n        settings.output_language = None\n        settings.enable_text_reasoning = False\n        settings.text_thinking_budget = 1024\n        settings.enable_image_reasoning = False\n        settings.image_thinking_budget = 1024\n        settings.description_generation_mode = None\n        settings.description_extra_fields = None\n        settings.image_prompt_extra_fields = None\n        settings.baidu_api_key = None\n        settings.text_model_source = None\n        settings.image_model_source = None\n        settings.image_caption_model_source = None\n        settings.lazyllm_api_keys = None\n        for model_type in ('text', 'image', 'image_caption'):\n            setattr(settings, f'{model_type}_api_key', None)\n            setattr(settings, f'{model_type}_api_base_url', None)\n        settings.image_resolution = None\n        settings.image_aspect_ratio = None\n        settings.max_description_workers = None\n        settings.max_image_workers = None\n        settings.updated_at = datetime.now(timezone.utc)\n\n        db.session.commit()\n\n        # Sync to app.config\n        _sync_settings_to_config(settings)\n\n        logger.info(\"Settings reset to defaults\")\n        return success_response(\n            settings.to_dict(), \"Settings reset to defaults\"\n        )\n\n    except Exception as e:\n        db.session.rollback()\n        logger.error(f\"Error resetting settings: {str(e)}\")\n        return error_response(\n            \"RESET_SETTINGS_ERROR\",\n            f\"Failed to reset settings: {str(e)}\",\n            500,\n        )\n\n\n@settings_bp.route(\"/active-config\", methods=[\"GET\"], strict_slashes=False)\ndef get_active_config():\n    \"\"\"\n    GET /api/settings/active-config - Return current app.config values for AI settings.\n    Useful for verifying that _sync_settings_to_config correctly restored .env defaults.\n    \"\"\"\n    return success_response({\n        \"ai_provider_format\": current_app.config.get(\"AI_PROVIDER_FORMAT\"),\n        \"text_model\": current_app.config.get(\"TEXT_MODEL\"),\n        \"image_model\": current_app.config.get(\"IMAGE_MODEL\"),\n        \"output_language\": current_app.config.get(\"OUTPUT_LANGUAGE\"),\n        \"image_caption_model\": current_app.config.get(\"IMAGE_CAPTION_MODEL\"),\n    })\n\n\n@settings_bp.route(\"/verify\", methods=[\"POST\"], strict_slashes=False)\ndef verify_api_key():\n    \"\"\"\n    POST /api/settings/verify - 验证模型配置是否可用\n    通过调用一个轻量测试请求（thinking_budget=0）来判断\n\n    Returns:\n        {\n            \"data\": {\n                \"available\": true/false,\n                \"message\": \"提示信息\"\n            }\n        }\n    \"\"\"\n    try:\n        # 获取当前设置\n        settings = Settings.get_settings()\n        if not settings:\n            return success_response({\n                \"available\": False,\n                \"message\": \"用户设置未找到\"\n            })\n\n        # 准备设置覆盖字典\n        settings_override = {}\n        if settings.api_key:\n            settings_override[\"api_key\"] = settings.api_key\n        if settings.api_base_url:\n            settings_override[\"api_base_url\"] = settings.api_base_url\n        if settings.ai_provider_format:\n            settings_override[\"ai_provider_format\"] = settings.ai_provider_format\n        if settings.text_model:\n            settings_override[\"text_model\"] = settings.text_model\n\n        # 使用上下文管理器临时应用用户配置进行验证\n        with temporary_settings_override(settings_override):\n            from services.ai_providers import get_text_provider\n\n            verification_model = (\n                settings.text_model\n                or current_app.config.get(\"TEXT_MODEL\")\n                or Config.TEXT_MODEL\n                or \"gemini-3-flash-preview\"\n            )\n\n            # 尝试创建provider并调用一个简单的测试请求\n            try:\n                provider = get_text_provider(model=verification_model)\n                # 调用一个简单的测试请求（思考budget=0，最小开销）\n                provider.generate_text(\"Hello\", thinking_budget=0)\n\n                logger.info(\"API key verification successful\")\n                return success_response({\n                    \"available\": True,\n                    \"message\": \"API key 可用\"\n                })\n\n            except ValueError as ve:\n                # API key未配置\n                logger.warning(f\"API key not configured: {str(ve)}\")\n                provider_format = (settings.ai_provider_format or \"\").lower()\n                if provider_format == \"lazyllm\" or provider_format in LAZYLLM_VENDORS:\n                    source = (provider_format if provider_format in LAZYLLM_VENDORS\n                              else current_app.config.get(\"TEXT_MODEL_SOURCE\") or Config.TEXT_MODEL_SOURCE or \"unknown\").upper()\n                    message = f\"LazyLLM API key 未配置，请设置 {source}_API_KEY\"\n                else:\n                    message = \"API key 未配置，请在设置中配置 API key 和 API Base URL\"\n                return success_response({\n                    \"available\": False,\n                    \"message\": message\n                })\n            except Exception as e:\n                # API调用失败（可能是key无效、余额不足等）\n                error_msg = str(e)\n                logger.warning(f\"API key verification failed: {error_msg}\")\n\n                # 根据错误信息判断具体原因\n                if \"401\" in error_msg or \"unauthorized\" in error_msg.lower() or \"invalid\" in error_msg.lower():\n                    message = \"API key 无效或已过期，请在设置中检查 API key 配置\"\n                elif \"429\" in error_msg or \"quota\" in error_msg.lower() or \"limit\" in error_msg.lower():\n                    message = \"API 调用超限或余额不足，请在设置中检查配置\"\n                elif \"403\" in error_msg or \"forbidden\" in error_msg.lower():\n                    message = \"API 访问被拒绝，请在设置中检查 API key 权限\"\n                elif \"timeout\" in error_msg.lower():\n                    message = \"API 调用超时，请在设置中检查网络连接和 API Base URL\"\n                else:\n                    message = f\"API 调用失败，请在设置中检查配置: {error_msg}\"\n\n                return success_response({\n                    \"available\": False,\n                    \"message\": message\n                })\n\n    except Exception as e:\n        logger.error(f\"Error verifying API key: {str(e)}\")\n        return error_response(\n            \"VERIFY_API_KEY_ERROR\",\n            f\"验证 API key 时出错: {str(e)}\",\n            500,\n        )\n\n\ndef _sync_settings_to_config(settings: Settings):\n    \"\"\"Sync settings to Flask app config and clear AI service cache if needed\"\"\"\n    # Track if AI-related settings changed\n    ai_config_changed = False\n    \n    # Sync AI provider format (always sync, fall back to .env default when NULL)\n    new_format = settings.ai_provider_format or Config.AI_PROVIDER_FORMAT\n    old_format = current_app.config.get(\"AI_PROVIDER_FORMAT\")\n    if old_format != new_format:\n        ai_config_changed = True\n        logger.info(f\"AI provider format changed: {old_format} -> {new_format}\")\n    current_app.config[\"AI_PROVIDER_FORMAT\"] = new_format\n    \n    # Sync API configuration (sync to both GOOGLE_* and OPENAI_* to ensure DB settings override env vars)\n    if settings.api_base_url is not None:\n        old_base = current_app.config.get(\"GOOGLE_API_BASE\")\n        if old_base != settings.api_base_url:\n            ai_config_changed = True\n            logger.info(f\"API base URL changed: {old_base} -> {settings.api_base_url}\")\n        current_app.config[\"GOOGLE_API_BASE\"] = settings.api_base_url\n        current_app.config[\"OPENAI_API_BASE\"] = settings.api_base_url\n    else:\n        # Restore .env defaults (pop would permanently lose .env values)\n        env_base_google = Config.GOOGLE_API_BASE\n        env_base_openai = Config.OPENAI_API_BASE\n        if current_app.config.get(\"GOOGLE_API_BASE\") != env_base_google or current_app.config.get(\"OPENAI_API_BASE\") != env_base_openai:\n            ai_config_changed = True\n            logger.info(\"API base URL cleared, falling back to .env defaults\")\n        current_app.config[\"GOOGLE_API_BASE\"] = env_base_google\n        current_app.config[\"OPENAI_API_BASE\"] = env_base_openai\n\n    if settings.api_key is not None:\n        old_key = current_app.config.get(\"GOOGLE_API_KEY\")\n        # Compare actual values to detect any change (but don't log the keys for security)\n        if old_key != settings.api_key:\n            ai_config_changed = True\n            logger.info(\"API key updated\")\n        current_app.config[\"GOOGLE_API_KEY\"] = settings.api_key\n        current_app.config[\"OPENAI_API_KEY\"] = settings.api_key\n    else:\n        # Restore .env defaults (pop would permanently lose .env values)\n        env_key_google = Config.GOOGLE_API_KEY\n        env_key_openai = Config.OPENAI_API_KEY\n        if current_app.config.get(\"GOOGLE_API_KEY\") != env_key_google or current_app.config.get(\"OPENAI_API_KEY\") != env_key_openai:\n            ai_config_changed = True\n            logger.info(\"API key cleared, falling back to .env defaults\")\n        current_app.config[\"GOOGLE_API_KEY\"] = env_key_google\n        current_app.config[\"OPENAI_API_KEY\"] = env_key_openai\n    \n    # Check model changes\n    new_text_model = settings.text_model or Config.TEXT_MODEL\n    old_model = current_app.config.get(\"TEXT_MODEL\")\n    if old_model != new_text_model:\n        ai_config_changed = True\n        logger.info(f\"Text model changed: {old_model} -> {new_text_model}\")\n    current_app.config[\"TEXT_MODEL\"] = new_text_model\n\n    new_image_model = settings.image_model or Config.IMAGE_MODEL\n    old_model = current_app.config.get(\"IMAGE_MODEL\")\n    if old_model != new_image_model:\n        ai_config_changed = True\n        logger.info(f\"Image model changed: {old_model} -> {new_image_model}\")\n    current_app.config[\"IMAGE_MODEL\"] = new_image_model\n\n    # Sync image generation settings (fall back to Config when NULL)\n    current_app.config[\"DEFAULT_RESOLUTION\"] = settings.image_resolution or Config.DEFAULT_RESOLUTION\n    current_app.config[\"DEFAULT_ASPECT_RATIO\"] = settings.image_aspect_ratio or Config.DEFAULT_ASPECT_RATIO\n\n    # Sync worker settings (fall back to Config when NULL)\n    current_app.config[\"MAX_DESCRIPTION_WORKERS\"] = settings.max_description_workers or Config.MAX_DESCRIPTION_WORKERS\n    current_app.config[\"MAX_IMAGE_WORKERS\"] = settings.max_image_workers or Config.MAX_IMAGE_WORKERS\n    logger.info(f\"Updated worker settings: desc={current_app.config['MAX_DESCRIPTION_WORKERS']}, img={current_app.config['MAX_IMAGE_WORKERS']}\")\n\n    # Sync MinerU settings (fall back to Config defaults when NULL)\n    current_app.config[\"MINERU_API_BASE\"] = settings.mineru_api_base or Config.MINERU_API_BASE\n    current_app.config[\"MINERU_TOKEN\"] = settings.mineru_token if settings.mineru_token is not None else Config.MINERU_TOKEN\n    current_app.config[\"IMAGE_CAPTION_MODEL\"] = settings.image_caption_model or Config.IMAGE_CAPTION_MODEL\n    current_app.config[\"OUTPUT_LANGUAGE\"] = settings.output_language or Config.OUTPUT_LANGUAGE\n    \n    # Sync reasoning mode settings (separate for text and image)\n    # Check if reasoning configuration changed (requires AIService cache clear)\n    old_text_reasoning = current_app.config.get(\"ENABLE_TEXT_REASONING\")\n    old_text_budget = current_app.config.get(\"TEXT_THINKING_BUDGET\")\n    old_image_reasoning = current_app.config.get(\"ENABLE_IMAGE_REASONING\")\n    old_image_budget = current_app.config.get(\"IMAGE_THINKING_BUDGET\")\n    \n    if (old_text_reasoning != settings.enable_text_reasoning or \n        old_text_budget != settings.text_thinking_budget or\n        old_image_reasoning != settings.enable_image_reasoning or\n        old_image_budget != settings.image_thinking_budget):\n        ai_config_changed = True\n        logger.info(f\"Reasoning config changed: text={old_text_reasoning}({old_text_budget})->{settings.enable_text_reasoning}({settings.text_thinking_budget}), image={old_image_reasoning}({old_image_budget})->{settings.enable_image_reasoning}({settings.image_thinking_budget})\")\n    \n    current_app.config[\"ENABLE_TEXT_REASONING\"] = settings.enable_text_reasoning\n    current_app.config[\"TEXT_THINKING_BUDGET\"] = settings.text_thinking_budget\n    current_app.config[\"ENABLE_IMAGE_REASONING\"] = settings.enable_image_reasoning\n    current_app.config[\"IMAGE_THINKING_BUDGET\"] = settings.image_thinking_budget\n    \n    # Sync Baidu OCR settings (fall back to Config default when NULL)\n    current_app.config[\"BAIDU_API_KEY\"] = settings.baidu_api_key or Config.BAIDU_API_KEY\n\n    # Sync per-model provider source settings\n    for model_type, source_attr in [('TEXT', 'text_model_source'), ('IMAGE', 'image_model_source'), ('IMAGE_CAPTION', 'image_caption_model_source')]:\n        source_val = getattr(settings, source_attr, None)\n        config_key = f'{model_type}_MODEL_SOURCE'\n        if source_val:\n            old_source = current_app.config.get(config_key)\n            if old_source != source_val:\n                ai_config_changed = True\n            current_app.config[config_key] = source_val\n        else:\n            if config_key in current_app.config:\n                ai_config_changed = True\n            current_app.config.pop(config_key, None)\n\n    # Sync per-model API credentials (for gemini/openai per-model overrides)\n    for model_type in ('text', 'image', 'image_caption'):\n        prefix = model_type.upper()\n        for suffix, setting_suffix in [('_API_KEY', '_api_key'), ('_API_BASE', '_api_base_url')]:\n            config_key = f'{prefix}{suffix}'\n            val = getattr(settings, f'{model_type}{setting_suffix}', None)\n            if val:\n                if current_app.config.get(config_key) != val:\n                    ai_config_changed = True\n                current_app.config[config_key] = val\n            else:\n                if config_key in current_app.config:\n                    ai_config_changed = True\n                current_app.config.pop(config_key, None)\n\n    # Sync LazyLLM vendor API keys to environment variables\n    # (lazyllm_env.py reads from os.environ via {SOURCE}_API_KEY)\n    if settings.lazyllm_api_keys:\n        try:\n            keys = json.loads(settings.lazyllm_api_keys)\n            for vendor, key in keys.items():\n                if key:\n                    env_key = f\"{vendor.upper()}_API_KEY\"\n                    if os.environ.get(env_key) != key:\n                        ai_config_changed = True\n                    os.environ[env_key] = key\n        except (json.JSONDecodeError, TypeError):\n            pass\n    \n    # Clear AI service cache if AI-related configuration changed\n    if ai_config_changed:\n        try:\n            from services.ai_service_manager import clear_ai_service_cache\n            clear_ai_service_cache()\n            logger.warning(\"AI configuration changed - AIService cache cleared. New providers will be created on next request.\")\n        except Exception as e:\n            logger.error(f\"Failed to clear AI service cache: {e}\")\n\n\ndef _get_test_image_path() -> Path:\n    test_image = Path(PROJECT_ROOT) / \"assets\" / \"test_img.png\"\n    if not test_image.exists():\n        raise FileNotFoundError(\"未找到 test_img.png，请确认已放在项目根目录 assets 下\")\n    return test_image\n\n\ndef _get_baidu_credentials():\n    \"\"\"获取百度 API 凭证\"\"\"\n    api_key = current_app.config.get(\"BAIDU_API_KEY\") or Config.BAIDU_API_KEY\n    if not api_key:\n        raise ValueError(\"未配置 BAIDU_API_KEY\")\n    return api_key\n\n\ndef _create_file_parser():\n    \"\"\"创建 FileParserService 实例，根据 per-model caption 配置解析正确的凭证\"\"\"\n    from services.ai_providers import LAZYLLM_VENDORS\n\n    caption_source = current_app.config.get(\"IMAGE_CAPTION_MODEL_SOURCE\")\n    global_format = current_app.config.get(\"AI_PROVIDER_FORMAT\", \"gemini\")\n\n    # Determine effective caption provider format\n    if caption_source:\n        source_lower = caption_source.lower()\n        if source_lower == 'gemini':\n            caption_format = 'gemini'\n        elif source_lower == 'openai':\n            caption_format = 'openai'\n        elif source_lower in LAZYLLM_VENDORS:\n            caption_format = 'lazyllm'\n        else:\n            caption_format = global_format\n    else:\n        caption_format = global_format\n\n    # Resolve API credentials based on caption format\n    if caption_format == 'gemini':\n        google_key = current_app.config.get(\"IMAGE_CAPTION_API_KEY\") or current_app.config.get(\"GOOGLE_API_KEY\", \"\")\n        google_base = current_app.config.get(\"IMAGE_CAPTION_API_BASE\") or current_app.config.get(\"GOOGLE_API_BASE\", \"\")\n        openai_key = \"\"\n        openai_base = \"\"\n    elif caption_format == 'openai':\n        google_key = \"\"\n        google_base = \"\"\n        openai_key = current_app.config.get(\"IMAGE_CAPTION_API_KEY\") or current_app.config.get(\"OPENAI_API_KEY\", \"\")\n        openai_base = current_app.config.get(\"IMAGE_CAPTION_API_BASE\") or current_app.config.get(\"OPENAI_API_BASE\", \"\")\n    else:\n        # lazyllm or global fallback\n        google_key = current_app.config.get(\"GOOGLE_API_KEY\", \"\")\n        google_base = current_app.config.get(\"GOOGLE_API_BASE\", \"\")\n        openai_key = current_app.config.get(\"OPENAI_API_KEY\", \"\")\n        openai_base = current_app.config.get(\"OPENAI_API_BASE\", \"\")\n\n    return FileParserService(\n        mineru_token=current_app.config.get(\"MINERU_TOKEN\", \"\"),\n        mineru_api_base=current_app.config.get(\"MINERU_API_BASE\", \"\"),\n        google_api_key=google_key,\n        google_api_base=google_base,\n        openai_api_key=openai_key,\n        openai_api_base=openai_base,\n        image_caption_model=current_app.config.get(\"IMAGE_CAPTION_MODEL\", Config.IMAGE_CAPTION_MODEL),\n        lazyllm_image_caption_source=caption_source or getattr(\n            Config, 'IMAGE_CAPTION_MODEL_SOURCE', None\n        ),\n        provider_format=caption_format,\n    )\n\n\n# 测试函数 - 每个测试一个独立函数\ndef _test_baidu_ocr():\n    \"\"\"测试百度 OCR 服务\"\"\"\n    api_key = _get_baidu_credentials()\n    provider = create_baidu_accurate_ocr_provider(api_key)\n    if not provider:\n        raise ValueError(\"百度 OCR Provider 初始化失败\")\n\n    test_image_path = _get_test_image_path()\n    result = provider.recognize(str(test_image_path), language_type=\"CHN_ENG\")\n    recognized_text = provider.get_full_text(result, separator=\" \")\n\n    return {\n        \"recognized_text\": recognized_text,\n        \"words_result_num\": result.get(\"words_result_num\", 0),\n    }, \"百度 OCR 测试成功\"\n\n\ndef _test_text_model():\n    \"\"\"测试文本生成模型\"\"\"\n    ai_service = AIService()\n    reply = ai_service.text_provider.generate_text(\"请只回复 OK。\", thinking_budget=64)\n    return {\"reply\": reply.strip()}, \"文本模型测试成功\"\n\n\ndef _test_caption_model():\n    \"\"\"测试图片识别模型\"\"\"\n    upload_folder = Path(current_app.config.get(\"UPLOAD_FOLDER\", Config.UPLOAD_FOLDER))\n    mineru_root = upload_folder / \"mineru_files\"\n    mineru_root.mkdir(parents=True, exist_ok=True)\n    extract_id = datetime.now(timezone.utc).strftime(\"test-%Y%m%d%H%M%S\")\n    image_dir = mineru_root / extract_id\n    image_dir.mkdir(parents=True, exist_ok=True)\n    image_path = image_dir / \"caption_test.png\"\n\n    try:\n        test_image_path = _get_test_image_path()\n        shutil.copyfile(test_image_path, image_path)\n\n        parser = _create_file_parser()\n        image_url = f\"/files/mineru/{extract_id}/{image_path.name}\"\n        caption = parser._generate_single_caption(image_url).strip()\n\n        if not caption:\n            raise ValueError(\"图片识别模型返回空结果\")\n\n        return {\"caption\": caption}, \"图片识别模型测试成功\"\n    finally:\n        if image_path.exists():\n            image_path.unlink()\n        if image_dir.exists():\n            try:\n                image_dir.rmdir()\n            except OSError:\n                pass\n\n\ndef _test_baidu_inpaint():\n    \"\"\"测试百度图像修复\"\"\"\n    api_key = _get_baidu_credentials()\n    provider = create_baidu_inpainting_provider(api_key)\n    if not provider:\n        raise ValueError(\"百度图像修复 Provider 初始化失败\")\n\n    test_image_path = _get_test_image_path()\n    with Image.open(test_image_path) as image:\n        width, height = image.size\n        rect_width = max(1, int(width * 0.3))\n        rect_height = max(1, int(height * 0.3))\n        left = max(0, int(width * 0.35))\n        top = max(0, int(height * 0.35))\n        rectangles = [{\n            \"left\": left,\n            \"top\": top,\n            \"width\": min(rect_width, width - left),\n            \"height\": min(rect_height, height - top),\n        }]\n        result = provider.inpaint(image, rectangles)\n\n    if result is None:\n        raise ValueError(\"百度图像修复返回空结果\")\n\n    return {\"image_size\": result.size}, \"百度图像修复测试成功\"\n\n\ndef _test_image_model():\n    \"\"\"测试图像生成模型\"\"\"\n    ai_service = AIService()\n    test_image_path = _get_test_image_path()\n    prompt = \"生成一张简洁、明亮、适合演示文稿的背景图。\"\n    settings = Settings.get_settings()\n    result = ai_service.generate_image(\n        prompt=prompt,\n        ref_image_path=str(test_image_path),\n        aspect_ratio=settings.image_aspect_ratio or \"16:9\",\n        resolution=settings.image_resolution or \"2K\"\n    )\n\n    if result is None:\n        raise ValueError(\"图像生成模型返回空结果\")\n\n    return {\"image_size\": result.size}, \"图像生成模型测试成功\"\n\n\ndef _test_mineru_pdf():\n    \"\"\"测试 MinerU PDF 解析\"\"\"\n    mineru_token = current_app.config.get(\"MINERU_TOKEN\", \"\")\n    if not mineru_token:\n        raise ValueError(\"未配置 MINERU_TOKEN\")\n\n    parser = _create_file_parser()\n    tmp_file = None\n    try:\n        with tempfile.NamedTemporaryFile(suffix=\".pdf\", delete=False) as tmp:\n            tmp_file = Path(tmp.name)\n        test_image_path = _get_test_image_path()\n        with Image.open(test_image_path) as image:\n            if image.mode != \"RGB\":\n                image = image.convert(\"RGB\")\n            image.save(tmp_file, format=\"PDF\")\n\n        batch_id, upload_url, error = parser._get_upload_url(\"mineru-test.pdf\")\n        if error:\n            raise ValueError(error)\n\n        upload_error = parser._upload_file(str(tmp_file), upload_url)\n        if upload_error:\n            raise ValueError(upload_error)\n\n        markdown_content, extract_id, poll_error = parser._poll_result(batch_id, max_wait_time=30)\n        if poll_error:\n            if \"timeout\" in poll_error.lower():\n                return {\n                    \"batch_id\": batch_id,\n                    \"status\": \"processing\",\n                    \"message\": \"服务正常，文件正在处理中\"\n                }, \"MinerU 服务可用（处理中）\"\n            else:\n                raise ValueError(poll_error)\n        else:\n            content_preview = (markdown_content or \"\").strip()[:120]\n            return {\n                \"batch_id\": batch_id,\n                \"extract_id\": extract_id,\n                \"content_preview\": content_preview,\n            }, \"MinerU 解析测试成功\"\n    finally:\n        if tmp_file and tmp_file.exists():\n            tmp_file.unlink()\n\n\n# 测试函数映射\nTEST_FUNCTIONS = {\n    \"baidu-ocr\": _test_baidu_ocr,\n    \"text-model\": _test_text_model,\n    \"caption-model\": _test_caption_model,\n    \"baidu-inpaint\": _test_baidu_inpaint,\n    \"image-model\": _test_image_model,\n    \"mineru-pdf\": _test_mineru_pdf,\n}\n\n\ndef _run_test_async(task_id: str, test_name: str, test_settings: dict, app):\n    \"\"\"\n    在后台异步执行测试任务\n\n    Args:\n        task_id: 任务ID\n        test_name: 测试名称\n        test_settings: 测试设置\n        app: Flask app 实例\n    \"\"\"\n    with app.app_context():\n        try:\n            # 更新状态为运行中\n            task = Task.query.get(task_id)\n            if not task:\n                logger.error(f\"Task {task_id} not found\")\n                return\n\n            task.status = 'PROCESSING'\n            db.session.commit()\n\n            # 应用测试设置并执行测试\n            with temporary_settings_override(test_settings):\n                # 查找并执行对应的测试函数\n                test_func = TEST_FUNCTIONS.get(test_name)\n                if not test_func:\n                    raise ValueError(f\"未知测试类型: {test_name}\")\n\n                result_data, message = test_func()\n\n                # 更新任务状态为完成\n                task = Task.query.get(task_id)\n                if task:\n                    task.status = 'COMPLETED'\n                    task.completed_at = datetime.now(timezone.utc)\n                    task.set_progress({\n                        'result': result_data,\n                        'message': message\n                    })\n                    db.session.commit()\n                    logger.info(f\"Test task {task_id} completed successfully\")\n\n        except Exception as e:\n            error_msg = str(e)\n            logger.error(f\"Test task {task_id} failed: {error_msg}\", exc_info=True)\n            task = Task.query.get(task_id)\n            if task:\n                task.status = 'FAILED'\n                task.error_message = error_msg\n                task.completed_at = datetime.now(timezone.utc)\n                db.session.commit()\n\n\n\n@settings_bp.route(\"/tests/<test_name>\", methods=[\"POST\"], strict_slashes=False)\ndef run_settings_test(test_name: str):\n    \"\"\"\n    POST /api/settings/tests/<test_name> - 启动异步服务测试\n\n    Request Body (optional):\n        可选的设置覆盖参数，用于测试未保存的配置\n        {\n            \"api_key\": \"test-key\",\n            \"api_base_url\": \"https://test.api.com\",\n            \"text_model\": \"test-model\",\n            ...\n        }\n\n    Returns:\n        {\n            \"data\": {\n                \"task_id\": \"uuid\",\n                \"status\": \"PENDING\"\n            }\n        }\n    \"\"\"\n    try:\n        # 从数据库加载已保存的全局设置作为基础\n        global_settings = Settings.get_settings()\n\n        # 构建基础测试设置（使用数据库中已保存的值）\n        test_settings = {}\n        if global_settings.api_key:\n            test_settings[\"api_key\"] = global_settings.api_key\n        if global_settings.api_base_url:\n            test_settings[\"api_base_url\"] = global_settings.api_base_url\n        if global_settings.ai_provider_format:\n            test_settings[\"ai_provider_format\"] = global_settings.ai_provider_format\n        if global_settings.text_model:\n            test_settings[\"text_model\"] = global_settings.text_model\n        if global_settings.image_model:\n            test_settings[\"image_model\"] = global_settings.image_model\n        if global_settings.image_caption_model:\n            test_settings[\"image_caption_model\"] = global_settings.image_caption_model\n        if current_app.config.get(\"IMAGE_CAPTION_MODEL_SOURCE\"):\n            test_settings[\"image_caption_model_source\"] = current_app.config.get(\"IMAGE_CAPTION_MODEL_SOURCE\")\n        # Per-model provider sources and credentials\n        for model_type in ('text', 'image', 'image_caption'):\n            for suffix in ('model_source', 'api_key', 'api_base_url'):\n                attr = f'{model_type}_{suffix}'\n                val = getattr(global_settings, attr, None)\n                if val:\n                    test_settings[attr] = val\n        if global_settings.mineru_api_base:\n            test_settings[\"mineru_api_base\"] = global_settings.mineru_api_base\n        if global_settings.mineru_token:\n            test_settings[\"mineru_token\"] = global_settings.mineru_token\n        if global_settings.baidu_api_key:\n            test_settings[\"baidu_api_key\"] = global_settings.baidu_api_key\n        if global_settings.image_resolution:\n            test_settings[\"image_resolution\"] = global_settings.image_resolution\n        # 推理模式设置\n        test_settings[\"enable_text_reasoning\"] = global_settings.enable_text_reasoning\n        test_settings[\"text_thinking_budget\"] = global_settings.text_thinking_budget\n        test_settings[\"enable_image_reasoning\"] = global_settings.enable_image_reasoning\n        test_settings[\"image_thinking_budget\"] = global_settings.image_thinking_budget\n\n        # 应用前端发送的覆盖参数（如果有的话，用于测试未保存的配置）\n        override_settings = request.get_json() or {}\n        if override_settings:\n            logger.info(f\"Applying test setting overrides: {list(override_settings.keys())}\")\n            test_settings.update(override_settings)\n\n        # 创建任务记录（使用特殊的 project_id='settings-test'）\n        task = Task(\n            project_id='settings-test',  # 特殊标记，表示这是设置测试任务\n            task_type=f'TEST_{test_name.upper().replace(\"-\", \"_\")}',\n            status='PENDING'\n        )\n        db.session.add(task)\n        db.session.commit()\n\n        task_id = task.id\n\n        # 使用 TaskManager 提交后台任务\n        task_manager.submit_task(\n            task_id,\n            _run_test_async,\n            test_name,\n            test_settings,\n            current_app._get_current_object()\n        )\n\n        logger.info(f\"Started test task {task_id} for {test_name}\")\n\n        return success_response({\n            'task_id': task_id,\n            'status': 'PENDING'\n        }, '测试任务已启动')\n\n    except Exception as e:\n        logger.error(f\"Failed to start test: {str(e)}\", exc_info=True)\n        return error_response(\n            \"SETTINGS_TEST_ERROR\",\n            f\"启动测试失败: {str(e)}\",\n            500\n        )\n\n\n@settings_bp.route(\"/tests/<task_id>/status\", methods=[\"GET\"], strict_slashes=False)\ndef get_test_status(task_id: str):\n    \"\"\"\n    GET /api/settings/tests/<task_id>/status - 查询测试任务状态\n\n    Returns:\n        {\n            \"data\": {\n                \"status\": \"PENDING|PROCESSING|COMPLETED|FAILED\",\n                \"result\": {...},  # 仅当 status=COMPLETED 时存在\n                \"error\": \"...\",   # 仅当 status=FAILED 时存在\n                \"message\": \"...\"\n            }\n        }\n    \"\"\"\n    try:\n        task = Task.query.get(task_id)\n        if not task:\n            return error_response(\"TASK_NOT_FOUND\", \"测试任务不存在\", 404)\n\n        # 构建响应数据\n        response_data = {\n            'status': task.status,\n            'task_type': task.task_type,\n            'created_at': task.created_at.isoformat() if task.created_at else None,\n            'completed_at': task.completed_at.isoformat() if task.completed_at else None,\n        }\n\n        # 如果任务完成，包含结果和消息\n        if task.status == 'COMPLETED':\n            progress = task.get_progress()\n            response_data['result'] = progress.get('result', {})\n            response_data['message'] = progress.get('message', '测试完成')\n\n        # 如果任务失败，包含错误信息\n        elif task.status == 'FAILED':\n            response_data['error'] = task.error_message\n\n        return success_response(response_data)\n\n    except Exception as e:\n        logger.error(f\"Failed to get test status: {str(e)}\", exc_info=True)\n        return error_response(\n            \"GET_TEST_STATUS_ERROR\",\n            f\"获取测试状态失败: {str(e)}\",\n            500\n        )\n"
  },
  {
    "path": "backend/controllers/template_controller.py",
    "content": "\"\"\"\nTemplate Controller - handles template-related endpoints\n\"\"\"\nimport logging\nfrom flask import Blueprint, request, current_app\nfrom models import db, Project, UserTemplate\nfrom utils import success_response, error_response, not_found, bad_request, allowed_file\nfrom services import FileService\nfrom datetime import datetime\n\nlogger = logging.getLogger(__name__)\n\ntemplate_bp = Blueprint('templates', __name__, url_prefix='/api/projects')\nuser_template_bp = Blueprint('user_templates', __name__, url_prefix='/api/user-templates')\n\n\n@template_bp.route('/<project_id>/template', methods=['POST'])\ndef upload_template(project_id):\n    \"\"\"\n    POST /api/projects/{project_id}/template - Upload template image\n    \n    Content-Type: multipart/form-data\n    Form: template_image=@file.png\n    \"\"\"\n    try:\n        project = Project.query.get(project_id)\n        \n        if not project:\n            return not_found('Project')\n        \n        # Check if file is in request\n        if 'template_image' not in request.files:\n            return bad_request(\"No file uploaded\")\n        \n        file = request.files['template_image']\n        \n        if file.filename == '':\n            return bad_request(\"No file selected\")\n        \n        # Validate file extension\n        if not allowed_file(file.filename, current_app.config['ALLOWED_EXTENSIONS']):\n            return bad_request(\"Invalid file type. Allowed types: png, jpg, jpeg, gif, webp\")\n        \n        # Save template\n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n        file_path = file_service.save_template_image(file, project_id)\n        \n        # Update project\n        project.template_image_path = file_path\n        project.updated_at = datetime.utcnow()\n        \n        db.session.commit()\n        \n        return success_response({\n            'template_image_url': f'/files/{project_id}/template/{file_path.split(\"/\")[-1]}'\n        })\n    \n    except Exception as e:\n        db.session.rollback()\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@template_bp.route('/<project_id>/template', methods=['DELETE'])\ndef delete_template(project_id):\n    \"\"\"\n    DELETE /api/projects/{project_id}/template - Delete template\n    \"\"\"\n    try:\n        project = Project.query.get(project_id)\n        \n        if not project:\n            return not_found('Project')\n        \n        if not project.template_image_path:\n            return bad_request(\"No template to delete\")\n        \n        # Delete template file\n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n        file_service.delete_template(project_id)\n        \n        # Update project\n        project.template_image_path = None\n        project.updated_at = datetime.utcnow()\n        \n        db.session.commit()\n        \n        return success_response(message=\"Template deleted successfully\")\n    \n    except Exception as e:\n        db.session.rollback()\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@template_bp.route('/templates', methods=['GET'])\ndef get_system_templates():\n    \"\"\"\n    GET /api/templates - Get system preset templates\n    \n    Note: This is a placeholder for future implementation\n    \"\"\"\n    # TODO: Implement system templates\n    templates = []\n    \n    return success_response({\n        'templates': templates\n    })\n\n\n# ========== User Template Endpoints ==========\n\n@user_template_bp.route('', methods=['POST'])\ndef upload_user_template():\n    \"\"\"\n    POST /api/user-templates - Upload user template image\n\n    Content-Type: multipart/form-data\n    Form: template_image=@file.png\n    Optional: name=Template Name\n    \"\"\"\n    try:\n        # Check if file is in request\n        if 'template_image' not in request.files:\n            return bad_request(\"No file uploaded\")\n\n        file = request.files['template_image']\n\n        if file.filename == '':\n            return bad_request(\"No file selected\")\n\n        # Validate file extension\n        if not allowed_file(file.filename, current_app.config['ALLOWED_EXTENSIONS']):\n            return bad_request(\"Invalid file type. Allowed types: png, jpg, jpeg, gif, webp\")\n\n        # Get optional name\n        name = request.form.get('name', None)\n\n        # Get file size before saving\n        file.seek(0, 2)  # Seek to end\n        file_size = file.tell()\n        file.seek(0)  # Reset to beginning\n\n        # Generate template ID first\n        import uuid\n        template_id = str(uuid.uuid4())\n\n        # Save template file first (using the generated ID)\n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n        file_path = file_service.save_user_template(file, template_id)\n\n        # Generate thumbnail for faster loading\n        thumb_path = file_service.save_user_template_thumbnail(template_id, file_path)\n\n        # Create template record with file_path already set\n        template = UserTemplate(\n            id=template_id,\n            name=name,\n            file_path=file_path,\n            thumb_path=thumb_path,\n            file_size=file_size\n        )\n        db.session.add(template)\n        db.session.commit()\n\n        return success_response(template.to_dict())\n    \n    except Exception as e:\n        import traceback\n        db.session.rollback()\n        error_msg = str(e)\n        logger.error(f\"Error uploading user template: {error_msg}\", exc_info=True)\n        # 在开发环境中返回详细错误，生产环境返回通用错误\n        if current_app.config.get('DEBUG', False):\n            return error_response('SERVER_ERROR', f\"{error_msg}\\n{traceback.format_exc()}\", 500)\n        else:\n            return error_response('SERVER_ERROR', error_msg, 500)\n\n\n@user_template_bp.route('', methods=['GET'])\ndef list_user_templates():\n    \"\"\"\n    GET /api/user-templates - Get list of user templates\n    \"\"\"\n    try:\n        templates = UserTemplate.query.order_by(UserTemplate.created_at.desc()).all()\n        \n        return success_response({\n            'templates': [template.to_dict() for template in templates]\n        })\n    \n    except Exception as e:\n        return error_response('SERVER_ERROR', str(e), 500)\n\n\n@user_template_bp.route('/<template_id>', methods=['DELETE'])\ndef delete_user_template(template_id):\n    \"\"\"\n    DELETE /api/user-templates/{template_id} - Delete user template\n    \"\"\"\n    try:\n        template = UserTemplate.query.get(template_id)\n        \n        if not template:\n            return not_found('UserTemplate')\n        \n        # Delete template file\n        file_service = FileService(current_app.config['UPLOAD_FOLDER'])\n        file_service.delete_user_template(template_id)\n        \n        # Delete template record\n        db.session.delete(template)\n        db.session.commit()\n        \n        return success_response(message=\"Template deleted successfully\")\n    \n    except Exception as e:\n        db.session.rollback()\n        return error_response('SERVER_ERROR', str(e), 500)\n\n"
  },
  {
    "path": "backend/migrations/env.py",
    "content": "import os\nimport sys\nfrom logging.config import fileConfig\n\nfrom alembic import context\nfrom sqlalchemy import engine_from_config, pool\n\n# Add the backend directory to the Python path\nsys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))\n\nfrom app import create_app\nfrom models import db\n\n# this is the Alembic Config object, which provides\n# access to the values within the .ini file in use.\nconfig = context.config\n\n# Interpret the config file for Python logging.\nif config.config_file_name is not None:\n    fileConfig(config.config_file_name)\n\n# target_metadata is used for autogenerate support.\ntarget_metadata = db.metadata\n\n\ndef get_url() -> str:\n    \"\"\"Get database URL from Flask application config.\"\"\"\n    app = create_app()\n    return app.config[\"SQLALCHEMY_DATABASE_URI\"]\n\n\ndef run_migrations_offline() -> None:\n    \"\"\"Run migrations in 'offline' mode.\"\"\"\n    url = get_url()\n    context.configure(\n        url=url,\n        target_metadata=target_metadata,\n        literal_binds=True,\n        compare_type=True,\n    )\n\n    with context.begin_transaction():\n        context.run_migrations()\n\n\ndef run_migrations_online() -> None:\n    \"\"\"Run migrations in 'online' mode.\"\"\"\n    app = create_app()\n    connectable = engine_from_config(\n        {\"sqlalchemy.url\": app.config[\"SQLALCHEMY_DATABASE_URI\"]},\n        prefix=\"sqlalchemy.\",\n        poolclass=pool.NullPool,\n    )\n\n    with connectable.connect() as connection:\n        context.configure(\n            connection=connection,\n            target_metadata=target_metadata,\n            compare_type=True,\n        )\n\n        with context.begin_transaction():\n            context.run_migrations()\n\n\nif context.is_offline_mode():\n    run_migrations_offline()\nelse:\n    run_migrations_online()\n\n\n\n"
  },
  {
    "path": "backend/migrations/script.py.mako",
    "content": "\"\"\"${message}\n\nRevision ID: ${up_revision}\nRevises: ${down_revision | comma,n}\nCreate Date: ${create_date}\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n${imports if imports else \"\"}\n\n# revision identifiers, used by Alembic.\nrevision = ${repr(up_revision)}\ndown_revision = ${repr(down_revision)}\nbranch_labels = ${repr(branch_labels)}\ndepends_on = ${repr(depends_on)}\n\n\ndef upgrade() -> None:\n    ${upgrades if upgrades else \"pass\"}\n\n\ndef downgrade() -> None:\n    ${downgrades if downgrades else \"pass\"}\n\n\n\n"
  },
  {
    "path": "backend/migrations/versions/001_baseline_schema.py",
    "content": "\"\"\"baseline schema - core tables only\n\nRevision ID: 001_baseline\nRevises: \nCreate Date: 2025-12-17 22:00:00.000000\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\nfrom sqlalchemy import inspect\n\n\n# revision identifiers, used by Alembic.\nrevision = '001_baseline'\ndown_revision = None\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    \"\"\"\n    Baseline migration - creates only the earliest core tables.\n    \n    Idempotent: skips if 'projects' table already exists (old project).\n    \"\"\"\n    bind = op.get_bind()\n    inspector = inspect(bind)\n    if 'projects' in inspector.get_table_names():\n        # Old project: tables already created by db.create_all(), skip\n        return\n    \n    # New installation: create core tables (NOT including settings - it came later)\n    op.create_table('projects',\n    sa.Column('id', sa.String(length=36), nullable=False),\n    sa.Column('idea_prompt', sa.Text(), nullable=True),\n    sa.Column('outline_text', sa.Text(), nullable=True),\n    sa.Column('description_text', sa.Text(), nullable=True),\n    sa.Column('extra_requirements', sa.Text(), nullable=True),\n    sa.Column('creation_type', sa.String(length=20), nullable=False),\n    sa.Column('template_image_path', sa.String(length=500), nullable=True),\n    sa.Column('status', sa.String(length=50), nullable=False),\n    sa.Column('created_at', sa.DateTime(), nullable=False),\n    sa.Column('updated_at', sa.DateTime(), nullable=False),\n    sa.PrimaryKeyConstraint('id')\n    )\n    \n    op.create_table('user_templates',\n    sa.Column('id', sa.String(length=36), nullable=False),\n    sa.Column('name', sa.String(length=200), nullable=True),\n    sa.Column('file_path', sa.String(length=500), nullable=False),\n    sa.Column('file_size', sa.Integer(), nullable=True),\n    sa.Column('created_at', sa.DateTime(), nullable=False),\n    sa.Column('updated_at', sa.DateTime(), nullable=False),\n    sa.PrimaryKeyConstraint('id')\n    )\n    \n    op.create_table('materials',\n    sa.Column('id', sa.String(length=36), nullable=False),\n    sa.Column('project_id', sa.String(length=36), nullable=True),\n    sa.Column('filename', sa.String(length=500), nullable=False),\n    sa.Column('relative_path', sa.String(length=500), nullable=False),\n    sa.Column('url', sa.String(length=500), nullable=False),\n    sa.Column('created_at', sa.DateTime(), nullable=False),\n    sa.Column('updated_at', sa.DateTime(), nullable=False),\n    sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ),\n    sa.PrimaryKeyConstraint('id')\n    )\n    \n    op.create_table('pages',\n    sa.Column('id', sa.String(length=36), nullable=False),\n    sa.Column('project_id', sa.String(length=36), nullable=False),\n    sa.Column('order_index', sa.Integer(), nullable=False),\n    sa.Column('part', sa.String(length=200), nullable=True),\n    sa.Column('outline_content', sa.Text(), nullable=True),\n    sa.Column('description_content', sa.Text(), nullable=True),\n    sa.Column('generated_image_path', sa.String(length=500), nullable=True),\n    sa.Column('status', sa.String(length=50), nullable=False),\n    sa.Column('created_at', sa.DateTime(), nullable=False),\n    sa.Column('updated_at', sa.DateTime(), nullable=False),\n    sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ),\n    sa.PrimaryKeyConstraint('id')\n    )\n    \n    op.create_table('reference_files',\n    sa.Column('id', sa.String(length=36), nullable=False),\n    sa.Column('project_id', sa.String(length=36), nullable=True),\n    sa.Column('filename', sa.String(length=500), nullable=False),\n    sa.Column('file_path', sa.String(length=500), nullable=False),\n    sa.Column('file_size', sa.Integer(), nullable=False),\n    sa.Column('file_type', sa.String(length=50), nullable=False),\n    sa.Column('parse_status', sa.String(length=50), nullable=False),\n    sa.Column('markdown_content', sa.Text(), nullable=True),\n    sa.Column('error_message', sa.Text(), nullable=True),\n    sa.Column('mineru_batch_id', sa.String(length=100), nullable=True),\n    sa.Column('created_at', sa.DateTime(), nullable=False),\n    sa.Column('updated_at', sa.DateTime(), nullable=False),\n    sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ),\n    sa.PrimaryKeyConstraint('id')\n    )\n    \n    op.create_table('tasks',\n    sa.Column('id', sa.String(length=36), nullable=False),\n    sa.Column('project_id', sa.String(length=36), nullable=False),\n    sa.Column('task_type', sa.String(length=50), nullable=False),\n    sa.Column('status', sa.String(length=50), nullable=False),\n    sa.Column('progress', sa.Text(), nullable=True),\n    sa.Column('error_message', sa.Text(), nullable=True),\n    sa.Column('created_at', sa.DateTime(), nullable=False),\n    sa.Column('completed_at', sa.DateTime(), nullable=True),\n    sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ),\n    sa.PrimaryKeyConstraint('id')\n    )\n    \n    op.create_table('page_image_versions',\n    sa.Column('id', sa.String(length=36), nullable=False),\n    sa.Column('page_id', sa.String(length=36), nullable=False),\n    sa.Column('image_path', sa.String(length=500), nullable=False),\n    sa.Column('version_number', sa.Integer(), nullable=False),\n    sa.Column('is_current', sa.Boolean(), nullable=False),\n    sa.Column('created_at', sa.DateTime(), nullable=False),\n    sa.ForeignKeyConstraint(['page_id'], ['pages.id'], ),\n    sa.PrimaryKeyConstraint('id')\n    )\n    op.create_index(op.f('ix_page_image_versions_page_id'), 'page_image_versions', ['page_id'], unique=False)\n\n\ndef downgrade() -> None:\n    op.drop_index(op.f('ix_page_image_versions_page_id'), table_name='page_image_versions')\n    op.drop_table('page_image_versions')\n    op.drop_table('tasks')\n    op.drop_table('reference_files')\n    op.drop_table('pages')\n    op.drop_table('materials')\n    op.drop_table('user_templates')\n    op.drop_table('projects')\n\n"
  },
  {
    "path": "backend/migrations/versions/002_create_settings_table.py",
    "content": "\"\"\"create settings table\n\nRevision ID: 002_settings\nRevises: 001_baseline\nCreate Date: 2025-12-17 22:01:00.000000\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\nfrom sqlalchemy import inspect\n\n\n# revision identifiers, used by Alembic.\nrevision = '002_settings'\ndown_revision = '001_baseline'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    \"\"\"\n    Create settings table (without new model/mineru fields).\n    \n    Idempotent: skips if 'settings' table already exists.\n    \"\"\"\n    bind = op.get_bind()\n    inspector = inspect(bind)\n    if 'settings' in inspector.get_table_names():\n        # Settings table already exists (created by db.create_all() in an intermediate version)\n        return\n    \n    # Create settings table with original fields only\n    op.create_table('settings',\n    sa.Column('id', sa.Integer(), nullable=False),\n    sa.Column('ai_provider_format', sa.String(length=20), nullable=False),\n    sa.Column('api_base_url', sa.String(length=500), nullable=True),\n    sa.Column('api_key', sa.String(length=500), nullable=True),\n    sa.Column('image_resolution', sa.String(length=20), nullable=False),\n    sa.Column('image_aspect_ratio', sa.String(length=10), nullable=False),\n    sa.Column('max_description_workers', sa.Integer(), nullable=False),\n    sa.Column('max_image_workers', sa.Integer(), nullable=False),\n    sa.Column('created_at', sa.DateTime(), nullable=False),\n    sa.Column('updated_at', sa.DateTime(), nullable=False),\n    sa.PrimaryKeyConstraint('id')\n    )\n\n\ndef downgrade() -> None:\n    op.drop_table('settings')\n\n"
  },
  {
    "path": "backend/migrations/versions/003_add_model_and_mineru_settings.py",
    "content": "\"\"\"add model and mineru settings to settings table\n\nRevision ID: 003_new_fields\nRevises: 002_settings\nCreate Date: 2025-12-17 22:02:00.000000\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\nfrom sqlalchemy import inspect\n\n\n# revision identifiers, used by Alembic.\nrevision = '003_new_fields'\ndown_revision = '002_settings'\nbranch_labels = None\ndepends_on = None\n\n\ndef _column_exists(table_name: str, column_name: str) -> bool:\n    \"\"\"检查列是否存在\"\"\"\n    bind = op.get_bind()\n    inspector = inspect(bind)\n    columns = [col['name'] for col in inspector.get_columns(table_name)]\n    return column_name in columns\n\n\ndef upgrade() -> None:\n    \"\"\"\n    Add new model and MinerU configuration fields to settings table.\n    \n    Idempotent: checks each column before adding.\n    \"\"\"\n    # Add text_model column if not exists\n    if not _column_exists('settings', 'text_model'):\n        op.add_column('settings', sa.Column('text_model', sa.String(length=100), nullable=True))\n    \n    # Add image_model column if not exists\n    if not _column_exists('settings', 'image_model'):\n        op.add_column('settings', sa.Column('image_model', sa.String(length=100), nullable=True))\n    \n    # Add mineru_api_base column if not exists\n    if not _column_exists('settings', 'mineru_api_base'):\n        op.add_column('settings', sa.Column('mineru_api_base', sa.String(length=255), nullable=True))\n    \n    # Add image_caption_model column if not exists\n    if not _column_exists('settings', 'image_caption_model'):\n        op.add_column('settings', sa.Column('image_caption_model', sa.String(length=100), nullable=True))\n\n\ndef downgrade() -> None:\n    op.drop_column('settings', 'image_caption_model')\n    op.drop_column('settings', 'mineru_api_base')\n    op.drop_column('settings', 'image_model')\n    op.drop_column('settings', 'text_model')\n\n"
  },
  {
    "path": "backend/migrations/versions/004_add_template_style_to_projects.py",
    "content": "\"\"\"add template_style to projects\n\nRevision ID: 004_add_template_style\nRevises: 38292967f3ca\nCreate Date: 2025-12-27 00:00:00.000000\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision = '004_add_template_style'\ndown_revision = '38292967f3ca'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    \"\"\"\n    Add template_style field to projects table.\n    This field stores the style description when user chooses template-free mode.\n    \"\"\"\n    # Add template_style column (nullable, defaults to None)\n    op.add_column('projects', sa.Column('template_style', sa.Text(), nullable=True))\n\n\ndef downgrade() -> None:\n    \"\"\"\n    Remove template_style field from projects table.\n    \"\"\"\n    op.drop_column('projects', 'template_style')\n\n"
  },
  {
    "path": "backend/migrations/versions/005_add_pdf_image_path.py",
    "content": "\"\"\"add pdf_image_path placeholder (migration file was lost)\n\nRevision ID: 005_add_pdf_image_path\nRevises: 004_add_template_style\nCreate Date: 2025-01-04 00:00:00.000000\n\nNote: This is a placeholder migration. The original migration file was lost,\nbut the migration was already applied to the database. This file exists to\nmaintain the migration chain integrity.\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision = '005_add_pdf_image_path'\ndown_revision = '004_add_template_style'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    \"\"\"\n    Placeholder - the actual migration was already applied.\n    \"\"\"\n    pass\n\n\ndef downgrade() -> None:\n    \"\"\"\n    Placeholder - original downgrade logic unknown.\n    \"\"\"\n    pass\n\n\n\n"
  },
  {
    "path": "backend/migrations/versions/006_add_export_settings_to_projects.py",
    "content": "\"\"\"add export settings to projects\n\nRevision ID: 006_add_export_settings\nRevises: 005_add_pdf_image_path\nCreate Date: 2025-01-04 00:00:00.000000\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision = '006_add_export_settings'\ndown_revision = '005_add_pdf_image_path'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    \"\"\"\n    Add export settings fields to projects table.\n    - export_extractor_method: Component extraction method (mineru, hybrid)\n    - export_inpaint_method: Background generation method (generative, baidu, hybrid)\n    \"\"\"\n    # Add export_extractor_method column (nullable, defaults to 'hybrid')\n    op.add_column('projects', sa.Column('export_extractor_method', sa.String(50), nullable=True, server_default='hybrid'))\n    \n    # Add export_inpaint_method column (nullable, defaults to 'hybrid')\n    op.add_column('projects', sa.Column('export_inpaint_method', sa.String(50), nullable=True, server_default='hybrid'))\n\n\ndef downgrade() -> None:\n    \"\"\"\n    Remove export settings fields from projects table.\n    \"\"\"\n    op.drop_column('projects', 'export_inpaint_method')\n    op.drop_column('projects', 'export_extractor_method')\n\n\n\n"
  },
  {
    "path": "backend/migrations/versions/007_add_enable_reasoning_to_settings.py",
    "content": "\"\"\"add enable_reasoning to settings\n\nRevision ID: 007_add_enable_reasoning\nRevises: 006_add_export_settings\nCreate Date: 2025-01-17 00:00:00.000000\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\nfrom sqlalchemy import inspect\n\n\n# revision identifiers, used by Alembic.\nrevision = '007_add_enable_reasoning'\ndown_revision = '006_add_export_settings'\nbranch_labels = None\ndepends_on = None\n\n\ndef _column_exists(table_name: str, column_name: str) -> bool:\n    \"\"\"Check if column exists\"\"\"\n    bind = op.get_bind()\n    inspector = inspect(bind)\n    columns = [col['name'] for col in inspector.get_columns(table_name)]\n    return column_name in columns\n\n\ndef upgrade() -> None:\n    \"\"\"\n    Add enable_reasoning column to settings table with default value False.\n    \n    This setting controls whether AI models should use extended thinking/reasoning mode.\n    When enabled, supported models will use deeper reasoning which may improve quality\n    but increases response time and token consumption.\n    \n    Idempotent: checks if column exists before adding.\n    \"\"\"\n    if not _column_exists('settings', 'enable_reasoning'):\n        op.add_column('settings', sa.Column('enable_reasoning', sa.Boolean(), nullable=False, server_default='0'))\n\n\ndef downgrade() -> None:\n    \"\"\"\n    Remove enable_reasoning column from settings table.\n    \"\"\"\n    op.drop_column('settings', 'enable_reasoning')\n"
  },
  {
    "path": "backend/migrations/versions/008_add_baidu_ocr_api_key_to_settings.py",
    "content": "\"\"\"add baidu_ocr_api_key to settings\n\nRevision ID: 008_add_baidu_ocr_api_key\nRevises: 007_add_enable_reasoning\nCreate Date: 2026-01-17 00:00:00.000000\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\nfrom sqlalchemy import inspect\n\n\n# revision identifiers, used by Alembic.\nrevision = '008_add_baidu_ocr_api_key'\ndown_revision = '007_add_enable_reasoning'\nbranch_labels = None\ndepends_on = None\n\n\ndef _column_exists(table_name: str, column_name: str) -> bool:\n    \"\"\"Check if column exists\"\"\"\n    bind = op.get_bind()\n    inspector = inspect(bind)\n    columns = [col['name'] for col in inspector.get_columns(table_name)]\n    return column_name in columns\n\n\ndef upgrade() -> None:\n    \"\"\"\n    Add baidu_ocr_api_key column to settings table.\n    \n    This setting stores the Baidu OCR API Key used for text recognition\n    in editable PPTX export functionality.\n    \n    Idempotent: checks if column exists before adding.\n    \"\"\"\n    if not _column_exists('settings', 'baidu_ocr_api_key'):\n        op.add_column('settings', sa.Column('baidu_ocr_api_key', sa.String(500), nullable=True))\n\n\ndef downgrade() -> None:\n    \"\"\"\n    Remove baidu_ocr_api_key column from settings table.\n    \"\"\"\n    op.drop_column('settings', 'baidu_ocr_api_key')\n"
  },
  {
    "path": "backend/migrations/versions/009_split_reasoning_config.py",
    "content": "\"\"\"split reasoning config into text and image\n\nRevision ID: 009_split_reasoning_config\nRevises: 007_add_enable_reasoning\nCreate Date: 2026-01-17 00:00:00.000000\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\nfrom sqlalchemy import inspect\n\n\n# revision identifiers, used by Alembic.\nrevision = '009_split_reasoning_config'\ndown_revision = '008_add_baidu_ocr_api_key'\nbranch_labels = None\ndepends_on = None\n\n\ndef _column_exists(table_name: str, column_name: str) -> bool:\n    \"\"\"Check if column exists\"\"\"\n    bind = op.get_bind()\n    inspector = inspect(bind)\n    columns = [col['name'] for col in inspector.get_columns(table_name)]\n    return column_name in columns\n\n\ndef upgrade() -> None:\n    \"\"\"\n    Split enable_reasoning into separate text and image reasoning configs.\n    \n    - enable_text_reasoning: whether to enable reasoning for text generation\n    - text_thinking_budget: thinking budget for text (1-8192)\n    - enable_image_reasoning: whether to enable reasoning for image generation\n    - image_thinking_budget: thinking budget for image (1-8192)\n    \n    Migrate existing enable_reasoning value to both new text and image flags.\n    \"\"\"\n    # Add new columns\n    if not _column_exists('settings', 'enable_text_reasoning'):\n        op.add_column('settings', sa.Column('enable_text_reasoning', sa.Boolean(), nullable=False, server_default='0'))\n    \n    if not _column_exists('settings', 'text_thinking_budget'):\n        op.add_column('settings', sa.Column('text_thinking_budget', sa.Integer(), nullable=False, server_default='1024'))\n    \n    if not _column_exists('settings', 'enable_image_reasoning'):\n        op.add_column('settings', sa.Column('enable_image_reasoning', sa.Boolean(), nullable=False, server_default='0'))\n    \n    if not _column_exists('settings', 'image_thinking_budget'):\n        op.add_column('settings', sa.Column('image_thinking_budget', sa.Integer(), nullable=False, server_default='1024'))\n    \n    # Migrate existing enable_reasoning value to new columns\n    if _column_exists('settings', 'enable_reasoning'):\n        # Copy enable_reasoning value to both new text and image flags\n        op.execute(\"\"\"\n            UPDATE settings \n            SET enable_text_reasoning = enable_reasoning,\n                enable_image_reasoning = enable_reasoning\n        \"\"\")\n        # Drop old column\n        op.drop_column('settings', 'enable_reasoning')\n\n\ndef downgrade() -> None:\n    \"\"\"\n    Revert to single enable_reasoning column.\n    \"\"\"\n    # Add back old column\n    if not _column_exists('settings', 'enable_reasoning'):\n        op.add_column('settings', sa.Column('enable_reasoning', sa.Boolean(), nullable=False, server_default='0'))\n    \n    # Migrate: if either text or image reasoning is enabled, set enable_reasoning to true\n    if _column_exists('settings', 'enable_text_reasoning'):\n        op.execute(\"\"\"\n            UPDATE settings \n            SET enable_reasoning = (enable_text_reasoning OR enable_image_reasoning)\n        \"\"\")\n    \n    # Drop new columns\n    if _column_exists('settings', 'enable_text_reasoning'):\n        op.drop_column('settings', 'enable_text_reasoning')\n    if _column_exists('settings', 'text_thinking_budget'):\n        op.drop_column('settings', 'text_thinking_budget')\n    if _column_exists('settings', 'enable_image_reasoning'):\n        op.drop_column('settings', 'enable_image_reasoning')\n    if _column_exists('settings', 'image_thinking_budget'):\n        op.drop_column('settings', 'image_thinking_budget')\n"
  },
  {
    "path": "backend/migrations/versions/010_add_cached_image_path.py",
    "content": "\"\"\"add cached_image_path to pages\n\nRevision ID: 010_add_cached_image_path\nRevises: 009_split_reasoning_config\nCreate Date: 2026-01-18 00:00:00.000000\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision = '010_add_cached_image_path'\ndown_revision = '009_split_reasoning_config'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    op.add_column('pages', sa.Column('cached_image_path', sa.String(500), nullable=True))\n\n\ndef downgrade() -> None:\n    op.drop_column('pages', 'cached_image_path')\n"
  },
  {
    "path": "backend/migrations/versions/011_add_user_template_thumb.py",
    "content": "\"\"\"Add thumb_path to user_templates table\n\nRevision ID: 011_add_user_template_thumb\nRevises: 010_add_cached_image_path\nCreate Date: 2025-01-18\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision = '011_add_user_template_thumb'\ndown_revision = '010_add_cached_image_path'\nbranch_labels = None\ndepends_on = None\n\n\ndef generate_user_template_thumbnails():\n    \"\"\"Generate thumbnails for existing user templates\"\"\"\n    import os\n    from pathlib import Path\n\n    # Get upload folder - use parent directory's uploads folder\n    script_dir = Path(__file__).resolve().parent\n    project_root = script_dir.parent.parent.parent  # migrations/versions -> migrations -> backend -> project\n    upload_folder = os.environ.get('UPLOAD_FOLDER', str(project_root / 'uploads'))\n\n    try:\n        from PIL import Image\n    except ImportError:\n        print(\"PIL not available, skipping thumbnail generation\")\n        return\n\n    # Get database connection\n    connection = op.get_bind()\n\n    # Query user templates without thumbnail\n    result = connection.execute(\n        sa.text(\"\"\"\n            SELECT id, file_path\n            FROM user_templates\n            WHERE thumb_path IS NULL\n            AND file_path IS NOT NULL\n        \"\"\")\n    )\n    templates = result.fetchall()\n\n    print(f\"Generating thumbnails for {len(templates)} user templates...\")\n\n    for template_id, file_path in templates:\n        try:\n            # Open original image\n            original_path = Path(upload_folder) / file_path.replace('\\\\', '/')\n            if not original_path.exists():\n                print(f\"  Skipped {template_id}: file not found\")\n                continue\n\n            image = Image.open(str(original_path))\n\n            # Resize if too large (600px for template thumbnails)\n            max_width = 600\n            if image.width > max_width:\n                ratio = max_width / image.width\n                new_height = int(image.height * ratio)\n                image = image.resize((max_width, new_height), Image.Resampling.LANCZOS)\n\n            # Convert to RGB\n            if image.mode in ('RGBA', 'LA', 'P'):\n                background = Image.new('RGB', image.size, (255, 255, 255))\n                if image.mode == 'P':\n                    image = image.convert('RGBA')\n                if image.mode in ('RGBA', 'LA'):\n                    background.paste(image, mask=image.split()[-1])\n                else:\n                    background.paste(image)\n                image = background\n            elif image.mode != 'RGB':\n                image = image.convert('RGB')\n\n            # Save thumbnail\n            thumb_filename = \"template-thumb.webp\"\n            thumb_dir = Path(upload_folder) / \"user-templates\" / template_id\n            thumb_dir.mkdir(parents=True, exist_ok=True)\n            thumb_full_path = thumb_dir / thumb_filename\n            thumb_relative_path = f\"user-templates/{template_id}/{thumb_filename}\"\n\n            image.save(str(thumb_full_path), 'WEBP', quality=80)\n            image.close()\n\n            # Update database\n            connection.execute(\n                sa.text(\"UPDATE user_templates SET thumb_path = :path WHERE id = :id\"),\n                {\"path\": thumb_relative_path, \"id\": template_id}\n            )\n            print(f\"  Generated: {thumb_relative_path}\")\n\n        except Exception as e:\n            print(f\"  Failed for template {template_id}: {e}\")\n            continue\n\n    print(\"User template thumbnail generation complete\")\n\n\ndef generate_page_thumbnails():\n    \"\"\"Generate thumbnails for existing pages (in case 010 ran without this)\"\"\"\n    import os\n    from pathlib import Path\n\n    # Get upload folder - use parent directory's uploads folder\n    script_dir = Path(__file__).resolve().parent\n    project_root = script_dir.parent.parent.parent  # migrations/versions -> migrations -> backend -> project\n    upload_folder = os.environ.get('UPLOAD_FOLDER', str(project_root / 'uploads'))\n\n    try:\n        from PIL import Image\n    except ImportError:\n        print(\"PIL not available, skipping page thumbnail generation\")\n        return\n\n    connection = op.get_bind()\n\n    result = connection.execute(\n        sa.text(\"\"\"\n            SELECT id, project_id, generated_image_path\n            FROM pages\n            WHERE generated_image_path IS NOT NULL\n            AND cached_image_path IS NULL\n        \"\"\")\n    )\n    pages = result.fetchall()\n\n    if not pages:\n        print(\"No pages need thumbnail generation\")\n        return\n\n    print(f\"Generating thumbnails for {len(pages)} pages...\")\n\n    for page_id, project_id, image_path in pages:\n        try:\n            # Generate thumbnail filename based on original filename\n            original_filename = Path(image_path).stem  # e.g., \"page_id_timestamp\" or \"page_id_v1\"\n            thumb_filename = f\"{original_filename}_thumb.jpg\"\n            thumb_relative_path = f\"{project_id}/pages/{thumb_filename}\"\n            thumb_full_path = Path(upload_folder) / thumb_relative_path\n\n            if thumb_full_path.exists():\n                connection.execute(\n                    sa.text(\"UPDATE pages SET cached_image_path = :path WHERE id = :id\"),\n                    {\"path\": thumb_relative_path, \"id\": page_id}\n                )\n                continue\n\n            original_path = Path(upload_folder) / image_path.replace('\\\\', '/')\n            if not original_path.exists():\n                print(f\"  Skipped {page_id}: file not found\")\n                continue\n\n            image = Image.open(str(original_path))\n\n            max_width = 1920\n            if image.width > max_width:\n                ratio = max_width / image.width\n                image = image.resize((max_width, int(image.height * ratio)), Image.Resampling.LANCZOS)\n\n            if image.mode in ('RGBA', 'LA', 'P'):\n                background = Image.new('RGB', image.size, (255, 255, 255))\n                if image.mode == 'P':\n                    image = image.convert('RGBA')\n                if image.mode in ('RGBA', 'LA'):\n                    background.paste(image, mask=image.split()[-1])\n                else:\n                    background.paste(image)\n                image = background\n            elif image.mode != 'RGB':\n                image = image.convert('RGB')\n\n            thumb_full_path.parent.mkdir(parents=True, exist_ok=True)\n            image.save(str(thumb_full_path), 'JPEG', quality=85, optimize=True)\n            image.close()\n\n            connection.execute(\n                sa.text(\"UPDATE pages SET cached_image_path = :path WHERE id = :id\"),\n                {\"path\": thumb_relative_path, \"id\": page_id}\n            )\n            print(f\"  Generated: {thumb_relative_path}\")\n\n        except Exception as e:\n            print(f\"  Failed for page {page_id}: {e}\")\n\n    print(\"Page thumbnail generation complete\")\n\n\ndef upgrade():\n    # Add thumb_path column to user_templates table\n    op.add_column('user_templates', sa.Column('thumb_path', sa.String(500), nullable=True))\n\n    # Generate thumbnails for existing user templates\n    generate_user_template_thumbnails()\n\n    # Also generate page thumbnails if 010 migration ran without the auto-generation\n    generate_page_thumbnails()\n\n\ndef downgrade():\n    # Remove thumb_path column from user_templates table\n    op.drop_column('user_templates', 'thumb_path')\n"
  },
  {
    "path": "backend/migrations/versions/012_add_export_allow_partial_to_projects.py",
    "content": "\"\"\"add export_allow_partial to projects table\n\nRevision ID: 012\nRevises: 011_add_user_template_thumb\nCreate Date: 2025-01-29\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision = '012'\ndown_revision = '011_add_user_template_thumb'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade():\n    # Add export_allow_partial column to projects table\n    op.add_column('projects', sa.Column('export_allow_partial', sa.Boolean(), nullable=True, server_default='0'))\n    # 为现有行设置默认值 false，避免 NULL 状态\n    op.execute(\"UPDATE projects SET export_allow_partial = false WHERE export_allow_partial IS NULL\")\n\n\ndef downgrade():\n    op.drop_column('projects', 'export_allow_partial')\n"
  },
  {
    "path": "backend/migrations/versions/013_add_lazyllm_source_fields.py",
    "content": "\"\"\"add lazyllm source fields to settings table\n\nRevision ID: 013\nRevises: 012\nCreate Date: 2026-02-13\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision = '013'\ndown_revision = '012'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade():\n    op.add_column('settings', sa.Column('text_model_source', sa.String(50), nullable=True))\n    op.add_column('settings', sa.Column('image_model_source', sa.String(50), nullable=True))\n    op.add_column('settings', sa.Column('image_caption_model_source', sa.String(50), nullable=True))\n    op.add_column('settings', sa.Column('lazyllm_api_keys', sa.Text(), nullable=True))\n\n\ndef downgrade():\n    op.drop_column('settings', 'lazyllm_api_keys')\n    op.drop_column('settings', 'image_caption_model_source')\n    op.drop_column('settings', 'image_model_source')\n    op.drop_column('settings', 'text_model_source')\n"
  },
  {
    "path": "backend/migrations/versions/014_add_per_model_provider_config.py",
    "content": "\"\"\"add per-model provider config fields to settings table\n\nRevision ID: 014\nRevises: 013\nCreate Date: 2026-02-16\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision = '014'\ndown_revision = 'ee22f1512027'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade():\n    op.add_column('settings', sa.Column('text_api_key', sa.String(500), nullable=True))\n    op.add_column('settings', sa.Column('text_api_base_url', sa.String(500), nullable=True))\n    op.add_column('settings', sa.Column('image_api_key', sa.String(500), nullable=True))\n    op.add_column('settings', sa.Column('image_api_base_url', sa.String(500), nullable=True))\n    op.add_column('settings', sa.Column('image_caption_api_key', sa.String(500), nullable=True))\n    op.add_column('settings', sa.Column('image_caption_api_base_url', sa.String(500), nullable=True))\n\n\ndef downgrade():\n    op.drop_column('settings', 'image_caption_api_base_url')\n    op.drop_column('settings', 'image_caption_api_key')\n    op.drop_column('settings', 'image_api_base_url')\n    op.drop_column('settings', 'image_api_key')\n    op.drop_column('settings', 'text_api_base_url')\n    op.drop_column('settings', 'text_api_key')\n"
  },
  {
    "path": "backend/migrations/versions/015_rename_baidu_ocr_api_key.py",
    "content": "\"\"\"rename baidu_ocr_api_key to baidu_api_key\n\nRevision ID: 015\nRevises: 7acf21d5e41d\nCreate Date: 2026-02-26\n\n\"\"\"\nfrom alembic import op\n\n\n# revision identifiers, used by Alembic.\nrevision = '015'\ndown_revision = '7acf21d5e41d'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade():\n    with op.batch_alter_table('settings') as batch_op:\n        batch_op.alter_column('baidu_ocr_api_key', new_column_name='baidu_api_key')\n\n\ndef downgrade():\n    with op.batch_alter_table('settings') as batch_op:\n        batch_op.alter_column('baidu_api_key', new_column_name='baidu_ocr_api_key')\n"
  },
  {
    "path": "backend/migrations/versions/38292967f3ca_add_output_language_to_settings_table.py",
    "content": "\"\"\"add output_language to settings table\n\nRevision ID: 38292967f3ca\nRevises: a912a64b7a86\nCreate Date: 2025-12-17 22:26:19.564663\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\nfrom sqlalchemy import inspect\n\n\n# revision identifiers, used by Alembic.\nrevision = '38292967f3ca'\ndown_revision = 'a912a64b7a86'\nbranch_labels = None\ndepends_on = None\n\n\ndef _column_exists(table_name: str, column_name: str) -> bool:\n    \"\"\"Check if column exists\"\"\"\n    bind = op.get_bind()\n    inspector = inspect(bind)\n    columns = [col['name'] for col in inspector.get_columns(table_name)]\n    return column_name in columns\n\n\ndef upgrade() -> None:\n    \"\"\"\n    Add output_language column to settings table with default value.\n    \n    Idempotent: checks if column exists before adding.\n    \"\"\"\n    # ### commands auto generated by Alembic - please adjust! ###\n    if not _column_exists('settings', 'output_language'):\n        op.add_column('settings', sa.Column('output_language', sa.String(length=10), nullable=False, server_default='zh'))\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.drop_column('settings', 'output_language')\n    # ### end Alembic commands ###\n\n\n\n"
  },
  {
    "path": "backend/migrations/versions/64ecc9f34de0_add_description_generation_mode_to_.py",
    "content": "\"\"\"add description_generation_mode to settings\n\nRevision ID: 64ecc9f34de0\nRevises: 88054bda1ece\nCreate Date: 2026-03-01 22:23:58.171031\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision = '64ecc9f34de0'\ndown_revision = '88054bda1ece'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.add_column('settings', sa.Column('description_generation_mode', sa.String(length=20), nullable=True))\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.drop_column('settings', 'description_generation_mode')\n    # ### end Alembic commands ###\n\n\n\n"
  },
  {
    "path": "backend/migrations/versions/7acf21d5e41d_make_settings_columns_nullable_for_env_.py",
    "content": "\"\"\"make settings columns nullable for env fallback\n\nRevision ID: 7acf21d5e41d\nRevises: 014\nCreate Date: 2026-02-23 14:22:40.719334\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision = '7acf21d5e41d'\ndown_revision = '014'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    with op.batch_alter_table('settings') as batch_op:\n        batch_op.alter_column('ai_provider_format',\n                   existing_type=sa.VARCHAR(length=20), nullable=True)\n        batch_op.alter_column('image_resolution',\n                   existing_type=sa.VARCHAR(length=20), nullable=True)\n        batch_op.alter_column('image_aspect_ratio',\n                   existing_type=sa.VARCHAR(length=10), nullable=True)\n        batch_op.alter_column('max_description_workers',\n                   existing_type=sa.INTEGER(), nullable=True)\n        batch_op.alter_column('max_image_workers',\n                   existing_type=sa.INTEGER(), nullable=True)\n        batch_op.alter_column('output_language',\n                   existing_type=sa.VARCHAR(length=10), nullable=True)\n\n\ndef downgrade() -> None:\n    with op.batch_alter_table('settings') as batch_op:\n        batch_op.alter_column('output_language',\n                   existing_type=sa.VARCHAR(length=10), nullable=False)\n        batch_op.alter_column('max_image_workers',\n                   existing_type=sa.INTEGER(), nullable=False)\n        batch_op.alter_column('max_description_workers',\n                   existing_type=sa.INTEGER(), nullable=False)\n        batch_op.alter_column('image_aspect_ratio',\n                   existing_type=sa.VARCHAR(length=10), nullable=False)\n        batch_op.alter_column('image_resolution',\n                   existing_type=sa.VARCHAR(length=20), nullable=False)\n        batch_op.alter_column('ai_provider_format',\n                   existing_type=sa.VARCHAR(length=20), nullable=False)\n\n\n\n"
  },
  {
    "path": "backend/migrations/versions/88054bda1ece_add_outline_and_description_.py",
    "content": "\"\"\"add outline and description requirements to projects\n\nRevision ID: 88054bda1ece\nRevises: 015\nCreate Date: 2026-03-01 21:57:23.450061\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision = '88054bda1ece'\ndown_revision = '015'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.add_column('projects', sa.Column('outline_requirements', sa.Text(), nullable=True))\n    op.add_column('projects', sa.Column('description_requirements', sa.Text(), nullable=True))\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.drop_column('projects', 'description_requirements')\n    op.drop_column('projects', 'outline_requirements')\n    # ### end Alembic commands ###\n\n\n\n"
  },
  {
    "path": "backend/migrations/versions/9439faddcdd5_add_description_extra_fields_to_settings.py",
    "content": "\"\"\"add description_extra_fields to settings\n\nRevision ID: 9439faddcdd5\nRevises: 64ecc9f34de0\nCreate Date: 2026-03-03 00:22:54.296186\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision = '9439faddcdd5'\ndown_revision = '64ecc9f34de0'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.add_column('settings', sa.Column('description_extra_fields', sa.Text(), nullable=True))\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.drop_column('settings', 'description_extra_fields')\n    # ### end Alembic commands ###\n\n\n\n"
  },
  {
    "path": "backend/migrations/versions/9ad736fec43d_add_image_prompt_extra_fields_to_.py",
    "content": "\"\"\"add image_prompt_extra_fields to settings\n\nRevision ID: 9ad736fec43d\nRevises: 9439faddcdd5\nCreate Date: 2026-03-04 21:52:36.488053\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision = '9ad736fec43d'\ndown_revision = '9439faddcdd5'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.add_column('settings', sa.Column('image_prompt_extra_fields', sa.Text(), nullable=True))\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.drop_column('settings', 'image_prompt_extra_fields')\n    # ### end Alembic commands ###\n\n\n\n"
  },
  {
    "path": "backend/migrations/versions/a912a64b7a86_add_mineru_token_to_settings_table.py",
    "content": "\"\"\"add mineru_token to settings table\n\nRevision ID: a912a64b7a86\nRevises: 003_new_fields\nCreate Date: 2025-12-17 22:07:23.174881\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\nfrom sqlalchemy import inspect\n\n\n# revision identifiers, used by Alembic.\nrevision = 'a912a64b7a86'\ndown_revision = '003_new_fields'\nbranch_labels = None\ndepends_on = None\n\n\ndef _column_exists(table_name: str, column_name: str) -> bool:\n    \"\"\"Check if column exists\"\"\"\n    bind = op.get_bind()\n    inspector = inspect(bind)\n    columns = [col['name'] for col in inspector.get_columns(table_name)]\n    return column_name in columns\n\n\ndef upgrade() -> None:\n    \"\"\"\n    Add mineru_token column to settings table.\n    \n    Idempotent: checks if column exists before adding.\n    \"\"\"\n    # ### commands auto generated by Alembic - please adjust! ###\n    if not _column_exists('settings', 'mineru_token'):\n        op.add_column('settings', sa.Column('mineru_token', sa.String(length=500), nullable=True))\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.drop_column('settings', 'mineru_token')\n    # ### end Alembic commands ###\n\n\n\n"
  },
  {
    "path": "backend/migrations/versions/ee22f1512027_add_image_aspect_ratio_to_project.py",
    "content": "\"\"\"add image_aspect_ratio to project\n\nRevision ID: ee22f1512027\nRevises: 013\nCreate Date: 2026-02-14 01:58:15.948064\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision = 'ee22f1512027'\ndown_revision = '013'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    op.add_column('projects', sa.Column('image_aspect_ratio', sa.String(length=10), server_default='16:9', nullable=False))\n\n\ndef downgrade() -> None:\n    op.drop_column('projects', 'image_aspect_ratio')\n"
  },
  {
    "path": "backend/models/__init__.py",
    "content": "\"\"\"Database models package\"\"\"\nfrom flask_sqlalchemy import SQLAlchemy\n\n# 创建 SQLAlchemy 实例，配置 SQLite 连接选项\ndb = SQLAlchemy(\n    engine_options={\n        'connect_args': {\n            'check_same_thread': False,  # 允许跨线程使用（仅SQLite）\n            'timeout': 30,  # 数据库锁定超时（秒）- SQLite特定\n        },\n        'pool_pre_ping': True,  # 连接前检查，确保连接有效\n        'pool_recycle': 3600,  # 1小时回收连接，释放文件句柄\n        'pool_size': 5,  # SQLite连接池不需要太大（建议5-10）\n        'max_overflow': 10,  # 溢出连接数（SQLite受文件锁限制，不宜过大）\n        'pool_timeout': 30,  # 获取连接的超时时间（秒）\n    }\n)\n\nfrom .project import Project\nfrom .page import Page\nfrom .task import Task\nfrom .user_template import UserTemplate\nfrom .page_image_version import PageImageVersion\nfrom .material import Material\nfrom .reference_file import ReferenceFile\nfrom .settings import Settings\n\n__all__ = ['db', 'Project', 'Page', 'Task', 'UserTemplate', 'PageImageVersion', 'Material', 'ReferenceFile', 'Settings']\n\n"
  },
  {
    "path": "backend/models/material.py",
    "content": "\"\"\"\nMaterial model - stores material images\n\"\"\"\nimport uuid\nfrom datetime import datetime\nfrom . import db\n\n\nclass Material(db.Model):\n    \"\"\"\n    Material model - represents a material image\n    \"\"\"\n    __tablename__ = 'materials'\n    \n    id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))\n    project_id = db.Column(db.String(36), db.ForeignKey('projects.id'), nullable=True)  # Can be null, for global materials not belonging to a project\n    filename = db.Column(db.String(500), nullable=False)\n    relative_path = db.Column(db.String(500), nullable=False)  # Path relative to the upload_folder\n    url = db.Column(db.String(500), nullable=False)  # URL accessible by the frontend\n    created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)\n    updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)\n    \n    # Relationships\n    project = db.relationship('Project', back_populates='materials')\n    \n    def to_dict(self):\n        \"\"\"Convert to dictionary\"\"\"\n        return {\n            'id': self.id,\n            'project_id': self.project_id,\n            'filename': self.filename,\n            'url': self.url,\n            'relative_path': self.relative_path,\n            'created_at': self.created_at.isoformat() if self.created_at else None,\n            'updated_at': self.updated_at.isoformat() if self.updated_at else None,\n        }\n    \n    def __repr__(self):\n        return f'<Material {self.id}: {self.filename} (project={self.project_id or \"None\"})>'\n\n"
  },
  {
    "path": "backend/models/page.py",
    "content": "\"\"\"\nPage model\n\"\"\"\nimport uuid\nimport json\nfrom pathlib import Path\nfrom datetime import datetime\nfrom . import db\n\n\nclass Page(db.Model):\n    \"\"\"\n    Page model - represents a single PPT page/slide\n    \"\"\"\n    __tablename__ = 'pages'\n    \n    id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))\n    project_id = db.Column(db.String(36), db.ForeignKey('projects.id'), nullable=False)\n    order_index = db.Column(db.Integer, nullable=False)\n    part = db.Column(db.String(200), nullable=True)  # Optional section name\n    outline_content = db.Column(db.Text, nullable=True)  # JSON string\n    description_content = db.Column(db.Text, nullable=True)  # JSON string\n    generated_image_path = db.Column(db.String(500), nullable=True)  # Original PNG image path\n    cached_image_path = db.Column(db.String(500), nullable=True)  # Compressed JPG thumbnail path\n    status = db.Column(db.String(50), nullable=False, default='DRAFT')\n    created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)\n    updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)\n    \n    # Relationships\n    project = db.relationship('Project', back_populates='pages')\n    image_versions = db.relationship('PageImageVersion', back_populates='page', \n                                     lazy='dynamic', cascade='all, delete-orphan',\n                                     order_by='PageImageVersion.version_number.desc()')\n    \n    def get_outline_content(self):\n        \"\"\"Parse outline_content from JSON string\"\"\"\n        if self.outline_content:\n            try:\n                return json.loads(self.outline_content)\n            except json.JSONDecodeError:\n                return None\n        return None\n    \n    def set_outline_content(self, data):\n        \"\"\"Set outline_content as JSON string\"\"\"\n        if data:\n            self.outline_content = json.dumps(data, ensure_ascii=False)\n        else:\n            self.outline_content = None\n    \n    def get_description_content(self):\n        \"\"\"Parse description_content from JSON string\"\"\"\n        if self.description_content:\n            try:\n                return json.loads(self.description_content)\n            except json.JSONDecodeError:\n                return None\n        return None\n    \n    def set_description_content(self, data):\n        \"\"\"Set description_content as JSON string\"\"\"\n        if data:\n            self.description_content = json.dumps(data, ensure_ascii=False)\n        else:\n            self.description_content = None\n    \n    def to_dict(self, include_versions=False):\n        \"\"\"Convert to dictionary\"\"\"\n        # Use cached image for frontend display, fallback to original if no cache\n        display_image_path = self.cached_image_path or self.generated_image_path\n        display_image_url = None\n        if display_image_path:\n            filename = Path(display_image_path).name\n            display_image_url = f'/files/{self.project_id}/pages/{filename}'\n\n        data = {\n            'page_id': self.id,\n            'order_index': self.order_index,\n            'part': self.part,\n            'outline_content': self.get_outline_content(),\n            'description_content': self.get_description_content(),\n            'generated_image_url': display_image_url,\n            'status': self.status,\n            'created_at': self.created_at.isoformat() if self.created_at else None,\n            'updated_at': self.updated_at.isoformat() if self.updated_at else None,\n        }\n\n        if include_versions:\n            data['image_versions'] = [v.to_dict() for v in self.image_versions.all()]\n\n        return data\n    \n    def __repr__(self):\n        return f'<Page {self.id}: {self.order_index} - {self.status}>'\n\n"
  },
  {
    "path": "backend/models/page_image_version.py",
    "content": "\"\"\"\nPage Image Version model - stores historical versions of generated images\n\"\"\"\nimport uuid\nfrom datetime import datetime\nfrom . import db\n\n\nclass PageImageVersion(db.Model):\n    \"\"\"\n    Page Image Version model - represents a historical version of a page's generated image\n    \"\"\"\n    __tablename__ = 'page_image_versions'\n    \n    id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))\n    page_id = db.Column(db.String(36), db.ForeignKey('pages.id'), nullable=False, index=True)\n    image_path = db.Column(db.String(500), nullable=False)\n    version_number = db.Column(db.Integer, nullable=False)  # 版本号，从1开始递增\n    is_current = db.Column(db.Boolean, nullable=False, default=False)  # 是否为当前使用的版本\n    created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)\n    \n    # Relationships\n    page = db.relationship('Page', back_populates='image_versions')\n    \n    def to_dict(self):\n        \"\"\"Convert to dictionary\"\"\"\n        # Get project_id from page relationship\n        project_id = self.page.project_id if self.page else None\n        # Format created_at with UTC timezone indicator for proper frontend parsing\n        created_at_str = None\n        if self.created_at:\n            # Add 'Z' suffix to indicate UTC timezone, so frontend can parse it correctly\n            created_at_str = self.created_at.isoformat() + 'Z' if not self.created_at.tzinfo else self.created_at.isoformat()\n        return {\n            'version_id': self.id,\n            'page_id': self.page_id,\n            'image_path': self.image_path,\n            'image_url': f'/files/{project_id}/pages/{self.image_path.split(\"/\")[-1]}' if self.image_path and project_id else None,\n            'version_number': self.version_number,\n            'is_current': self.is_current,\n            'created_at': created_at_str,\n        }\n    \n    def __repr__(self):\n        return f'<PageImageVersion {self.id}: page={self.page_id}, version={self.version_number}, current={self.is_current}>'\n\n"
  },
  {
    "path": "backend/models/project.py",
    "content": "\"\"\"\nProject model\n\"\"\"\nimport uuid\nfrom datetime import datetime\nfrom . import db\n\n\nclass Project(db.Model):\n    \"\"\"\n    Project model - represents a PPT project\n    \"\"\"\n    __tablename__ = 'projects'\n    \n    id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))\n    idea_prompt = db.Column(db.Text, nullable=True)\n    outline_text = db.Column(db.Text, nullable=True)  # 用户输入的大纲文本（用于outline类型）\n    description_text = db.Column(db.Text, nullable=True)  # 用户输入的描述文本（用于description类型）\n    extra_requirements = db.Column(db.Text, nullable=True)  # 额外要求，应用到每个页面的AI提示词\n    outline_requirements = db.Column(db.Text, nullable=True)  # 大纲生成要求\n    description_requirements = db.Column(db.Text, nullable=True)  # 页面描述生成要求\n    creation_type = db.Column(db.String(20), nullable=False, default='idea')  # idea|outline|descriptions\n    template_image_path = db.Column(db.String(500), nullable=True)\n    template_style = db.Column(db.Text, nullable=True)  # 风格描述文本（无模板图模式）\n    # 导出设置\n    export_extractor_method = db.Column(db.String(50), nullable=True, default='hybrid')  # 组件提取方法: mineru, hybrid\n    export_inpaint_method = db.Column(db.String(50), nullable=True, default='hybrid')  # 背景图获取方法: generative, baidu, hybrid\n    export_allow_partial = db.Column(db.Boolean, nullable=True, default=False)  # 是否允许返回半成品（导出出错时继续而非停止）\n    image_aspect_ratio = db.Column(db.String(10), nullable=False, server_default='16:9', default='16:9')\n    status = db.Column(db.String(50), nullable=False, default='DRAFT')\n    created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)\n    updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)\n    \n    # Relationships\n    # 使用 'select' 策略支持 eager loading，同时保持灵活性\n    pages = db.relationship('Page', back_populates='project', lazy='select', \n                           cascade='all, delete-orphan', order_by='Page.order_index')\n    tasks = db.relationship('Task', back_populates='project', lazy='select',\n                           cascade='all, delete-orphan')\n    materials = db.relationship('Material', back_populates='project', lazy='select',\n                           cascade='all, delete-orphan')\n    \n    def to_dict(self, include_pages=False):\n        \"\"\"Convert to dictionary\"\"\"\n        # Format created_at and updated_at with UTC timezone indicator for proper frontend parsing\n        created_at_str = None\n        if self.created_at:\n            created_at_str = self.created_at.isoformat() + 'Z' if not self.created_at.tzinfo else self.created_at.isoformat()\n        \n        updated_at_str = None\n        if self.updated_at:\n            updated_at_str = self.updated_at.isoformat() + 'Z' if not self.updated_at.tzinfo else self.updated_at.isoformat()\n        \n        data = {\n            'project_id': self.id,\n            'idea_prompt': self.idea_prompt,\n            'outline_text': self.outline_text,\n            'description_text': self.description_text,\n            'extra_requirements': self.extra_requirements,\n            'outline_requirements': self.outline_requirements,\n            'description_requirements': self.description_requirements,\n            'creation_type': self.creation_type,\n            'template_image_url': f'/files/{self.id}/template/{self.template_image_path.split(\"/\")[-1]}' if self.template_image_path else None,\n            'template_style': self.template_style,\n            'export_extractor_method': self.export_extractor_method or 'hybrid',\n            'export_inpaint_method': self.export_inpaint_method or 'hybrid',\n            'export_allow_partial': self.export_allow_partial or False,\n            'image_aspect_ratio': self.image_aspect_ratio,\n            'status': self.status,\n            'created_at': created_at_str,\n            'updated_at': updated_at_str,\n        }\n        \n        if include_pages:\n            # pages 现在是列表，不需要 order_by（已在 relationship 中定义）\n            data['pages'] = [page.to_dict() for page in self.pages]\n        \n        return data\n    \n    def __repr__(self):\n        return f'<Project {self.id}: {self.status}>'\n\n"
  },
  {
    "path": "backend/models/reference_file.py",
    "content": "\"\"\"\nReference File model - stores uploaded reference files and their parsed content\n\"\"\"\nimport uuid\nfrom datetime import datetime\nfrom . import db\n\n\nclass ReferenceFile(db.Model):\n    \"\"\"\n    Reference File model - represents an uploaded reference file\n    \"\"\"\n    __tablename__ = 'reference_files'\n    \n    id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))\n    project_id = db.Column(db.String(36), db.ForeignKey('projects.id'), nullable=True)  # Can be null for global files\n    filename = db.Column(db.String(500), nullable=False)\n    file_path = db.Column(db.String(500), nullable=False)  # Path relative to upload folder\n    file_size = db.Column(db.Integer, nullable=False)  # File size in bytes\n    file_type = db.Column(db.String(50), nullable=False)  # pdf, docx, pptx, etc.\n    parse_status = db.Column(db.String(50), nullable=False, default='pending')  # pending|parsing|completed|failed\n    markdown_content = db.Column(db.Text, nullable=True)  # Parsed markdown with enhanced image descriptions\n    error_message = db.Column(db.Text, nullable=True)  # Error message if parsing failed\n    mineru_batch_id = db.Column(db.String(100), nullable=True)  # Mineru service batch ID\n    created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)\n    updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)\n    \n    # Relationships\n    project = db.relationship('Project', backref='reference_files', foreign_keys=[project_id])\n    \n    def to_dict(self, include_content=True, include_failed_count=False):\n        \"\"\"\n        Convert to dictionary\n        \n        Args:\n            include_content: Whether to include markdown_content (can be large)\n            include_failed_count: Whether to calculate failed image count (can be slow)\n        \"\"\"\n        result = {\n            'id': self.id,\n            'project_id': self.project_id,\n            'filename': self.filename,\n            'file_size': self.file_size,\n            'file_type': self.file_type,\n            'parse_status': self.parse_status,\n            'error_message': self.error_message,\n            'created_at': self.created_at.isoformat() if self.created_at else None,\n            'updated_at': self.updated_at.isoformat() if self.updated_at else None,\n        }\n        \n        if include_content:\n            result['markdown_content'] = self.markdown_content\n        \n        # 只有明确要求且文件已解析完成时才计算失败数\n        if include_failed_count and self.parse_status == 'completed':\n            result['image_caption_failed_count'] = self.count_failed_image_captions()\n        \n        return result\n    \n    def count_failed_image_captions(self) -> int:\n        \"\"\"\n        Count images in markdown that don't have alt text (failed to generate captions)\n        \n        Returns:\n            Number of images without captions\n        \"\"\"\n        if not self.markdown_content:\n            return 0\n        \n        import re\n        # Match markdown images: ![alt](url)\n        pattern = r'!\\[(.*?)\\]\\([^\\)]+\\)'\n        matches = re.findall(pattern, self.markdown_content)\n        \n        # Count images with empty alt text\n        failed_count = sum(1 for alt_text in matches if not alt_text.strip())\n        return failed_count\n    \n    def __repr__(self):\n        return f'<ReferenceFile {self.id}: {self.filename} ({self.parse_status})>'\n\n"
  },
  {
    "path": "backend/models/settings.py",
    "content": "\"\"\"Settings model\"\"\"\nimport json\nfrom datetime import datetime, timezone\nfrom . import db\n\n\nclass Settings(db.Model):\n    \"\"\"\n    Settings model - stores global application settings\n    \"\"\"\n    __tablename__ = 'settings'\n\n    id = db.Column(db.Integer, primary_key=True, default=1)\n    ai_provider_format = db.Column(db.String(20), nullable=True)   # AI提供商格式: openai, gemini (NULL=use .env)\n    api_base_url = db.Column(db.String(500), nullable=True)        # API基础URL\n    api_key = db.Column(db.String(500), nullable=True)             # API密钥\n    image_resolution = db.Column(db.String(20), nullable=True)     # 图像清晰度: 1K, 2K, 4K (NULL=use .env)\n    image_aspect_ratio = db.Column(db.String(10), nullable=True)   # 图像比例: 16:9, 4:3, 1:1 (NULL=use .env)\n    max_description_workers = db.Column(db.Integer, nullable=True)  # 描述生成最大工作线程数 (NULL=use .env)\n    max_image_workers = db.Column(db.Integer, nullable=True)        # 图像生成最大工作线程数 (NULL=use .env)\n\n    # 新增：大模型与 MinerU 相关可视化配置（可在设置页中编辑）\n    text_model = db.Column(db.String(100), nullable=True)  # 文本大模型名称（覆盖 Config.TEXT_MODEL）\n    image_model = db.Column(db.String(100), nullable=True)  # 图片大模型名称（覆盖 Config.IMAGE_MODEL）\n    mineru_api_base = db.Column(db.String(255), nullable=True)  # MinerU 服务地址（覆盖 Config.MINERU_API_BASE）\n    mineru_token = db.Column(db.String(500), nullable=True)  # MinerU API Token（覆盖 Config.MINERU_TOKEN）\n    image_caption_model = db.Column(db.String(100), nullable=True)  # 图片识别模型（覆盖 Config.IMAGE_CAPTION_MODEL）\n    output_language = db.Column(db.String(10), nullable=True)  # 输出语言偏好（zh, en, ja, auto）(NULL=use .env)\n    \n    # 推理模式配置（分别控制文本和图像生成）\n    enable_text_reasoning = db.Column(db.Boolean, nullable=False, default=False)  # 文本生成是否开启推理\n    text_thinking_budget = db.Column(db.Integer, nullable=False, default=1024)  # 文本推理思考负载 (1-8192)\n    enable_image_reasoning = db.Column(db.Boolean, nullable=False, default=False)  # 图像生成是否开启推理\n    image_thinking_budget = db.Column(db.Integer, nullable=False, default=1024)  # 图像推理思考负载 (1-8192)\n    \n    # 描述生成模式: streaming / parallel (NULL=默认 streaming)\n    description_generation_mode = db.Column(db.String(20), nullable=True)\n\n    # 描述额外字段配置: JSON 数组如 [\"排版布局\", \"视觉素材\"] (NULL=默认 DEFAULT_EXTRA_FIELDS)\n    description_extra_fields = db.Column(db.Text, nullable=True)\n    image_prompt_extra_fields = db.Column(db.Text, nullable=True)  # JSON array: 哪些额外字段传入文生图 prompt\n\n    # 百度 API 配置\n    baidu_api_key = db.Column(db.String(500), nullable=True)  # 百度 API Key\n\n    # 每种模型类型的提供商配置（source 可选 gemini/openai/lazyllm厂商名，NULL=使用全局配置）\n    text_model_source = db.Column(db.String(50), nullable=True)           # 文本模型提供商 (gemini, openai, qwen, doubao, deepseek, ...)\n    image_model_source = db.Column(db.String(50), nullable=True)          # 图片模型提供商\n    image_caption_model_source = db.Column(db.String(50), nullable=True)  # 图片识别模型提供商\n    lazyllm_api_keys = db.Column(db.Text, nullable=True)                  # JSON: {\"qwen\": \"key1\", \"doubao\": \"key2\", ...}\n\n    # Per-model API 凭证（当 source 为 gemini/openai 时使用，NULL=使用全局 api_key/api_base_url）\n    text_api_key = db.Column(db.String(500), nullable=True)\n    text_api_base_url = db.Column(db.String(500), nullable=True)\n    image_api_key = db.Column(db.String(500), nullable=True)\n    image_api_base_url = db.Column(db.String(500), nullable=True)\n    image_caption_api_key = db.Column(db.String(500), nullable=True)\n    image_caption_api_base_url = db.Column(db.String(500), nullable=True)\n    \n    created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))\n    updated_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))\n\n    def _val(self, attr, defaults):\n        \"\"\"Return DB value, falling back to .env default when None.\"\"\"\n        v = getattr(self, attr)\n        return v if v is not None else defaults.get(attr)\n\n    DEFAULT_EXTRA_FIELDS = ['视觉元素', '视觉焦点', '排版布局', '演讲者备注']\n    DEFAULT_IMAGE_PROMPT_FIELDS = ['视觉元素', '视觉焦点', '排版布局']  # 演讲者备注默认不传入图片生成\n\n    def get_description_extra_fields(self):\n        \"\"\"Return parsed extra fields list.\"\"\"\n        if self.description_extra_fields:\n            try:\n                fields = json.loads(self.description_extra_fields)\n                if isinstance(fields, list):\n                    return fields\n            except (json.JSONDecodeError, TypeError):\n                pass\n        return list(self.DEFAULT_EXTRA_FIELDS)\n\n    def get_image_prompt_extra_fields(self):\n        \"\"\"Return parsed list of extra fields to include in image prompts.\"\"\"\n        if self.image_prompt_extra_fields:\n            try:\n                fields = json.loads(self.image_prompt_extra_fields)\n                if isinstance(fields, list):\n                    return fields\n            except (json.JSONDecodeError, TypeError):\n                pass\n        return list(self.DEFAULT_IMAGE_PROMPT_FIELDS)\n\n    def to_dict(self):\n        \"\"\"Convert to dictionary, merging .env defaults for None fields.\"\"\"\n        d = Settings._get_config_defaults()\n        api_key = self._val('api_key', d)\n        mineru_token = self._val('mineru_token', d)\n        baidu_api_key = self._val('baidu_api_key', d)\n        text_api_key = self._val('text_api_key', d)\n        image_api_key = self._val('image_api_key', d)\n        image_caption_api_key = self._val('image_caption_api_key', d)\n        return {\n            'id': self.id,\n            'ai_provider_format': self._val('ai_provider_format', d),\n            'api_base_url': self._val('api_base_url', d),\n            'api_key_length': len(api_key) if api_key else 0,\n            'image_resolution': self._val('image_resolution', d),\n            'image_aspect_ratio': self._val('image_aspect_ratio', d),\n            'max_description_workers': self._val('max_description_workers', d),\n            'max_image_workers': self._val('max_image_workers', d),\n            'text_model': self._val('text_model', d),\n            'image_model': self._val('image_model', d),\n            'mineru_api_base': self._val('mineru_api_base', d),\n            'mineru_token_length': len(mineru_token) if mineru_token else 0,\n            'image_caption_model': self._val('image_caption_model', d),\n            'output_language': self._val('output_language', d),\n            'description_generation_mode': self._val('description_generation_mode', d) or 'streaming',\n            'description_extra_fields': self.get_description_extra_fields(),\n            'image_prompt_extra_fields': self.get_image_prompt_extra_fields(),\n            'enable_text_reasoning': self.enable_text_reasoning,\n            'text_thinking_budget': self.text_thinking_budget,\n            'enable_image_reasoning': self.enable_image_reasoning,\n            'image_thinking_budget': self.image_thinking_budget,\n            'baidu_api_key_length': len(baidu_api_key) if baidu_api_key else 0,\n            'text_model_source': self._val('text_model_source', d),\n            'image_model_source': self._val('image_model_source', d),\n            'image_caption_model_source': self._val('image_caption_model_source', d),\n            'lazyllm_api_keys_info': self._get_lazyllm_api_keys_info(self._val('lazyllm_api_keys', d)),\n            'text_api_key_length': len(text_api_key) if text_api_key else 0,\n            'text_api_base_url': self._val('text_api_base_url', d),\n            'image_api_key_length': len(image_api_key) if image_api_key else 0,\n            'image_api_base_url': self._val('image_api_base_url', d),\n            'image_caption_api_key_length': len(image_caption_api_key) if image_caption_api_key else 0,\n            'image_caption_api_base_url': self._val('image_caption_api_base_url', d),\n            'created_at': self.created_at.isoformat() if self.created_at else None,\n            'updated_at': self.updated_at.isoformat() if self.updated_at else None,\n        }\n\n    def _get_lazyllm_api_keys_info(self, raw=None):\n        \"\"\"Return vendor names and key lengths (no plaintext) for frontend display.\"\"\"\n        data = raw if raw is not None else self.lazyllm_api_keys\n        if not data:\n            return {}\n        try:\n            keys = json.loads(data)\n            return {vendor: len(key) for vendor, key in keys.items() if key}\n        except (json.JSONDecodeError, TypeError):\n            return {}\n\n    def get_lazyllm_api_keys_dict(self):\n        \"\"\"Parse lazyllm_api_keys JSON into a dict.\"\"\"\n        if not self.lazyllm_api_keys:\n            return {}\n        try:\n            return json.loads(self.lazyllm_api_keys)\n        except (json.JSONDecodeError, TypeError):\n            return {}\n\n    @staticmethod\n    def _get_config_defaults():\n        \"\"\"Return a dict of default values from Config/env for settings fields.\"\"\"\n        from config import Config\n        from services.ai_providers.lazyllm_env import collect_env_lazyllm_api_keys\n\n        provider = (Config.AI_PROVIDER_FORMAT or '').lower()\n        if provider == 'openai':\n            api_base = Config.OPENAI_API_BASE or None\n            api_key = Config.OPENAI_API_KEY or None\n        elif provider == 'lazyllm':\n            api_base = None\n            api_key = None\n        else:\n            api_base = Config.GOOGLE_API_BASE or None\n            api_key = Config.GOOGLE_API_KEY or None\n\n        return {\n            'ai_provider_format': Config.AI_PROVIDER_FORMAT,\n            'api_base_url': api_base,\n            'api_key': api_key,\n            'image_resolution': Config.DEFAULT_RESOLUTION,\n            'image_aspect_ratio': Config.DEFAULT_ASPECT_RATIO,\n            'max_description_workers': Config.MAX_DESCRIPTION_WORKERS,\n            'max_image_workers': Config.MAX_IMAGE_WORKERS,\n            'text_model': Config.TEXT_MODEL,\n            'image_model': Config.IMAGE_MODEL,\n            'mineru_api_base': Config.MINERU_API_BASE,\n            'mineru_token': Config.MINERU_TOKEN,\n            'image_caption_model': Config.IMAGE_CAPTION_MODEL,\n            'output_language': Config.OUTPUT_LANGUAGE,\n            'baidu_api_key': Config.BAIDU_API_KEY or None,\n            'text_model_source': getattr(Config, 'TEXT_MODEL_SOURCE', None),\n            'image_model_source': getattr(Config, 'IMAGE_MODEL_SOURCE', None),\n            'image_caption_model_source': getattr(Config, 'IMAGE_CAPTION_MODEL_SOURCE', None),\n            'lazyllm_api_keys': collect_env_lazyllm_api_keys(),\n        }\n\n    @staticmethod\n    def get_settings():\n        \"\"\"\n        Get or create the single settings instance.\n\n        Returns the ORM object as-is from the database.  ``.env``\n        defaults for ``None`` fields are merged only at serialisation\n        time in ``to_dict()``, so this method has no write side-effects.\n        \"\"\"\n        settings = Settings.query.first()\n\n        if settings is None:\n            settings = Settings(id=1)\n            db.session.add(settings)\n            db.session.commit()\n\n        return settings\n\n    def __repr__(self):\n        return f'<Settings id={self.id}>'\n"
  },
  {
    "path": "backend/models/task.py",
    "content": "\"\"\"\nTask model for tracking async operations\n\"\"\"\nimport uuid\nimport json\nfrom datetime import datetime\nfrom . import db\n\n\nclass Task(db.Model):\n    \"\"\"\n    Task model - tracks asynchronous generation tasks\n    \"\"\"\n    __tablename__ = 'tasks'\n    \n    id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))\n    project_id = db.Column(db.String(36), db.ForeignKey('projects.id'), nullable=False)\n    task_type = db.Column(db.String(50), nullable=False)  # GENERATE_DESCRIPTIONS|GENERATE_IMAGES\n    status = db.Column(db.String(50), nullable=False, default='PENDING')\n    progress = db.Column(db.Text, nullable=True)  # JSON string: {\"total\": 10, \"completed\": 5, \"failed\": 0}\n    error_message = db.Column(db.Text, nullable=True)\n    created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)\n    completed_at = db.Column(db.DateTime, nullable=True)\n    \n    # Relationships\n    project = db.relationship('Project', back_populates='tasks')\n    \n    def get_progress(self):\n        \"\"\"Parse progress from JSON string\"\"\"\n        if self.progress:\n            try:\n                return json.loads(self.progress)\n            except json.JSONDecodeError:\n                return {\"total\": 0, \"completed\": 0, \"failed\": 0}\n        return {\"total\": 0, \"completed\": 0, \"failed\": 0}\n    \n    def set_progress(self, data):\n        \"\"\"Set progress as JSON string\"\"\"\n        if data:\n            self.progress = json.dumps(data)\n        else:\n            self.progress = None\n    \n    def update_progress(self, completed=None, failed=None):\n        \"\"\"Update progress incrementally\"\"\"\n        prog = self.get_progress()\n        if completed is not None:\n            prog['completed'] = completed\n        if failed is not None:\n            prog['failed'] = failed\n        self.set_progress(prog)\n    \n    def to_dict(self):\n        \"\"\"Convert to dictionary\"\"\"\n        return {\n            'task_id': self.id,\n            'task_type': self.task_type,\n            'status': self.status,\n            'progress': self.get_progress(),\n            'error_message': self.error_message,\n            'created_at': self.created_at.isoformat() if self.created_at else None,\n            'completed_at': self.completed_at.isoformat() if self.completed_at else None,\n        }\n    \n    def __repr__(self):\n        return f'<Task {self.id}: {self.task_type} - {self.status}>'\n\n"
  },
  {
    "path": "backend/models/user_template.py",
    "content": "\"\"\"\nUser Template model - stores user-uploaded templates\n\"\"\"\nimport uuid\nfrom datetime import datetime\nfrom . import db\n\n\nclass UserTemplate(db.Model):\n    \"\"\"\n    User Template model - represents a user-uploaded template\n    \"\"\"\n    __tablename__ = 'user_templates'\n\n    id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))\n    name = db.Column(db.String(200), nullable=True)  # Optional template name\n    file_path = db.Column(db.String(500), nullable=False)\n    thumb_path = db.Column(db.String(500), nullable=True)  # Thumbnail path for faster loading\n    file_size = db.Column(db.Integer, nullable=True)  # File size in bytes\n    created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)\n    updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)\n\n    def to_dict(self):\n        \"\"\"Convert to dictionary\"\"\"\n        # Use thumbnail for preview if available\n        if self.thumb_path:\n            thumb_url = f'/files/user-templates/{self.id}/{self.thumb_path.split(\"/\")[-1]}'\n        else:\n            thumb_url = None\n\n        return {\n            'template_id': self.id,\n            'name': self.name,\n            'template_image_url': f'/files/user-templates/{self.id}/{self.file_path.split(\"/\")[-1]}',\n            'thumb_url': thumb_url,\n            'created_at': self.created_at.isoformat() if self.created_at else None,\n            'updated_at': self.updated_at.isoformat() if self.updated_at else None,\n        }\n\n    def __repr__(self):\n        return f'<UserTemplate {self.id}: {self.name or \"Unnamed\"}>'\n\n"
  },
  {
    "path": "backend/run.bat",
    "content": "@echo off\nREM Banana Slides Backend Startup Script for Windows\n\necho ╔══════════════════════════════════════╗\necho ║   🍌 Banana Slides API Server 🍌   ║\necho ╚══════════════════════════════════════╝\necho.\n\nREM Check if .env exists\nif not exist .env (\n    echo ⚠️  .env file not found. Creating from .env.example...\n    copy .env.example .env\n    echo ✅ .env file created. Please edit it with your API keys.\n    echo.\n)\n\nREM Check if virtual environment exists\nif not exist venv (\n    echo 📦 Creating virtual environment...\n    python -m venv venv\n    echo ✅ Virtual environment created.\n    echo.\n)\n\nREM Activate virtual environment\necho 🔄 Activating virtual environment...\ncall venv\\Scripts\\activate.bat\n\nREM Install dependencies\necho 📥 Installing dependencies...\npip install -r requirements.txt\n\nREM Create instance folder if not exists\nif not exist instance mkdir instance\nif not exist uploads mkdir uploads\n\necho.\necho ✅ Setup complete!\necho.\necho 🚀 Starting server...\necho.\n\nREM Run the application\npython app.py\n\n"
  },
  {
    "path": "backend/run.sh",
    "content": "#!/bin/bash\n\n# Banana Slides Backend Startup Script\n\necho \"╔══════════════════════════════════════╗\"\necho \"║   🍌 Banana Slides API Server 🍌   ║\"\necho \"╚══════════════════════════════════════╝\"\necho \"\"\n\n# Check if .env exists\nif [ ! -f .env ]; then\n    echo \"⚠️  .env file not found. Creating from .env.example...\"\n    cp .env.example .env\n    echo \"✅ .env file created. Please edit it with your API keys.\"\n    echo \"\"\nfi\n\n# Check if virtual environment exists\nif [ ! -d \"venv\" ]; then\n    echo \"📦 Creating virtual environment...\"\n    python3 -m venv venv\n    echo \"✅ Virtual environment created.\"\n    echo \"\"\nfi\n\n# Activate virtual environment\necho \"🔄 Activating virtual environment...\"\nsource venv/bin/activate\n\n# Install dependencies\necho \"📥 Installing dependencies...\"\npip install -r requirements.txt\n\n# Create instance folder if not exists\nmkdir -p instance\nmkdir -p uploads\n\necho \"\"\necho \"✅ Setup complete!\"\necho \"\"\necho \"🚀 Starting server...\"\necho \"\"\n\n# Run the application\npython app.py\n\n"
  },
  {
    "path": "backend/server.log",
    "content": "Traceback (most recent call last):\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/engine/base.py\", line 143, in __init__\n    self._dbapi_connection = engine.raw_connection()\n                             ~~~~~~~~~~~~~~~~~~~~~^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/engine/base.py\", line 3301, in raw_connection\n    return self.pool.connect()\n           ~~~~~~~~~~~~~~~~~^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/base.py\", line 447, in connect\n    return _ConnectionFairy._checkout(self)\n           ~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/base.py\", line 1264, in _checkout\n    fairy = _ConnectionRecord.checkout(pool)\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/base.py\", line 711, in checkout\n    rec = pool._do_get()\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/impl.py\", line 177, in _do_get\n    with util.safe_reraise():\n         ~~~~~~~~~~~~~~~~~^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/util/langhelpers.py\", line 224, in __exit__\n    raise exc_value.with_traceback(exc_tb)\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/impl.py\", line 175, in _do_get\n    return self._create_connection()\n           ~~~~~~~~~~~~~~~~~~~~~~~^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/base.py\", line 388, in _create_connection\n    return _ConnectionRecord(self)\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/base.py\", line 673, in __init__\n    self.__connect()\n    ~~~~~~~~~~~~~~^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/base.py\", line 899, in __connect\n    with util.safe_reraise():\n         ~~~~~~~~~~~~~~~~~^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/util/langhelpers.py\", line 224, in __exit__\n    raise exc_value.with_traceback(exc_tb)\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/base.py\", line 895, in __connect\n    self.dbapi_connection = connection = pool._invoke_creator(self)\n                                         ~~~~~~~~~~~~~~~~~~~~^^^^^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/engine/create.py\", line 661, in connect\n    return dialect.connect(*cargs, **cparams)\n           ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/engine/default.py\", line 629, in connect\n    return self.loaded_dbapi.connect(*cargs, **cparams)  # type: ignore[no-any-return]  # NOQA: E501\n           ~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^\nsqlite3.OperationalError: unable to open database file\n\nThe above exception was the direct cause of the following exception:\n\nTraceback (most recent call last):\n  File \"/mnt/d/Desktop/banana-slides/backend/app.py\", line 80, in <module>\n    app = create_app()\n  File \"/mnt/d/Desktop/banana-slides/backend/app.py\", line 55, in create_app\n    db.create_all()\n    ~~~~~~~~~~~~~^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/flask_sqlalchemy/extension.py\", line 900, in create_all\n    self._call_for_binds(bind_key, \"create_all\")\n    ~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/flask_sqlalchemy/extension.py\", line 881, in _call_for_binds\n    getattr(metadata, op_name)(bind=engine)\n    ~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/sql/schema.py\", line 5928, in create_all\n    bind._run_ddl_visitor(\n    ~~~~~~~~~~~~~~~~~~~~~^\n        ddl.SchemaGenerator, self, checkfirst=checkfirst, tables=tables\n        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n    )\n    ^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/engine/base.py\", line 3251, in _run_ddl_visitor\n    with self.begin() as conn:\n         ~~~~~~~~~~^^\n  File \"/home/aa/miniconda3/lib/python3.13/contextlib.py\", line 141, in __enter__\n    return next(self.gen)\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/engine/base.py\", line 3241, in begin\n    with self.connect() as conn:\n         ~~~~~~~~~~~~^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/engine/base.py\", line 3277, in connect\n    return self._connection_cls(self)\n           ~~~~~~~~~~~~~~~~~~~~^^^^^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/engine/base.py\", line 145, in __init__\n    Connection._handle_dbapi_exception_noconnection(\n    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^\n        err, dialect, engine\n        ^^^^^^^^^^^^^^^^^^^^\n    )\n    ^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/engine/base.py\", line 2440, in _handle_dbapi_exception_noconnection\n    raise sqlalchemy_exception.with_traceback(exc_info[2]) from e\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/engine/base.py\", line 143, in __init__\n    self._dbapi_connection = engine.raw_connection()\n                             ~~~~~~~~~~~~~~~~~~~~~^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/engine/base.py\", line 3301, in raw_connection\n    return self.pool.connect()\n           ~~~~~~~~~~~~~~~~~^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/base.py\", line 447, in connect\n    return _ConnectionFairy._checkout(self)\n           ~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/base.py\", line 1264, in _checkout\n    fairy = _ConnectionRecord.checkout(pool)\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/base.py\", line 711, in checkout\n    rec = pool._do_get()\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/impl.py\", line 177, in _do_get\n    with util.safe_reraise():\n         ~~~~~~~~~~~~~~~~~^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/util/langhelpers.py\", line 224, in __exit__\n    raise exc_value.with_traceback(exc_tb)\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/impl.py\", line 175, in _do_get\n    return self._create_connection()\n           ~~~~~~~~~~~~~~~~~~~~~~~^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/base.py\", line 388, in _create_connection\n    return _ConnectionRecord(self)\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/base.py\", line 673, in __init__\n    self.__connect()\n    ~~~~~~~~~~~~~~^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/base.py\", line 899, in __connect\n    with util.safe_reraise():\n         ~~~~~~~~~~~~~~~~~^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/util/langhelpers.py\", line 224, in __exit__\n    raise exc_value.with_traceback(exc_tb)\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/pool/base.py\", line 895, in __connect\n    self.dbapi_connection = connection = pool._invoke_creator(self)\n                                         ~~~~~~~~~~~~~~~~~~~~^^^^^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/engine/create.py\", line 661, in connect\n    return dialect.connect(*cargs, **cparams)\n           ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^\n  File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/engine/default.py\", line 629, in connect\n    return self.loaded_dbapi.connect(*cargs, **cparams)  # type: ignore[no-any-return]  # NOQA: E501\n           ~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^\nsqlalchemy.exc.OperationalError: (sqlite3.OperationalError) unable to open database file\n(Background on this error at: https://sqlalche.me/e/20/e3q8)\n"
  },
  {
    "path": "backend/server_running.log",
    "content": "\n    ╔══════════════════════════════════════╗\n    ║   🍌 Banana Slides API Server 🍌   ║\n    ╚══════════════════════════════════════╝\n    \n    Server starting on: http://localhost:5000\n    Environment: development\n    Debug mode: True\n    \n    API Base URL: http://localhost:5000/api\n    Database: sqlite:////mnt/d/Desktop/banana-slides/backend/instance/database.db\n    Uploads: /mnt/d/Desktop/banana-slides/uploads\n    \n * Serving Flask app 'app_simple'\n * Debug mode: on\nWARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.\n * Running on all addresses (0.0.0.0)\n * Running on http://127.0.0.1:5000\n * Running on http://172.30.207.46:5000\nPress CTRL+C to quit\n127.0.0.1 - - [30/Nov/2025 03:18:33] \"GET /health HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:18:39] \"GET /health HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:18:39] \"GET /health HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:18:39] \"GET / HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:18:39] \"POST /api/projects HTTP/1.1\" 201 -\n127.0.0.1 - - [30/Nov/2025 03:18:39] \"GET /api/projects/81632cd2-b693-49df-ba50-632a6753bf2b HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:18:39] \"PUT /api/projects/81632cd2-b693-49df-ba50-632a6753bf2b HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:18:40] \"POST /api/projects/81632cd2-b693-49df-ba50-632a6753bf2b/template HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:18:40] \"GET /api/projects/templates HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:18:40] \"GET /api/projects/81632cd2-b693-49df-ba50-632a6753bf2b HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:18:40] \"POST /api/projects/81632cd2-b693-49df-ba50-632a6753bf2b/pages HTTP/1.1\" 201 -\n127.0.0.1 - - [30/Nov/2025 03:18:40] \"GET /api/projects/81632cd2-b693-49df-ba50-632a6753bf2b/export/pptx?filename=test_presentation.pptx HTTP/1.1\" 400 -\n127.0.0.1 - - [30/Nov/2025 03:18:40] \"GET /api/projects/81632cd2-b693-49df-ba50-632a6753bf2b/export/pdf?filename=test_presentation.pdf HTTP/1.1\" 400 -\n127.0.0.1 - - [30/Nov/2025 03:18:40] \"DELETE /api/projects/81632cd2-b693-49df-ba50-632a6753bf2b/template HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:19:16] \"GET /health HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:19:16] \"GET /health HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:19:16] \"GET / HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:19:16] \"POST /api/projects HTTP/1.1\" 201 -\n127.0.0.1 - - [30/Nov/2025 03:19:16] \"GET /api/projects/8543aa4a-4d07-437e-b0f2-3a9dfbc275f5 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:19:16] \"PUT /api/projects/8543aa4a-4d07-437e-b0f2-3a9dfbc275f5 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:19:16] \"POST /api/projects/8543aa4a-4d07-437e-b0f2-3a9dfbc275f5/template HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:19:16] \"GET /api/projects/templates HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:19:16] \"GET /api/projects/8543aa4a-4d07-437e-b0f2-3a9dfbc275f5 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:19:16] \"POST /api/projects/8543aa4a-4d07-437e-b0f2-3a9dfbc275f5/pages HTTP/1.1\" 201 -\n127.0.0.1 - - [30/Nov/2025 03:19:16] \"GET /api/projects/8543aa4a-4d07-437e-b0f2-3a9dfbc275f5/export/pptx?filename=test_presentation.pptx HTTP/1.1\" 400 -\n127.0.0.1 - - [30/Nov/2025 03:19:16] \"GET /api/projects/8543aa4a-4d07-437e-b0f2-3a9dfbc275f5/export/pdf?filename=test_presentation.pdf HTTP/1.1\" 400 -\n127.0.0.1 - - [30/Nov/2025 03:19:16] \"DELETE /api/projects/8543aa4a-4d07-437e-b0f2-3a9dfbc275f5/template HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:19:23] \"GET /api/projects/8543aa4a-4d07-437e-b0f2-3a9dfbc275f5 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:22:38] \"GET /health HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:22:43] \"GET /health HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:22:43] \"GET /health HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:22:43] \"POST /api/projects HTTP/1.1\" 201 -\n127.0.0.1 - - [30/Nov/2025 03:22:43] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:22:44] \"POST /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/template HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:22:51] \"POST /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/generate/outline HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:22:51] \"PUT /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/pages/fbcfcd9c-7b83-4d58-9710-dba90de88df9/outline HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:22:51] \"POST /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/generate/descriptions HTTP/1.1\" 202 -\n127.0.0.1 - - [30/Nov/2025 03:22:51] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:22:56] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:23:01] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:23:06] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:23:11] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:23:15] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:23:20] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:23:25] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:23:30] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:23:35] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:23:40] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:23:45] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:23:48] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:23:53] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:23:58] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:03] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:08] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:13] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:17] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:22] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:27] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:32] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:37] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:39] \"GET /health HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:39] \"GET /health HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:39] \"POST /api/projects HTTP/1.1\" 201 -\n127.0.0.1 - - [30/Nov/2025 03:24:39] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:39] \"POST /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/template HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:42] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:45] \"POST /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/generate/outline HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:45] \"PUT /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/pages/86cc3e3e-7e87-4414-a4e0-24b566fa400d/outline HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:45] \"POST /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/generate/descriptions HTTP/1.1\" 202 -\n127.0.0.1 - - [30/Nov/2025 03:24:45] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:45] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:50] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:50] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:55] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:24:55] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:00] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:00] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:00] \"GET /health HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:00] \"GET /health HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:00] \"POST /api/projects HTTP/1.1\" 201 -\n127.0.0.1 - - [30/Nov/2025 03:25:00] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:01] \"POST /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/template HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:05] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:05] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:06] \"POST /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/generate/outline HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:06] \"PUT /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/pages/b04d9ed5-8180-45d2-be33-7dd7996725e8/outline HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:06] \"POST /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/generate/descriptions HTTP/1.1\" 202 -\n127.0.0.1 - - [30/Nov/2025 03:25:06] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:10] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:10] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:12] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:15] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:15] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:17] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:19] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:19] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:20] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:24] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:24] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:25] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:29] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:29] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:30] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:34] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:34] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:35] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:39] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:39] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:40] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:44] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:44] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:45] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:47] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:48] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:49] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:52] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:53] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:54] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:57] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:58] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:25:59] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:02] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:03] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:04] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:07] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:08] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:09] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:11] \"GET /health HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:11] \"GET /health HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:11] \"POST /api/projects HTTP/1.1\" 201 -\n127.0.0.1 - - [30/Nov/2025 03:26:11] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:11] \"POST /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/template HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:12] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:13] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:14] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:18] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:18] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:17] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:18] \"POST /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/generate/outline HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:19] \"PUT /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/pages/22d1ee12-fe87-4664-a306-173e0ba024c2/outline HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:19] \"POST /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/generate/descriptions HTTP/1.1\" 202 -\n127.0.0.1 - - [30/Nov/2025 03:26:19] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:21] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:21] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:22] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:24] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:26] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:26] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:27] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:29] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:31] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:31] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:32] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:34] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:36] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:37] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:37] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:39] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:41] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:42] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:42] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:44] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:46] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:47] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:47] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:49] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:50] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:50] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:51] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:52] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:55] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:55] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:56] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:26:57] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:00] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:00] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:01] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:02] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:05] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:05] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:06] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:07] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:10] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:10] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:11] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:12] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:15] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:15] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:16] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:17] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:20] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:19] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:20] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:21] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:23] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:24] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:25] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:26] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:28] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:29] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:30] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:31] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:33] \"GET /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48/tasks/2c565b27-3e6b-4a28-bcba-91a97c2c68ad HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:34] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:35] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:36] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:38] \"GET /files/084c9edb-7c21-4330-aa67-840371f4cf48/template/template.png HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:38] \"PUT /api/projects/084c9edb-7c21-4330-aa67-840371f4cf48 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:39] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:40] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:41] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:44] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:45] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:46] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:49] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:50] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:49] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:53] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:53] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:54] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:58] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:58] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:27:59] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:03] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:03] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:04] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:08] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:08] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:09] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:13] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:13] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:15] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:18] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:19] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:20] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:22] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:22] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:23] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:27] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:27] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:28] \"GET /api/projects/b01900e7-3c3a-4fd9-a801-b4c7698cb272/tasks/07e6aa60-a3e9-495d-b4c5-2b4e58e41b28 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:32] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:32] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:37] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:37] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:42] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:42] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:47] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:47] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:52] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:51] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:55] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:28:56] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:00] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:01] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:05] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:06] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:10] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:11] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:15] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:16] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:20] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:21] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:24] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:25] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:29] \"GET /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/tasks/484d73a0-115b-471c-8593-83a57f9ebbe9 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:30] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:34] \"GET /files/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d/template/template.png HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:34] \"PUT /api/projects/ca4be5b5-f9cf-42ab-a345-5d1c98e2a86d HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:35] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:40] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:45] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:50] \"GET /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966/tasks/c85e5411-e6ff-4571-9e07-89070d676af7 HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:53] \"GET /files/1952981f-27fe-4c86-870b-a3d69e6c8966/template/template.png HTTP/1.1\" 200 -\n127.0.0.1 - - [30/Nov/2025 03:29:53] \"PUT /api/projects/1952981f-27fe-4c86-870b-a3d69e6c8966 HTTP/1.1\" 200 -\n"
  },
  {
    "path": "backend/services/__init__.py",
    "content": "\"\"\"Services package\"\"\"\nfrom .ai_service import AIService, ProjectContext\nfrom .file_service import FileService\nfrom .export_service import ExportService\n\n__all__ = ['AIService', 'ProjectContext', 'FileService', 'ExportService']\n\n"
  },
  {
    "path": "backend/services/ai_providers/__init__.py",
    "content": "\"\"\"\nAI Providers factory module\n\nProvides factory functions to get the appropriate text/image generation providers\nbased on environment configuration.\n\nConfiguration priority (highest → lowest):\n    1. Database settings (Flask app.config, persisted via Settings page)\n    2. Environment variables (.env file)\n    3. Hard-coded defaults\n\nSupported provider formats:\n    gemini  — Google AI Studio (API key auth)\n    openai  — OpenAI-compatible endpoints\n    vertex  — Google Cloud Vertex AI (service-account auth)\n    lazyllm — LazyLLM multi-vendor framework\n\"\"\"\nimport os\nimport logging\nfrom typing import Any, Dict, Optional\n\nfrom .text import TextProvider, GenAITextProvider, OpenAITextProvider, LazyLLMTextProvider\nfrom .image import ImageProvider, GenAIImageProvider, OpenAIImageProvider, LazyLLMImageProvider\n\nlogger = logging.getLogger(__name__)\n\n__all__ = [\n    'TextProvider', 'GenAITextProvider', 'OpenAITextProvider', 'LazyLLMTextProvider',\n    'ImageProvider', 'GenAIImageProvider', 'OpenAIImageProvider', 'LazyLLMImageProvider',\n    'get_text_provider', 'get_image_provider', 'get_provider_format',\n    'get_caption_provider', 'get_image_caption_provider_config', 'LAZYLLM_VENDORS',\n]\n\n# LazyLLM vendor names (used to distinguish from gemini/openai formats)\nLAZYLLM_VENDORS = {'qwen', 'doubao', 'deepseek', 'glm', 'siliconflow', 'sensenova', 'minimax', 'kimi'}\n\n\ndef get_provider_format() -> str:\n    \"\"\"\n    Get the configured AI provider format\n\n    Priority:\n        1. Flask app.config['AI_PROVIDER_FORMAT'] (from database settings)\n        2. Environment variable AI_PROVIDER_FORMAT\n        3. Default: 'gemini'\n\n    Returns:\n        \"gemini\", \"openai\", \"vertex\", \"lazyllm\", or a lazyllm vendor name\n        (e.g., \"doubao\", \"qwen\", \"deepseek\")\n    \"\"\"\n    # Try to get from Flask app config first (database settings)\n    try:\n        from flask import current_app\n        if current_app and hasattr(current_app, 'config'):\n            config_value = current_app.config.get('AI_PROVIDER_FORMAT')\n            if config_value:\n                return str(config_value).lower()\n    except RuntimeError:\n        # Not in Flask application context\n        pass\n\n    # Fallback to environment variable\n    return os.getenv('AI_PROVIDER_FORMAT', 'gemini').lower()\n\n\ndef _resolve_setting(key: str, fallback: Optional[str] = None) -> Optional[str]:\n    \"\"\"Look up a configuration value using the standard priority chain.\n\n    Resolution order:\n        1. Flask ``app.config`` (populated from the database Settings page)\n        2. OS environment variable\n        3. *fallback* argument (may be ``None``)\n    \"\"\"\n    # 1) Try Flask app.config\n    try:\n        from flask import current_app\n        if current_app and hasattr(current_app, 'config') and key in current_app.config:\n            val = current_app.config[key]\n            if val is not None:\n                logger.debug(\"Setting %s resolved from app.config\", key)\n                return str(val)\n    except RuntimeError:\n        pass  # outside Flask request context\n\n    # 2) Try environment\n    env_val = os.getenv(key)\n    if env_val is not None:\n        logger.debug(\"Setting %s resolved from environment\", key)\n        return env_val\n\n    # 3) Fallback\n    if fallback is not None:\n        logger.debug(\"Setting %s using fallback: %s\", key, fallback)\n    return fallback\n\n\ndef _build_provider_config() -> Dict[str, Any]:\n    \"\"\"Assemble provider-specific configuration dict.\n\n    Returns a dict always containing ``'format'`` plus format-specific keys:\n        - gemini / openai → ``api_key``, ``api_base``\n        - vertex          → ``project_id``, ``location``\n        - lazyllm         → ``text_source``, ``image_source``\n\n    Raises ``ValueError`` when required settings are missing.\n    \"\"\"\n    fmt = get_provider_format()\n    cfg: Dict[str, Any] = {'format': fmt}\n\n    if fmt == 'openai':\n        cfg['api_key'] = _resolve_setting('OPENAI_API_KEY') or _resolve_setting('GOOGLE_API_KEY')\n        cfg['api_base'] = _resolve_setting('OPENAI_API_BASE', 'https://aihubmix.com/v1')\n        if not cfg['api_key']:\n            raise ValueError(\n                \"OPENAI_API_KEY or GOOGLE_API_KEY (from database settings or environment) \"\n                \"is required when AI_PROVIDER_FORMAT=openai.\"\n            )\n        logger.info(\"Provider config — format: openai, api_base: %s\", cfg['api_base'])\n\n    elif fmt == 'vertex':\n        cfg['project_id'] = _resolve_setting('VERTEX_PROJECT_ID')\n        cfg['location'] = _resolve_setting('VERTEX_LOCATION', 'us-central1')\n        if not cfg['project_id']:\n            raise ValueError(\n                \"VERTEX_PROJECT_ID must be set when AI_PROVIDER_FORMAT=vertex. \"\n                \"Make sure GOOGLE_APPLICATION_CREDENTIALS points to a valid service-account JSON.\"\n            )\n        logger.info(\"Provider config — format: vertex, project: %s, location: %s\",\n                     cfg['project_id'], cfg['location'])\n\n    elif fmt in LAZYLLM_VENDORS or fmt == 'lazyllm':\n        # fmt is a specific vendor (e.g., 'doubao') or generic 'lazyllm' (legacy)\n        vendor = fmt if fmt in LAZYLLM_VENDORS else None\n        cfg['format'] = 'lazyllm'\n        cfg['text_source'] = _resolve_setting('TEXT_MODEL_SOURCE') or vendor or 'deepseek'\n        cfg['image_source'] = _resolve_setting('IMAGE_MODEL_SOURCE') or vendor or 'doubao'\n        logger.info(\"Provider config — format: lazyllm, vendor: %s, text_source: %s, image_source: %s\",\n                     vendor, cfg['text_source'], cfg['image_source'])\n\n    else:\n        # gemini (default) or unknown format\n        if fmt != 'gemini':\n            logger.warning(\"Unknown provider format '%s', falling back to gemini\", fmt)\n            cfg['format'] = 'gemini'\n        cfg['api_key'] = _resolve_setting('GOOGLE_API_KEY')\n        cfg['api_base'] = _resolve_setting('GOOGLE_API_BASE')\n        if not cfg['api_key']:\n            raise ValueError(\"GOOGLE_API_KEY (from database settings or environment) is required\")\n        logger.info(\"Provider config — format: gemini, api_base: %s, api_key: %s\",\n                     cfg['api_base'], '***' if cfg['api_key'] else 'None')\n\n    return cfg\n\n\ndef _get_model_type_provider_config(model_type: str) -> Dict[str, Any]:\n    \"\"\"\n    Get provider config for a specific model type, with fallback to global config.\n\n    Each model type (text, image, image_caption) can independently choose its provider\n    via {MODEL_TYPE}_MODEL_SOURCE. The source can be:\n      - 'gemini': uses {MODEL_TYPE}_API_KEY + {MODEL_TYPE}_API_BASE, fallback to global\n      - 'openai': uses {MODEL_TYPE}_API_KEY + {MODEL_TYPE}_API_BASE, fallback to global\n      - A LazyLLM vendor name (qwen, doubao, etc.): uses lazyllm with that vendor\n      - None/empty: falls back to global _build_provider_config()\n\n    Args:\n        model_type: \"text\", \"image\", or \"image_caption\"\n\n    Returns:\n        Dict with provider config (same format as _build_provider_config)\n    \"\"\"\n    prefix = model_type.upper()  # TEXT, IMAGE, IMAGE_CAPTION\n    source_key = f'{prefix}_MODEL_SOURCE'\n    source = _resolve_setting(source_key)\n\n    if not source:\n        # No per-model override, use global config\n        return _build_provider_config()\n\n    source_lower = source.lower()\n\n    if source_lower == 'gemini':\n        api_key = _resolve_setting(f'{prefix}_API_KEY') or _resolve_setting('GOOGLE_API_KEY')\n        api_base = _resolve_setting(f'{prefix}_API_BASE') or _resolve_setting('GOOGLE_API_BASE')\n        if not api_key:\n            raise ValueError(\n                f\"API key is required for {model_type} model with Gemini provider. \"\n                f\"Set {prefix}_API_KEY or GOOGLE_API_KEY.\"\n            )\n        logger.info(\"Per-model config — %s: gemini, api_base: %s\", model_type, api_base)\n        return {'format': 'gemini', 'api_key': api_key, 'api_base': api_base}\n\n    elif source_lower == 'openai':\n        api_key = (_resolve_setting(f'{prefix}_API_KEY')\n                   or _resolve_setting('OPENAI_API_KEY')\n                   or _resolve_setting('GOOGLE_API_KEY'))\n        api_base = (_resolve_setting(f'{prefix}_API_BASE')\n                    or _resolve_setting('OPENAI_API_BASE', 'https://aihubmix.com/v1'))\n        if not api_key:\n            raise ValueError(\n                f\"API key is required for {model_type} model with OpenAI provider. \"\n                f\"Set {prefix}_API_KEY or OPENAI_API_KEY.\"\n            )\n        logger.info(\"Per-model config — %s: openai, api_base: %s\", model_type, api_base)\n        return {'format': 'openai', 'api_key': api_key, 'api_base': api_base}\n\n    else:\n        # Assume it's a LazyLLM vendor name\n        logger.info(\"Per-model config — %s: lazyllm, source: %s\", model_type, source_lower)\n        return {'format': 'lazyllm', 'source': source_lower}\n\n\ndef get_image_caption_provider_config() -> Dict[str, Any]:\n    \"\"\"Get provider config specifically for image caption model.\"\"\"\n    return _get_model_type_provider_config('image_caption')\n\n\ndef get_caption_provider(model: str = \"gemini-3-flash-preview\") -> TextProvider:\n    \"\"\"Factory: return a TextProvider for image caption (multimodal) tasks.\"\"\"\n    config = _get_model_type_provider_config('image_caption')\n    fmt = config['format']\n\n    if fmt == 'openai':\n        logger.info(\"Caption provider: OpenAI, model=%s\", model)\n        return OpenAITextProvider(api_key=config['api_key'], api_base=config['api_base'], model=model)\n    elif fmt == 'vertex':\n        logger.info(\"Caption provider: Vertex AI, model=%s\", model)\n        return GenAITextProvider(\n            model=model, vertexai=True,\n            project_id=config['project_id'], location=config['location'],\n        )\n    elif fmt == 'lazyllm':\n        source = config.get('source') or config.get('text_source', 'doubao')\n        logger.info(\"Caption provider: LazyLLM, model=%s, source=%s\", model, source)\n        return LazyLLMTextProvider(source=source, model=model)\n    else:\n        logger.info(\"Caption provider: Gemini, model=%s\", model)\n        return GenAITextProvider(api_key=config['api_key'], api_base=config['api_base'], model=model)\n\n\ndef get_text_provider(model: str = \"gemini-3-flash-preview\") -> TextProvider:\n    \"\"\"Factory: return the appropriate text-generation provider.\"\"\"\n    config = _get_model_type_provider_config('text')\n    fmt = config['format']\n\n    if fmt == 'openai':\n        logger.info(\"Text provider: OpenAI, model=%s\", model)\n        return OpenAITextProvider(api_key=config['api_key'], api_base=config['api_base'], model=model)\n\n    elif fmt == 'vertex':\n        logger.info(\"Text provider: Vertex AI, model=%s, project=%s\", model, config['project_id'])\n        return GenAITextProvider(\n            model=model, vertexai=True,\n            project_id=config['project_id'], location=config['location'],\n        )\n\n    elif fmt == 'lazyllm':\n        source = config.get('source') or config.get('text_source', 'deepseek')\n        logger.info(\"Text provider: LazyLLM, model=%s, source=%s\", model, source)\n        return LazyLLMTextProvider(source=source, model=model)\n\n    else:\n        # gemini (default)\n        logger.info(\"Text provider: Gemini, model=%s\", model)\n        return GenAITextProvider(api_key=config['api_key'], api_base=config['api_base'], model=model)\n\n\ndef get_image_provider(model: str = \"gemini-3-pro-image-preview\") -> ImageProvider:\n    \"\"\"Factory: return the appropriate image-generation provider.\n\n    Note: OpenAI format does NOT support 4K resolution — only 1K is available.\n    Use Gemini or Vertex AI for higher resolution output.\n    \"\"\"\n    config = _get_model_type_provider_config('image')\n    fmt = config['format']\n\n    if fmt == 'openai':\n        logger.info(\"Image provider: OpenAI, model=%s\", model)\n        logger.warning(\"OpenAI format only supports 1K resolution, 4K is not available\")\n        return OpenAIImageProvider(api_key=config['api_key'], api_base=config['api_base'], model=model)\n\n    elif fmt == 'vertex':\n        logger.info(\"Image provider: Vertex AI, model=%s, project=%s\", model, config['project_id'])\n        return GenAIImageProvider(\n            model=model, vertexai=True,\n            project_id=config['project_id'], location=config['location'],\n        )\n\n    elif fmt == 'lazyllm':\n        source = config.get('source') or config.get('image_source', 'doubao')\n        logger.info(\"Image provider: LazyLLM, model=%s, source=%s\", model, source)\n        return LazyLLMImageProvider(source=source, model=model)\n\n    else:\n        # gemini (default)\n        logger.info(\"Image provider: Gemini, model=%s\", model)\n        return GenAIImageProvider(api_key=config['api_key'], api_base=config['api_base'], model=model)\n"
  },
  {
    "path": "backend/services/ai_providers/genai_client.py",
    "content": "\"\"\"Shared GenAI client factory used by both text and image providers.\"\"\"\n\nimport logging\nfrom google import genai\nfrom google.genai import types\nfrom config import get_config\n\nlogger = logging.getLogger(__name__)\n\n\ndef make_genai_client(\n    *,\n    vertexai: bool,\n    api_key: str = None,\n    api_base: str = None,\n    project_id: str = None,\n    location: str = None,\n) -> genai.Client:\n    \"\"\"Construct a ``genai.Client`` for either AI Studio or Vertex AI.\"\"\"\n    timeout_ms = int(get_config().GENAI_TIMEOUT * 1000)\n\n    if vertexai:\n        logger.info(\"Creating GenAI client (Vertex AI) — project=%s, location=%s\", project_id, location)\n        return genai.Client(\n            vertexai=True,\n            project=project_id,\n            location=location or \"us-central1\",\n            http_options=types.HttpOptions(timeout=timeout_ms),\n        )\n\n    opts = types.HttpOptions(timeout=timeout_ms, base_url=api_base)\n    return genai.Client(http_options=opts, api_key=api_key)\n"
  },
  {
    "path": "backend/services/ai_providers/image/__init__.py",
    "content": "\"\"\"Image generation providers\"\"\"\nfrom .base import ImageProvider\nfrom .genai_provider import GenAIImageProvider\nfrom .openai_provider import OpenAIImageProvider\nfrom .baidu_inpainting_provider import BaiduInpaintingProvider, create_baidu_inpainting_provider\nfrom .lazyllm_provider import LazyLLMImageProvider\n\n__all__ = [\n    'ImageProvider',\n    'GenAIImageProvider',\n    'OpenAIImageProvider',\n    'BaiduInpaintingProvider',\n    'create_baidu_inpainting_provider',\n    'LazyLLMImageProvider',\n]\n"
  },
  {
    "path": "backend/services/ai_providers/image/baidu_inpainting_provider.py",
    "content": "\"\"\"\n百度图像修复 Provider\n基于百度AI的图像修复能力，在指定矩形区域去除遮挡物并用背景内容填充\n\nAPI文档: https://ai.baidu.com/ai-doc/IMAGEPROCESS/Mk4i6o3w3\n\"\"\"\nimport logging\nimport base64\nimport requests\nimport json\nfrom typing import Dict, List, Any, Optional, Tuple\nfrom PIL import Image\nimport io\nfrom tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type\n\nlogger = logging.getLogger(__name__)\n\n\nclass BaiduInpaintingProvider:\n    \"\"\"\n    百度图像修复 Provider\n    \n    在图片中指定位置框定一个或多个规则矩形，去掉不需要的遮挡物，并用背景内容填充。\n    \n    特点：\n    - 支持多个矩形区域同时修复\n    - 使用背景内容智能填充\n    - 快速响应，适合批量处理\n    \"\"\"\n\n    def __init__(self, api_key: str):\n        \"\"\"\n        初始化百度图像修复 Provider\n\n        Args:\n            api_key: 百度API Key（BCEv3格式：bce-v3/ALTAK-...）或Access Token\n        \"\"\"\n        self.api_key = api_key\n        self.api_url = \"https://aip.baidubce.com/rest/2.0/image-process/v1/inpainting\"\n\n        if api_key.startswith('bce-v3/'):\n            logger.info(\"✅ 初始化百度图像修复 Provider (使用BCEv3 API Key)\")\n        else:\n            logger.info(\"✅ 初始化百度图像修复 Provider (使用Access Token)\")\n    \n    @retry(\n        stop=stop_after_attempt(3),\n        wait=wait_exponential(multiplier=0.5, min=1, max=5),\n        retry=retry_if_exception_type((requests.exceptions.RequestException, Exception)),\n        reraise=True\n    )\n    def inpaint(\n        self,\n        image: Image.Image,\n        rectangles: List[Dict[str, int]]\n    ) -> Optional[Image.Image]:\n        \"\"\"\n        修复图片中指定的矩形区域\n        \n        Args:\n            image: PIL Image对象\n            rectangles: 矩形区域列表，每个矩形包含:\n                - left: 左上角x坐标\n                - top: 左上角y坐标\n                - width: 宽度\n                - height: 高度\n        \n        Returns:\n            修复后的PIL Image对象，失败返回None\n        \"\"\"\n        if not rectangles:\n            logger.warning(\"没有提供矩形区域，返回原图\")\n            return image.copy()\n        \n        logger.info(f\"🔧 开始百度图像修复，共 {len(rectangles)} 个区域\")\n        \n        try:\n            # 转换图片为RGB模式\n            if image.mode != 'RGB':\n                image = image.convert('RGB')\n            \n            original_width, original_height = image.size\n            logger.info(f\"📏 图片尺寸: {original_width}x{original_height}\")\n            \n            # 检查并调整图片大小（最长边不超过5000px）\n            max_size = 5000\n            scale = 1.0\n            if original_width > max_size or original_height > max_size:\n                scale = min(max_size / original_width, max_size / original_height)\n                new_size = (int(original_width * scale), int(original_height * scale))\n                image = image.resize(new_size, Image.Resampling.LANCZOS)\n                logger.info(f\"✂️ 压缩图片: {image.size}\")\n                \n                # 同时缩放矩形区域\n                rectangles = [\n                    {\n                        'left': int(r['left'] * scale),\n                        'top': int(r['top'] * scale),\n                        'width': int(r['width'] * scale),\n                        'height': int(r['height'] * scale)\n                    }\n                    for r in rectangles\n                ]\n            \n            # 过滤掉无效的矩形（宽或高为0）\n            valid_rectangles = [\n                r for r in rectangles \n                if r['width'] > 0 and r['height'] > 0\n            ]\n            \n            if not valid_rectangles:\n                logger.warning(\"过滤后没有有效的矩形区域，返回原图\")\n                return image.copy()\n            \n            # 转为base64\n            buffer = io.BytesIO()\n            image.save(buffer, format='JPEG', quality=95)\n            image_bytes = buffer.getvalue()\n            image_base64 = base64.b64encode(image_bytes).decode('utf-8')\n            \n            logger.info(f\"📦 图片编码完成: {len(image_base64)} bytes, {len(valid_rectangles)} 个矩形区域\")\n            \n            # 构建请求头\n            headers = {\n                'Content-Type': 'application/json',\n                'Accept': 'application/json',\n            }\n            \n            # 选择认证方式\n            if self.api_key.startswith('bce-v3/'):\n                headers['Authorization'] = f'Bearer {self.api_key}'\n                url = self.api_url\n                logger.info(\"🔐 使用BCEv3签名认证\")\n            else:\n                url = f\"{self.api_url}?access_token={self.api_key}\"\n                logger.info(\"🔐 使用Access Token认证\")\n            \n            # 构建请求体\n            request_body = {\n                'image': image_base64,\n                'rectangle': valid_rectangles\n            }\n            \n            logger.info(\"🌐 发送请求到百度图像修复API...\")\n            response = requests.post(\n                url, \n                headers=headers, \n                json=request_body, \n                timeout=60\n            )\n            response.raise_for_status()\n            \n            result = response.json()\n            \n            # 检查错误 - 抛出异常以触发 @retry 装饰器\n            if 'error_code' in result:\n                error_msg = result.get('error_msg', 'Unknown error')\n                error_code = result.get('error_code')\n                logger.error(f\"❌ 百度API错误: [{error_code}] {error_msg}\")\n                raise Exception(f\"Baidu API error [{error_code}]: {error_msg}\")\n            \n            # 解析结果\n            result_image_base64 = result.get('image')\n            if not result_image_base64:\n                logger.error(\"❌ 百度API返回结果中没有图片\")\n                return None\n            \n            # 解码返回的图片\n            result_image_bytes = base64.b64decode(result_image_base64)\n            result_image = Image.open(io.BytesIO(result_image_bytes))\n            \n            # 如果之前缩放过，恢复到原始尺寸\n            if scale < 1.0:\n                result_image = result_image.resize(\n                    (original_width, original_height), \n                    Image.Resampling.LANCZOS\n                )\n                logger.info(f\"📐 恢复图片尺寸: {result_image.size}\")\n            \n            logger.info(f\"✅ 百度图像修复完成!\")\n            return result_image\n            \n        except Exception as e:\n            logger.error(f\"❌ 百度图像修复失败: {str(e)}\")\n            raise\n    \n    def inpaint_bboxes(\n        self,\n        image: Image.Image,\n        bboxes: List[Tuple[float, float, float, float]],\n        expand_pixels: int = 2\n    ) -> Optional[Image.Image]:\n        \"\"\"\n        使用bbox格式修复图片\n        \n        Args:\n            image: PIL Image对象\n            bboxes: bbox列表，每个bbox格式为 (x0, y0, x1, y1)\n            expand_pixels: 扩展像素数，默认2\n        \n        Returns:\n            修复后的PIL Image对象\n        \"\"\"\n        # 将bbox转换为rectangle格式\n        rectangles = []\n        for bbox in bboxes:\n            x0, y0, x1, y1 = bbox\n            # 扩展区域\n            x0 = max(1, x0 - expand_pixels)\n            y0 = max(1, y0 - expand_pixels)\n            x1 = min(image.width - 1, x1 + expand_pixels)\n            y1 = min(image.height - 1, y1 + expand_pixels)\n            \n            rectangles.append({\n                'left': int(x0),\n                'top': int(y0),\n                'width': int(x1 - x0),\n                'height': int(y1 - y0)\n            })\n        \n        return self.inpaint(image, rectangles)\n\n\ndef create_baidu_inpainting_provider(\n    api_key: Optional[str] = None\n) -> Optional[BaiduInpaintingProvider]:\n    \"\"\"\n    创建百度图像修复 Provider 实例\n\n    Args:\n        api_key: 百度API Key，如果不提供则从Flask config或环境变量读取\n\n    Returns:\n        BaiduInpaintingProvider实例，如果api_key不可用则返回None\n    \"\"\"\n    import os\n    from config import Config\n\n    if not api_key:\n        # 优先从 Flask config 读取（数据库设置），然后从 Config，最后从环境变量\n        try:\n            from flask import current_app\n            api_key = current_app.config.get('BAIDU_API_KEY')\n        except RuntimeError:\n            pass  # 不在 Flask 上下文中\n        if not api_key:\n            api_key = Config.BAIDU_API_KEY\n        if not api_key:\n            api_key = os.getenv('BAIDU_API_KEY')\n\n    if not api_key:\n        logger.warning(\"⚠️ 未配置百度API Key (BAIDU_API_KEY), 跳过百度图像修复\")\n        return None\n\n    return BaiduInpaintingProvider(api_key)\n\n"
  },
  {
    "path": "backend/services/ai_providers/image/base.py",
    "content": "\"\"\"\nAbstract base class for image generation providers\n\"\"\"\nfrom abc import ABC, abstractmethod\nfrom typing import Optional, List\nfrom PIL import Image\n\n\nclass ImageProvider(ABC):\n    \"\"\"Abstract base class for image generation\"\"\"\n    \n    @abstractmethod\n    def generate_image(\n        self,\n        prompt: str,\n        ref_images: Optional[List[Image.Image]] = None,\n        aspect_ratio: str = \"16:9\",\n        resolution: str = \"2K\",\n        enable_thinking: bool = False,\n        thinking_budget: int = 0\n    ) -> Optional[Image.Image]:\n        \"\"\"\n        Generate image from prompt\n        \n        Args:\n            prompt: The image generation prompt\n            ref_images: Optional list of reference images (PIL Image objects)\n            aspect_ratio: Image aspect ratio (e.g., \"16:9\", \"1:1\", \"4:3\")\n            resolution: Image resolution (\"1K\", \"2K\", \"4K\") - note: OpenAI format only supports 1K\n            enable_thinking: If True, enable thinking/reasoning mode (GenAI only)\n            thinking_budget: Thinking budget for the model (GenAI only)\n            \n        Returns:\n            Generated PIL Image object, or None if failed\n        \"\"\"\n        pass\n"
  },
  {
    "path": "backend/services/ai_providers/image/gemini_inpainting_provider.py",
    "content": "\"\"\"\nGemini Inpainting 消除服务提供者\n使用 Gemini 2.5 Flash Image Preview 模型进行基于 mask 的图像编辑\n\"\"\"\nimport logging\nfrom typing import Optional\nfrom PIL import Image, ImageDraw\nimport numpy as np\nfrom tenacity import retry, stop_after_attempt, wait_exponential\nfrom .genai_provider import GenAIImageProvider\nfrom config import get_config\n\nlogger = logging.getLogger(__name__)\n\n\nclass GeminiInpaintingProvider:\n    \"\"\"Gemini Inpainting 消除服务（使用 Gemini 2.5 Flash）\"\"\"\n    \n    # DEFAULT_MODEL = \"gemini-2.5-flash-image\"\n    DEFAULT_MODEL = \"gemini-3-pro-image-preview\"\n    DEFAULT_PROMPT = \"\"\"\\\n你是一个专业的图片前景元素去除专家，以极高的精度进行前景元素的去除工作。\n现在用户向你提供了两张不同的图片：\n1. 原始图片\n2. 使用黑色矩形遮罩标注后的图片，黑色矩形区域表示要移除的前景元素，你只需要处理这些区域。\n\n你需要根据原始图片和黑色遮罩信息，重新绘制黑色遮罩标注的区域，去除前景元素，使得这些区域无缝融入周围的画面，就好像前景元素从来没有出现过。如果一个区域被整体标注，请你将其作为一个整体进行移除，而不是只移除其内部的内容。\n\n禁止遗漏任何一个黑色矩形标注的区域。\n\n\"\"\"\n    \n    def __init__(\n        self, \n        api_key: str, \n        api_base: str = None,\n        model: str = None,\n        timeout: int = 60\n    ):\n        \"\"\"\n        初始化 Gemini Inpainting 提供者\n        \n        Args:\n            api_key: Google API key\n            api_base: API base URL (for proxies like aihubmix)\n            model: Model name to use (default: gemini-2.5-flash-image)\n            timeout: API 请求超时时间（秒）\n        \"\"\"\n        self.model = model or self.DEFAULT_MODEL\n        self.timeout = timeout\n        \n        # 复用 GenAIImageProvider 的底层实现\n        self.genai_provider = GenAIImageProvider(\n            api_key=api_key,\n            api_base=api_base,\n            model=self.model\n        )\n        \n        logger.info(f\"✅ Gemini Inpainting Provider 初始化 (model={self.model})\")\n    \n    @staticmethod\n    def create_marked_image(original_image: Image.Image, mask_image: Image.Image) -> Image.Image:\n        \"\"\"\n        在原图上用纯黑色框标注需要修复的区域\n        \n        Args:\n            original_image: 原始图像\n            mask_image: 掩码图像（白色=需要移除的区域）\n            \n        Returns:\n            标注后的图像（原图 + 纯黑色矩形覆盖）\n        \"\"\"\n        # 确保 mask 和原图尺寸一致\n        if mask_image.size != original_image.size:\n            mask_image = mask_image.resize(original_image.size, Image.LANCZOS)\n        \n        # 转换为 RGB 模式\n        if original_image.mode != 'RGB':\n            original_image = original_image.convert('RGB')\n        if mask_image.mode != 'RGB':\n            mask_image = mask_image.convert('RGB')\n        \n        # 创建一个副本用于标注\n        marked_image = original_image.copy()\n        \n        # 将 mask 转换为 numpy array 以便处理\n        mask_array = np.array(mask_image)\n        marked_array = np.array(marked_image)\n        \n        # 找到白色区域（需要标注的区域）\n        # 白色像素的 RGB 值都接近 255\n        white_threshold = 200\n        mask_regions = np.all(mask_array > white_threshold, axis=2)\n        \n        # 用纯黑色 (0, 0, 0) 完全覆盖标注区域\n        black_overlay = np.array([0, 0, 0], dtype=np.uint8)\n        marked_array[mask_regions] = black_overlay\n        \n        # 转回 PIL Image\n        marked_image = Image.fromarray(marked_array)\n        \n        logger.debug(f\"✅ 已创建标注图像，用纯黑色覆盖了 {np.sum(mask_regions)} 个像素\")\n        \n        return marked_image\n    \n    @retry(\n        stop=stop_after_attempt(3),  # 最多重试3次\n        wait=wait_exponential(multiplier=1, min=2, max=10),  # 指数避让: 2s, 4s, 8s\n        reraise=True\n    )\n    def inpaint_image(\n        self,\n        original_image: Image.Image,\n        mask_image: Image.Image,\n        inpaint_mode: str = \"remove\",\n        custom_prompt: Optional[str] = None,\n        full_page_image: Optional[Image.Image] = None,\n        crop_box: Optional[tuple] = None\n    ) -> Optional[Image.Image]:\n        \"\"\"\n        使用 Gemini 和掩码进行图像编辑\n        \n        Args:\n            original_image: 原始图像\n            mask_image: 掩码图像（白色=消除，黑色=保留）\n            inpaint_mode: 修复模式（未使用，保留兼容性）\n            custom_prompt: 自定义 prompt（如果为 None 则使用默认）\n            full_page_image: 完整的 PPT 页面图像（16:9），如果提供则直接使用\n            crop_box: 裁剪框 (x0, y0, x1, y1)，指定从完整页面结果中裁剪的区域\n            \n        Returns:\n            处理后的图像，失败返回 None\n        \"\"\"\n        try:\n            logger.info(\"🚀 开始调用 Gemini inpainting（标注模式）\")\n            \n            working_image = full_page_image\n            \n            # 1. 扩展 mask 到完整页面大小\n            result_crop_box = crop_box  # 保存传入的 crop_box\n            \n            # 直接使用完整页面图像\n            final_image = working_image\n            \n            # 扩展 mask 到完整页面大小\n            # 创建与完整页面同样大小的黑色 mask\n            full_mask = Image.new('RGB', final_image.size, (0, 0, 0))\n            # 将原 mask 粘贴到正确的位置\n            x0, y0, x1, y1 = crop_box\n            # 确保 mask 尺寸匹配\n            mask_resized = mask_image.resize((x1 - x0, y1 - y0), Image.LANCZOS)\n            full_mask.paste(mask_resized, (x0, y0))\n            final_mask = full_mask\n            logger.info(f\"📷 完整页面模式: 页面={final_image.size}, mask扩展到={final_mask.size}, 粘贴位置={crop_box}\")\n\n            # 2. 创建标注图像（在原图上用纯黑色框标注需要修复的区域）\n            logger.info(\"🎨 创建标注图像（纯黑色框标注需要移除的区域）...\")\n            marked_image = self.create_marked_image(final_image, final_mask)\n            logger.info(f\"✅ 标注图像创建完成: {marked_image.size}\")\n            \n            # 3. 构建 prompt\n            prompt = custom_prompt or self.DEFAULT_PROMPT\n            logger.info(f\"📝 Prompt: {prompt[:100]}...\")\n            \n            # 4. 调用 GenAI Provider 生成图像（只传标注后的图像，不传 mask）\n            logger.info(\"🌐 调用 GenAI Provider 进行 inpainting（仅传标注图）...\")\n            \n            result_image = self.genai_provider.generate_image(\n                prompt=prompt,\n                ref_images=[full_page_image, marked_image],  \n                aspect_ratio=\"16:9\",\n                resolution=\"1K\"\n            )\n            \n            if result_image is None:\n                logger.error(\"❌ Gemini Inpainting 失败：未返回图像\")\n                return None\n            \n            # 5. 转换为 PIL Image（如果需要）\n            # GenAI SDK 返回的是 google.genai.types.Image 对象，需要转换为 PIL Image\n            if hasattr(result_image, '_pil_image'):\n                logger.debug(\"🔄 转换 GenAI Image 为 PIL Image\")\n                result_image = result_image._pil_image\n            \n            logger.info(f\"✅ Gemini Inpainting 成功！API返回尺寸: {result_image.size}, {result_image.mode}\")\n            \n            # 6. Resize 到原图尺寸\n            if result_image.size != final_image.size:\n                logger.info(f\"🔄 Resize 从 {result_image.size} 到 {final_image.size}\")\n                result_image = result_image.resize(final_image.size, Image.LANCZOS)\n            \n            # 7. 合成图像：只在mask区域使用inpaint结果，其他区域保留原图\n            logger.info(\"🎨 合成图像：将inpaint结果与原图按mask合并...\")\n            \n            # 确保所有图像都是RGB模式\n            if result_image.mode != 'RGB':\n                result_image = result_image.convert('RGB')\n            if final_image.mode != 'RGB':\n                final_image = final_image.convert('RGB')\n            \n            # 将mask转换为灰度图（L模式）\n            mask_for_composite = final_mask.convert('L')\n            \n            # 使用PIL的composite方法合成\n            # mask中白色(255)区域使用inpainting结果，黑色(0)区域使用原图\n            composited_image = Image.composite(result_image, final_image, mask_for_composite)\n            logger.info(f\"✅ 图像合成完成！尺寸: {composited_image.size}\")\n            \n            # 8. 裁剪回目标尺寸\n            cropped_result = composited_image.crop(result_crop_box)\n            logger.info(f\"✂️  从完整页面裁剪: {composited_image.size} -> {cropped_result.size}\")\n            return cropped_result\n            \n        except Exception as e:\n            logger.error(f\"❌ Gemini Inpainting 失败: {e}\", exc_info=True)\n            raise\n"
  },
  {
    "path": "backend/services/ai_providers/image/genai_provider.py",
    "content": "\"\"\"\nGoogle GenAI SDK — image generation provider\n\nOperates in two authentication modes selected at construction time:\n  * API-key mode  (Google AI Studio or compatible proxy)\n  * Vertex AI mode (GCP service-account credentials via GOOGLE_APPLICATION_CREDENTIALS)\n\"\"\"\nimport logging\nfrom typing import Optional, List\nfrom google import genai\nfrom google.genai import types\nfrom PIL import Image\nfrom io import BytesIO\nfrom tenacity import retry, stop_after_attempt, wait_exponential\nfrom .base import ImageProvider\nfrom config import get_config\nfrom ..genai_client import make_genai_client\n\nlogger = logging.getLogger(__name__)\n\n\nclass GenAIImageProvider(ImageProvider):\n    \"\"\"Image generation via Google GenAI SDK (AI Studio / Vertex AI)\"\"\"\n\n    def __init__(\n        self,\n        model: str = \"gemini-3-pro-image-preview\",\n        api_key: str = None,\n        api_base: str = None,\n        vertexai: bool = False,\n        project_id: str = None,\n        location: str = None,\n    ):\n        self.client = make_genai_client(\n            vertexai=vertexai,\n            api_key=api_key,\n            api_base=api_base,\n            project_id=project_id,\n            location=location,\n        )\n        self.model = model\n\n    @retry(\n        stop=stop_after_attempt(get_config().GENAI_MAX_RETRIES + 1),\n        wait=wait_exponential(multiplier=1, min=2, max=10),\n        reraise=True\n    )\n    def generate_image(\n        self,\n        prompt: str,\n        ref_images: Optional[List[Image.Image]] = None,\n        aspect_ratio: str = \"16:9\",\n        resolution: str = \"2K\",\n        enable_thinking: bool = True,\n        thinking_budget: int = 1024\n    ) -> Optional[Image.Image]:\n        \"\"\"\n        Generate image using Google GenAI SDK\n        \n        Args:\n            prompt: The image generation prompt\n            ref_images: Optional list of reference images\n            aspect_ratio: Image aspect ratio\n            resolution: Image resolution (supports \"1K\", \"2K\", \"4K\")\n            enable_thinking: If True, enable thinking chain mode (may generate multiple images)\n            thinking_budget: Thinking budget for the model\n            \n        Returns:\n            Generated PIL Image object, or None if failed\n        \"\"\"\n        try:\n            # Build contents list with prompt and reference images\n            contents = []\n            \n            # Add reference images first (if any)\n            if ref_images:\n                for ref_img in ref_images:\n                    contents.append(ref_img)\n            \n            # Add text prompt\n            contents.append(prompt)\n            \n            logger.debug(f\"Calling GenAI API for image generation with {len(ref_images) if ref_images else 0} reference images...\")\n            logger.debug(f\"Config - aspect_ratio: {aspect_ratio}, resolution: {resolution}, enable_thinking: {enable_thinking}\")\n            \n            # Build config\n            config_params = {\n                'response_modalities': ['TEXT', 'IMAGE'],\n                'image_config': types.ImageConfig(\n                    aspect_ratio=aspect_ratio,\n                    image_size=resolution\n                )\n            }\n            \n            # Add thinking config if enabled\n            if enable_thinking:\n                # In Vertex AI (Gemini) Thinking mode, enabling include_thoughts=True requires explicitly setting thinking_budget\n                config_params['thinking_config'] = types.ThinkingConfig(  \n                    thinking_budget=thinking_budget, \n                    include_thoughts=True  \n                )\n            \n            response = self.client.models.generate_content(\n                model=self.model,\n                contents=contents,\n                config=types.GenerateContentConfig(**config_params)\n            )\n            \n            logger.debug(\"GenAI API call completed\")\n            \n            # Extract the final image from the response.\n            # Earlier images are usually low resolution drafts \n            # Therefore, always use the last image found.\n            last_image = None\n            \n            for i, part in enumerate(response.parts):\n                if part.text is not None:\n                    logger.debug(f\"Part {i}: TEXT - {part.text[:100] if len(part.text) > 100 else part.text}\")\n                else:\n                    try:\n                        logger.debug(f\"Part {i}: Attempting to extract image...\")\n                        image = part.as_image()\n                        if image:\n                            # as_image() should return PIL Image directly (official SDK)\n                            # But proxy may return custom Image object, so we need fallbacks\n                            if isinstance(image, Image.Image):\n                                last_image = image\n                            elif hasattr(image, 'image_bytes') and image.image_bytes:\n                                last_image = Image.open(BytesIO(image.image_bytes))\n                            elif hasattr(image, '_pil_image') and image._pil_image:\n                                last_image = image._pil_image\n                            else:\n                                logger.warning(f\"Part {i}: Image object type {type(image)} has no usable conversion method\")\n                                continue\n                            logger.debug(f\"Successfully extracted image from part {i}\")\n                    except Exception as e:\n                        logger.warning(f\"Part {i}: Failed to extract image - {type(e).__name__}: {str(e)}\")\n            \n            # Return the last image found (highest quality in thinking chain scenarios)\n            if last_image:\n                return last_image\n            \n            # No image found in response\n            error_msg = \"No image found in API response. \"\n            if response.parts:\n                error_msg += f\"Response had {len(response.parts)} parts but none contained valid images.\"\n            else:\n                error_msg += \"Response had no parts.\"\n            \n            raise ValueError(error_msg)\n            \n        except Exception as e:\n            error_detail = f\"Error generating image with GenAI: {type(e).__name__}: {str(e)}\"\n            logger.error(error_detail, exc_info=True)\n            raise Exception(error_detail) from e\n"
  },
  {
    "path": "backend/services/ai_providers/image/lazyllm_provider.py",
    "content": "\"\"\"\nLazyllm framework implementation for image editing and generation\n\nSupport models:\n- qwen-image-edit\n- qwen-image-edit-plus\n- qwen-image-edit-plus-2025-10-30\n- ...\n\n- doubao-seedream-4-0-250828\n- doubao-seededit-3-0-i2i-250628\n- doubao-seedream-4.5\n- ...\n\"\"\"\nimport re\nimport tempfile\nimport os\nimport logging\nimport requests\nfrom io import BytesIO\nfrom typing import Optional, List, Tuple\nfrom PIL import Image\nfrom .base import ImageProvider\nfrom ..lazyllm_env import ensure_lazyllm_namespace_key\n\nlogger = logging.getLogger(__name__)\n\n# Vendor-specific image dimension constraints\n# Format: vendor -> (min_dimension, max_dimension, min_total_pixels, separator)\nVENDOR_IMAGE_CONSTRAINTS = {\n    'qwen': {\n        'min_dim': 512,\n        'max_dim': 2048,\n        'min_pixels': None,  # No minimum total pixels requirement\n        'separator': '*',\n    },\n    'doubao': {\n        'min_dim': None,\n        'max_dim': None,\n        'min_pixels': 3686400,  # ~1920x1920, required by seedream models\n        'separator': 'x',\n    },\n}\nDEFAULT_CONSTRAINTS = {\n    'min_dim': None,\n    'max_dim': None,\n    'min_pixels': None,\n    'separator': 'x',\n}\n\n\ndef _calculate_image_dimensions(\n    resolution: str,\n    aspect_ratio: str,\n    source: str\n) -> Tuple[int, int, str]:\n    \"\"\"\n    Calculate image dimensions based on resolution, aspect ratio, and vendor constraints.\n\n    Args:\n        resolution: Resolution preset (1K, 2K, 4K)\n        aspect_ratio: Aspect ratio (16:9, 4:3, 1:1)\n        source: Vendor name (qwen, doubao, etc.)\n\n    Returns:\n        Tuple of (width, height, size_string)\n    \"\"\"\n    aspect_ratios = {\n        \"16:9\": (16, 9),\n        \"9:16\": (9, 16),\n        \"4:3\": (4, 3),\n        \"3:4\": (3, 4),\n        \"3:2\": (3, 2),\n        \"2:3\": (2, 3),\n        \"1:1\": (1, 1),\n    }\n    resolution_base = {\n        \"1K\": 1024,\n        \"2K\": 2048,\n        \"4K\": 4096,\n    }\n\n    constraints = VENDOR_IMAGE_CONSTRAINTS.get(source, DEFAULT_CONSTRAINTS)\n    min_dim = constraints['min_dim']\n    max_dim = constraints['max_dim']\n    min_pixels = constraints['min_pixels']\n    sep = constraints['separator']\n\n    # Start with base resolution\n    base = resolution_base.get(resolution, 2048)\n    if max_dim and base > max_dim:\n        base = max_dim\n\n    # Calculate dimensions from aspect ratio\n    ratio = aspect_ratios.get(aspect_ratio)\n    if not ratio:\n        # Parse arbitrary \"W:H\" format\n        parts = aspect_ratio.split(':')\n        if len(parts) == 2:\n            try:\n                ratio = (int(parts[0]), int(parts[1]))\n            except ValueError:\n                pass\n        if not ratio:\n            logger.warning(f\"Unknown aspect_ratio '{aspect_ratio}', falling back to 16:9\")\n            ratio = (16, 9)\n    if ratio[0] >= ratio[1]:\n        w = base\n        h = int(base * ratio[1] / ratio[0])\n    else:\n        h = base\n        w = int(base * ratio[0] / ratio[1])\n\n    # Scale up if total pixels below minimum (e.g., doubao requires >= 3686400)\n    if min_pixels:\n        total = w * h\n        if total < min_pixels:\n            scale = (min_pixels / total) ** 0.5\n            w = int(w * scale)\n            h = int(h * scale)\n\n    # Round up to nearest multiple of 64 (common GPU alignment requirement)\n    w = max(64, ((w + 63) // 64) * 64)\n    h = max(64, ((h + 63) // 64) * 64)\n\n    # Enforce minimum dimension if specified\n    if min_dim:\n        w = max(min_dim, w)\n        h = max(min_dim, h)\n\n    return w, h, f\"{w}{sep}{h}\"\n\n\nclass LazyLLMImageProvider(ImageProvider):\n    \"\"\"Image generation using Lazyllm framework\"\"\"\n    def __init__(self, source: str = 'doubao', model: str = 'doubao-seedream-4-0-250828'):\n        \"\"\"\n        Initialize GenAI image provider\n\n        Args:\n            source: image_editing model provider, support qwen,doubao,siliconflow now.\n            model: Model name to use\n            type: Category of the online service. Defaults to ``llm``.\n        \"\"\"\n        try:\n            import lazyllm\n        except ModuleNotFoundError as exc:\n            raise RuntimeError(\n                \"lazyllm is required when AI_PROVIDER_FORMAT=lazyllm. \"\n                \"Please install backend dependencies including lazyllm.\"\n            ) from exc\n\n        ensure_lazyllm_namespace_key(source, namespace='BANANA')\n        self._source = source\n        self.client = lazyllm.namespace('BANANA').OnlineModule(\n            source=source,\n            model=model,\n            type='image_editing',\n        )\n\n    def generate_image(self, prompt: str = None,\n                       ref_images: Optional[List[Image.Image]] = None,\n                       aspect_ratio = \"16:9\",\n                       resolution = \"1920*1080\",\n                       enable_thinking: bool = False,\n                       thinking_budget: int = 0\n                       ) -> Optional[Image.Image]:\n        # Calculate vendor-specific image dimensions\n        w, h, size_str = _calculate_image_dimensions(resolution, aspect_ratio, self._source)\n        logger.info(f\"[LazyLLM] aspect_ratio={aspect_ratio}, resolution={resolution}, size={size_str}\")\n        # Convert a PIL Image object to a file path: When passing a reference image to lazyllm, you need to input a path in string format.\n        file_paths = None\n        temp_paths = []\n        decode_query_with_filepaths = None\n        try:\n            from lazyllm.components.formatter import decode_query_with_filepaths as _decoder\n            decode_query_with_filepaths = _decoder\n        except ModuleNotFoundError as exc:\n            raise RuntimeError(\n                \"lazyllm is required when AI_PROVIDER_FORMAT=lazyllm. \"\n                \"Please install backend dependencies including lazyllm.\"\n            ) from exc\n        if ref_images:\n            file_paths = []\n            for img in ref_images:\n                with tempfile.NamedTemporaryFile(prefix='lazyllm_ref_', suffix='.png', delete=False) as tmp:\n                    temp_path = tmp.name\n                img.save(temp_path)\n                file_paths.append(temp_path)\n                temp_paths.append(temp_path)\n        try:\n            try:\n                response_path = self.client(prompt, lazyllm_files=file_paths, size=size_str)\n            except Exception as client_err:\n                # LazyLLM may fail internally when the image URL returns application/octet-stream\n                # instead of image/*. In that case, extract the URL and download manually.\n                err_str = str(client_err)\n                if 'content type' in err_str.lower() or 'Failed to load image from' in err_str:\n                    url_match = re.search(r'(https://[^\\s\"\\'<>]+)', err_str)\n                    if url_match:\n                        url = url_match.group(1).rstrip('.')\n                        # Only fetch from known image-hosting domains to prevent SSRF\n                        from urllib.parse import urlparse\n                        host = urlparse(url).hostname or ''\n                        allowed = host == 's3.siliconflow.cn' or host.endswith('.s3.amazonaws.com')\n                        if not allowed:\n                            logger.warning(f\"[LazyLLM] Untrusted host '{host}', skipping manual download\")\n                            raise\n                        logger.warning(\n                            f\"[LazyLLM] Content-type mismatch, downloading image manually: {url[:80]}...\"\n                        )\n                        max_size = 20 * 1024 * 1024  # 20 MB\n                        resp = requests.get(url, timeout=60, stream=True)\n                        resp.raise_for_status()\n                        content = b\"\"\n                        for chunk in resp.iter_content(chunk_size=8192):\n                            content += chunk\n                            if len(content) > max_size:\n                                raise ValueError(f\"Image too large (>{max_size // 1024 // 1024}MB)\")\n                        result = Image.open(BytesIO(content)).copy()\n                        logger.info(f\"[LazyLLM] Manual download succeeded, size: {result.size}\")\n                        return result\n                raise\n\n            image_path = decode_query_with_filepaths(response_path) # dict\n            if not image_path:\n                logger.warning('No images found in response')\n                raise ValueError()\n            if isinstance(image_path, dict):\n                files = image_path.get('files')\n                if files and isinstance(files, list) and len(files) > 0:\n                    image_path = files[0]\n                else:\n                    logger.warning('No valid image path in response')\n                    return None\n            try:\n                with Image.open(image_path) as image:\n                    result = image.copy()\n                logger.info(f'Successfully loaded image from: {image_path}, actual size: {result.size[0]}x{result.size[1]} (requested: {size_str})')\n                return result\n            except Exception as e:\n                logger.error(f'Failed to load image: {e}')\n            logger.warning('No valid images could be loaded')\n            return None\n        finally:\n            for temp_path in temp_paths:\n                try:\n                    os.remove(temp_path)\n                except OSError:\n                    pass\n"
  },
  {
    "path": "backend/services/ai_providers/image/openai_provider.py",
    "content": "\"\"\"\nOpenAI SDK implementation for image generation\n\nSupports multiple resolution parameter formats for different OpenAI-compatible providers:\n- Flat style: extra_body.aspect_ratio + extra_body.resolution\n- Nested style: extra_body.generationConfig.imageConfig.aspectRatio + imageSize\n\nNote: Not all providers support 2K/4K resolution in OpenAI format.\nSome may only return 1K regardless of settings.\nResolution validation is handled at the task_manager level for all providers.\n\"\"\"\nimport logging\nimport base64\nimport re\nimport requests\nfrom io import BytesIO\nfrom typing import Optional, List\nfrom openai import OpenAI\nfrom PIL import Image\nfrom .base import ImageProvider\nfrom config import get_config\n\nlogger = logging.getLogger(__name__)\n\n\nclass OpenAIImageProvider(ImageProvider):\n    \"\"\"\n    Image generation using OpenAI SDK (compatible with Gemini via proxy)\n    \n    Supports multiple resolution parameter formats for different providers.\n    Resolution support varies by provider:\n    - Some providers support 2K/4K via extra_body parameters\n    - Some providers only support 1K regardless of settings\n    \n    The provider will try multiple parameter formats to maximize compatibility.\n    \"\"\"\n    \n    def __init__(self, api_key: str, api_base: str = None, model: str = \"gemini-3-pro-image-preview\"):\n        \"\"\"\n        Initialize OpenAI image provider\n        \n        Args:\n            api_key: API key\n            api_base: API base URL (e.g., https://aihubmix.com/v1)\n            model: Model name to use\n        \"\"\"\n        self.client = OpenAI(\n            api_key=api_key,\n            base_url=api_base,\n            timeout=get_config().OPENAI_TIMEOUT,  # set timeout from config\n            max_retries=get_config().OPENAI_MAX_RETRIES  # set max retries from config\n        )\n        self.api_base = api_base or \"\"\n        self.model = model\n    \n    def _encode_image_to_base64(self, image: Image.Image) -> str:\n        \"\"\"\n        Encode PIL Image to base64 string\n        \n        Args:\n            image: PIL Image object\n            \n        Returns:\n            Base64 encoded string\n        \"\"\"\n        buffered = BytesIO()\n        # Convert to RGB if necessary (e.g., RGBA images)\n        if image.mode in ('RGBA', 'LA', 'P'):\n            image = image.convert('RGB')\n        image.save(buffered, format=\"JPEG\", quality=95)\n        return base64.b64encode(buffered.getvalue()).decode('utf-8')\n    \n    def _build_extra_body(self, aspect_ratio: str, resolution: str) -> dict:\n        \"\"\"\n        Build extra_body parameters for resolution control.\n        \n        Uses multiple format strategies to support different providers:\n        1. Flat style: aspect_ratio + resolution at top level\n        2. Nested style: generationConfig.imageConfig structure\n        \n        Args:\n            aspect_ratio: Image aspect ratio (e.g., \"16:9\", \"9:16\")\n            resolution: Image resolution (\"1K\", \"2K\", \"4K\")\n            \n        Returns:\n            Dict with extra_body parameters\n        \"\"\"\n        # Ensure resolution is uppercase (some providers require \"4K\" not \"4k\")\n        resolution_upper = resolution.upper()\n        \n        # Build comprehensive extra_body that works with multiple providers\n        extra_body = {\n            # Flat style parameters\n            \"aspect_ratio\": aspect_ratio,\n            \"resolution\": resolution_upper,\n            \n            # Nested style structure (compatible with some providers)\n            \"generationConfig\": {\n                \"imageConfig\": {\n                    \"aspectRatio\": aspect_ratio,\n                    \"imageSize\": resolution_upper,\n                }\n            }\n        }\n        \n        return extra_body\n\n    def generate_image(\n        self,\n        prompt: str,\n        ref_images: Optional[List[Image.Image]] = None,\n        aspect_ratio: str = \"16:9\",\n        resolution: str = \"2K\",\n        enable_thinking: bool = False,\n        thinking_budget: int = 0\n    ) -> Optional[Image.Image]:\n        \"\"\"\n        Generate image using OpenAI SDK\n        \n        Supports resolution control via extra_body parameters for compatible providers.\n        Note: Not all providers support 2K/4K resolution - some may return 1K regardless.\n        Note: enable_thinking and thinking_budget are ignored (OpenAI format doesn't support thinking mode)\n        \n        The provider will:\n        1. Try to use extra_body parameters (API易/AvalAI style) for resolution control\n        2. Use system message for aspect_ratio as fallback\n        \n        Args:\n            prompt: The image generation prompt\n            ref_images: Optional list of reference images\n            aspect_ratio: Image aspect ratio\n            resolution: Image resolution (\"1K\", \"2K\", \"4K\") - support depends on provider\n            enable_thinking: Ignored, kept for interface compatibility\n            thinking_budget: Ignored, kept for interface compatibility\n            \n        Returns:\n            Generated PIL Image object, or None if failed\n        \"\"\"\n        try:\n            # Build message content\n            content = []\n            \n            # Add reference images first (if any)\n            if ref_images:\n                for ref_img in ref_images:\n                    base64_image = self._encode_image_to_base64(ref_img)\n                    content.append({\n                        \"type\": \"image_url\",\n                        \"image_url\": {\n                            \"url\": f\"data:image/jpeg;base64,{base64_image}\"\n                        }\n                    })\n            \n            # Add text prompt\n            content.append({\"type\": \"text\", \"text\": prompt})\n            \n            logger.debug(f\"Calling OpenAI API for image generation with {len(ref_images) if ref_images else 0} reference images...\")\n            logger.debug(f\"Config - aspect_ratio: {aspect_ratio}, resolution: {resolution}\")\n            \n            # Build extra_body with resolution parameters for compatible providers\n            extra_body = self._build_extra_body(aspect_ratio, resolution)\n            logger.debug(f\"Using extra_body for resolution control: {extra_body}\")\n            \n            # Use both system message (for basic providers) and extra_body (for advanced providers)\n            response = self.client.chat.completions.create(\n                model=self.model,\n                messages=[\n                    {\"role\": \"system\", \"content\": f\"aspect_ratio={aspect_ratio}, resolution={resolution}\"},\n                    {\"role\": \"user\", \"content\": content},\n                ],\n                modalities=[\"text\", \"image\"],\n                extra_body=extra_body\n            )\n            \n            logger.debug(\"OpenAI API call completed\")\n            \n            # Extract image from response - handle different response formats\n            message = response.choices[0].message\n            \n            # Debug: log available attributes\n            logger.debug(f\"Response message attributes: {dir(message)}\")\n            \n            # Try multi_mod_content first (custom format from some proxies)\n            if hasattr(message, 'multi_mod_content') and message.multi_mod_content:\n                parts = message.multi_mod_content\n                for part in parts:\n                    if \"text\" in part:\n                        logger.debug(f\"Response text: {part['text'][:100] if len(part['text']) > 100 else part['text']}\")\n                    if \"inline_data\" in part:\n                        image_data = base64.b64decode(part[\"inline_data\"][\"data\"])\n                        image = Image.open(BytesIO(image_data))\n                        logger.debug(f\"Successfully extracted image: {image.size}, {image.mode}\")\n                        return image\n            \n            # Try standard OpenAI content format (list of content parts)\n            if hasattr(message, 'content') and message.content:\n                # If content is a list (multimodal response)\n                if isinstance(message.content, list):\n                    for part in message.content:\n                        if isinstance(part, dict):\n                            # Handle image_url type\n                            if part.get('type') == 'image_url':\n                                image_url = part.get('image_url', {}).get('url', '')\n                                if image_url.startswith('data:image'):\n                                    # Extract base64 data from data URL\n                                    base64_data = image_url.split(',', 1)[1]\n                                    image_data = base64.b64decode(base64_data)\n                                    image = Image.open(BytesIO(image_data))\n                                    logger.debug(f\"Successfully extracted image from content: {image.size}, {image.mode}\")\n                                    return image\n                            # Handle text type\n                            elif part.get('type') == 'text':\n                                text = part.get('text', '')\n                                if text:\n                                    logger.debug(f\"Response text: {text[:100] if len(text) > 100 else text}\")\n                        elif hasattr(part, 'type'):\n                            # Handle as object with attributes\n                            if part.type == 'image_url':\n                                image_url = getattr(part, 'image_url', {})\n                                if isinstance(image_url, dict):\n                                    url = image_url.get('url', '')\n                                else:\n                                    url = getattr(image_url, 'url', '')\n                                if url.startswith('data:image'):\n                                    base64_data = url.split(',', 1)[1]\n                                    image_data = base64.b64decode(base64_data)\n                                    image = Image.open(BytesIO(image_data))\n                                    logger.debug(f\"Successfully extracted image from content object: {image.size}, {image.mode}\")\n                                    return image\n                # If content is a string, try to extract image from it\n                elif isinstance(message.content, str):\n                    content_str = message.content\n                    logger.debug(f\"Response content (string): {content_str[:200] if len(content_str) > 200 else content_str}\")\n                    \n                    # Try to extract Markdown image URL: ![...](url)\n                    markdown_pattern = r'!\\[.*?\\]\\((https?://[^\\s\\)]+)\\)'\n                    markdown_matches = re.findall(markdown_pattern, content_str)\n                    if markdown_matches:\n                        image_url = markdown_matches[0]  # Use the first image URL found\n                        logger.debug(f\"Found Markdown image URL: {image_url}\")\n                        try:\n                            response = requests.get(image_url, timeout=30, stream=True)\n                            response.raise_for_status()\n                            image = Image.open(BytesIO(response.content))\n                            image.load()  # Ensure image is fully loaded\n                            logger.debug(f\"Successfully downloaded image from Markdown URL: {image.size}, {image.mode}\")\n                            return image\n                        except Exception as download_error:\n                            logger.warning(f\"Failed to download image from Markdown URL: {download_error}\")\n                    \n                    # Try to extract plain URL (not in Markdown format)\n                    url_pattern = r'(https?://[^\\s\\)\\]]+\\.(?:png|jpg|jpeg|gif|webp|bmp)(?:\\?[^\\s\\)\\]]*)?)'\n                    url_matches = re.findall(url_pattern, content_str, re.IGNORECASE)\n                    if url_matches:\n                        image_url = url_matches[0]\n                        logger.debug(f\"Found plain image URL: {image_url}\")\n                        try:\n                            response = requests.get(image_url, timeout=30, stream=True)\n                            response.raise_for_status()\n                            image = Image.open(BytesIO(response.content))\n                            image.load()\n                            logger.debug(f\"Successfully downloaded image from plain URL: {image.size}, {image.mode}\")\n                            return image\n                        except Exception as download_error:\n                            logger.warning(f\"Failed to download image from plain URL: {download_error}\")\n                    \n                    # Try to extract base64 data URL from string\n                    base64_pattern = r'data:image/[^;]+;base64,([A-Za-z0-9+/=]+)'\n                    base64_matches = re.findall(base64_pattern, content_str)\n                    if base64_matches:\n                        base64_data = base64_matches[0]\n                        logger.debug(f\"Found base64 image data in string\")\n                        try:\n                            image_data = base64.b64decode(base64_data)\n                            image = Image.open(BytesIO(image_data))\n                            logger.debug(f\"Successfully extracted base64 image from string: {image.size}, {image.mode}\")\n                            return image\n                        except Exception as decode_error:\n                            logger.warning(f\"Failed to decode base64 image from string: {decode_error}\")\n            \n            # Log raw response for debugging\n            logger.warning(f\"Unable to extract image. Raw message type: {type(message)}\")\n            logger.warning(f\"Message content type: {type(getattr(message, 'content', None))}\")\n            raw = str(getattr(message, 'content', 'N/A'))\n            logger.warning(f\"Message content: {raw[:300]}{'...(truncated)' if len(raw) > 300 else ''}\")\n            \n            raise ValueError(\"No valid multimodal response received from OpenAI API\")\n            \n        except Exception as e:\n            error_detail = f\"Error generating image with OpenAI (model={self.model}): {type(e).__name__}: {str(e)}\"\n            logger.error(error_detail, exc_info=True)\n            raise Exception(error_detail) from e\n"
  },
  {
    "path": "backend/services/ai_providers/image/volcengine_inpainting_provider.py",
    "content": "\"\"\"\n火山引擎 Inpainting 消除服务提供者\n直接HTTP调用，完全绕过SDK限制\n\"\"\"\nimport logging\nimport base64\nimport json\nimport requests\nfrom datetime import datetime\nfrom io import BytesIO\nfrom typing import Optional\nfrom PIL import Image\nfrom tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type\n\nlogger = logging.getLogger(__name__)\n\n\nclass VolcengineInpaintingProvider:\n    \"\"\"火山引擎 Inpainting 消除服务（直接HTTP调用）\"\"\"\n    \n    API_URL = \"https://visual.volcengineapi.com\"\n    SERVICE = \"cv\"\n    REGION = \"cn-north-1\"\n    \n    def __init__(self, access_key: str, secret_key: str, timeout: int = 60):\n        \"\"\"\n        初始化火山引擎 Inpainting 提供者\n        \n        Args:\n            access_key: 火山引擎 Access Key  \n            secret_key: 火山引擎 Secret Key\n            timeout: API 请求超时时间（秒）\n        \"\"\"\n        self.access_key = access_key\n        self.secret_key = secret_key\n        self.timeout = timeout\n        logger.info(\"火山引擎 Inpainting Provider 初始化（直接HTTP模式）\")\n        \n    def _encode_image_to_base64(self, image: Image.Image, is_mask: bool = False) -> str:\n        \"\"\"\n        将 PIL Image 编码为 base64 字符串\n        \n        Args:\n            image: PIL Image对象\n            is_mask: 是否是mask图（mask需要特殊处理）\n        \"\"\"\n        buffered = BytesIO()\n        \n        if is_mask:\n            # Mask要求：单通道灰度图，或RGB值相等的三通道图\n            # 转换为灰度图以确保正确\n            if image.mode != 'L':\n                image = image.convert('L')\n            # 保存为PNG（文档要求8bit PNG，不嵌入ICC Profile）\n            image.save(buffered, format=\"PNG\", optimize=True)\n        else:\n            # 原图：转换为 RGB\n            if image.mode in ('RGBA', 'LA', 'P'):\n                if image.mode == 'RGBA':\n                    background = Image.new('RGB', image.size, (255, 255, 255))\n                    background.paste(image, mask=image.split()[3])\n                    image = background\n                else:\n                    image = image.convert('RGB')\n            # 保存为 JPEG 减小大小\n            image.save(buffered, format=\"JPEG\", quality=85)\n        \n        return base64.b64encode(buffered.getvalue()).decode('utf-8')\n    \n    @retry(\n        stop=stop_after_attempt(3),  # 最多重试3次\n        wait=wait_exponential(multiplier=1, min=2, max=10),  # 指数避让: 2s, 4s, 8s\n        retry=retry_if_exception_type((requests.exceptions.RequestException, Exception)),\n        reraise=True\n    )\n    def inpaint_image(\n        self,\n        original_image: Image.Image,\n        mask_image: Image.Image,\n        inpaint_mode: str = \"remove\",\n        full_page_image: Optional[Image.Image] = None,\n        crop_box: Optional[tuple] = None\n    ) -> Optional[Image.Image]:\n        \"\"\"\n        使用掩码消除图像中的指定区域（带指数避让重试）\n        \n        Args:\n            original_image: 原始图像\n            mask_image: 掩码图像（白色=消除，黑色=保留）\n            inpaint_mode: 修复模式\n            \n        Returns:\n            处理后的图像，失败返回 None\n        \"\"\"\n        try:\n            logger.info(\"🚀 开始调用火山引擎 inpainting（直接HTTP）\")\n            \n            # 1. 压缩图片（火山引擎限制5MB）\n            max_dimension = 2048\n            if max(original_image.size) > max_dimension:\n                ratio = max_dimension / max(original_image.size)\n                new_size = tuple(int(dim * ratio) for dim in original_image.size)\n                original_image = original_image.resize(new_size, Image.LANCZOS)\n                mask_image = mask_image.resize(new_size, Image.LANCZOS)\n                logger.info(f\"✂️ 压缩图片: {original_image.size}\")\n            \n            # 2. 编码为base64（mask要特殊处理为灰度图）\n            logger.info(\"📦 编码图片为base64...\")\n            original_base64 = self._encode_image_to_base64(original_image, is_mask=False)\n            mask_base64 = self._encode_image_to_base64(mask_image, is_mask=True)\n            logger.info(f\"✅ 编码完成: 原图={len(original_base64)} bytes, mask={len(mask_base64)} bytes\")\n            \n            # 3. 构建请求参数（按官方文档）\n            # 参考：https://www.volcengine.com/docs/86081/1804489\n            # mask要求：黑色(0)=保留，白色(255)=消除\n            request_body = {\n                \"req_key\": \"i2i_inpainting\",\n                \"binary_data_base64\": [original_base64, mask_base64],\n                \"dilate_size\": 10,  # mask膨胀半径，帮助完整消除\n                \"quality\": \"H\",  # 高质量模式（最高质量）\n                \"steps\": 50,  # 采样步数，越大效果越好但耗时更长（默认30）\n                \"strength\": 0.85  # 控制强度，越大越接近文本控制（默认0.8）\n            }\n            \n            # 4. 构建请求URL\n            url = f\"{self.API_URL}/?Action=CVProcess&Version=2022-08-31\"\n            \n            # 5. 构建请求头（简化版，使用AK/SK直接认证）\n            headers = {\n                \"Content-Type\": \"application/json\",\n                \"X-Date\": datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')\n            }\n            \n            logger.info(f\"🌐 发送请求到: {url}\")\n            logger.debug(f\"请求体大小: {len(json.dumps(request_body))} bytes\")\n            \n            # 6. 使用SDK（它会处理签名）\n            from volcengine.visual.VisualService import VisualService\n            service = VisualService()\n            service.set_ak(self.access_key)\n            service.set_sk(self.secret_key)\n            \n            # 使用SDK的json_handler方法（这个方法会处理签名）\n            logger.info(\"使用SDK发送请求（带正确签名）\")\n            \n            try:\n                # 使用SDK的通用API调用方法\n                response = service.json(\n                    \"CVProcess\",\n                    {},  # query params\n                    json.dumps(request_body)  # body\n                )\n                \n                # 解析响应\n                if isinstance(response, str):\n                    response = json.loads(response)\n                    \n            except Exception as e:\n                error_str = str(e)\n                logger.error(f\"SDK调用错误: {error_str}\")\n                \n                # 尝试从错误信息中提取JSON响应\n                if error_str.startswith(\"b'\") and error_str.endswith(\"'\"):\n                    try:\n                        response_text = error_str[2:-1]  # 去掉 b' 和 '\n                        response = json.loads(response_text)\n                    except Exception:\n                        logger.error(\"无法解析错误响应\")\n                        return None\n                else:\n                    return None\n            \n            # 8. 解析响应\n            logger.debug(f\"API响应: {json.dumps(response, ensure_ascii=False)[:300]}\")\n            \n            if response.get(\"code\") == 10000 or response.get(\"status\") == 10000:\n                data = response.get(\"data\", {})\n                \n                # 尝试多种响应格式\n                result_base64 = None\n                if \"binary_data_base64\" in data and data[\"binary_data_base64\"]:\n                    result_base64 = data[\"binary_data_base64\"][0]\n                elif \"image_base64\" in data:\n                    result_base64 = data[\"image_base64\"]\n                elif \"result_image\" in data:\n                    result_base64 = data[\"result_image\"]\n                \n                if result_base64:\n                    image_data = base64.b64decode(result_base64)\n                    inpainted_image = Image.open(BytesIO(image_data))\n                    logger.info(f\"✅ Inpainting成功！结果: {inpainted_image.size}, {inpainted_image.mode}\")\n                    \n                    # 合成：只取inpainting结果的mask区域，其他区域用原图覆盖\n                    # 确保尺寸一致\n                    if inpainted_image.size != original_image.size:\n                        logger.warning(f\"尺寸不一致，调整inpainting结果: {inpainted_image.size} -> {original_image.size}\")\n                        inpainted_image = inpainted_image.resize(original_image.size, Image.LANCZOS)\n                    \n                    # 确保mask尺寸一致\n                    if mask_image.size != original_image.size:\n                        mask_image = mask_image.resize(original_image.size, Image.LANCZOS)\n                    \n                    # 确保inpainted_image是RGB模式\n                    if inpainted_image.mode != 'RGB':\n                        inpainted_image = inpainted_image.convert('RGB')\n                    if original_image.mode != 'RGB':\n                        original_image = original_image.convert('RGB')\n                    \n                    # 确保mask是L模式（灰度图）\n                    mask_for_composite = mask_image.convert('L')\n                    \n                    # 使用PIL的composite方法合成图像\n                    # mask中白色(255)区域使用inpainting结果，黑色(0)区域使用原图\n                    # 注意：Image.composite使用mask，其中白色表示使用image1，黑色表示使用image2\n                    # 所以这里image1是inpainting结果，image2是原图\n                    result_image = Image.composite(inpainted_image, original_image, mask_for_composite)\n                    \n                    logger.info(f\"✅ 图像合成完成！最终尺寸: {result_image.size}, {result_image.mode}\")\n                    return result_image\n                else:\n                    logger.error(f\"❌ 响应中无图像数据，keys: {list(data.keys())}\")\n                    return None\n            else:\n                code = response.get(\"code\") or response.get(\"status\")\n                message = response.get(\"message\", \"未知错误\")\n                logger.error(f\"❌ API错误: code={code}, message={message}\")\n                return None\n                \n        except Exception as e:\n            logger.error(f\"❌ Inpainting失败: {str(e)}\", exc_info=True)\n            return None\n    \n"
  },
  {
    "path": "backend/services/ai_providers/lazyllm_env.py",
    "content": "\"\"\"Utilities for resolving LazyLLM API keys from vendor-prefixed env vars.\"\"\"\nimport json\nimport os\n\nALLOWED_LAZYLLM_VENDORS = frozenset({\n    'qwen', 'doubao', 'deepseek', 'glm', 'siliconflow',\n    'sensenova', 'minimax', 'openai', 'kimi',\n})\n\n\ndef collect_env_lazyllm_api_keys() -> str | None:\n    \"\"\"Scan env vars for {VENDOR}_API_KEY and return JSON string, or None.\"\"\"\n    keys = {}\n    for vendor in ALLOWED_LAZYLLM_VENDORS:\n        val = os.getenv(f\"{vendor.upper()}_API_KEY\", \"\")\n        if val:\n            keys[vendor] = val\n    return json.dumps(keys) if keys else None\n\n\ndef get_lazyllm_api_key(source: str, namespace: str = \"BANANA\") -> str:\n    \"\"\"\n    Resolve API key for a LazyLLM source from vendor-prefixed key only.\n\n    Expected format: {SOURCE}_API_KEY, e.g. QWEN_API_KEY.\n    \"\"\"\n    source_upper = (source or \"\").upper()\n    if not source_upper:\n        return \"\"\n    return os.getenv(f\"{source_upper}_API_KEY\", \"\")\n\n\ndef ensure_lazyllm_namespace_key(source: str, namespace: str = \"BANANA\") -> bool:\n    \"\"\"\n    Ensure LazyLLM namespace key exists by mapping from vendor-prefixed key.\n    \"\"\"\n    source_upper = (source or \"\").upper()\n    if not source_upper:\n        return False\n\n    namespace_key = f\"{namespace}_{source_upper}_API_KEY\"\n    resolved_key = get_lazyllm_api_key(source, namespace=namespace)\n    if resolved_key:\n        os.environ[namespace_key] = resolved_key\n        return True\n    return False\n"
  },
  {
    "path": "backend/services/ai_providers/ocr/__init__.py",
    "content": "\"\"\"OCR相关的AI Provider\"\"\"\n\nfrom services.ai_providers.ocr.baidu_table_ocr_provider import (\n    BaiduTableOCRProvider,\n    create_baidu_table_ocr_provider\n)\nfrom services.ai_providers.ocr.baidu_accurate_ocr_provider import (\n    BaiduAccurateOCRProvider,\n    create_baidu_accurate_ocr_provider\n)\n\n__all__ = [\n    'BaiduTableOCRProvider',\n    'create_baidu_table_ocr_provider',\n    'BaiduAccurateOCRProvider',\n    'create_baidu_accurate_ocr_provider',\n]\n\n"
  },
  {
    "path": "backend/services/ai_providers/ocr/baidu_accurate_ocr_provider.py",
    "content": "\"\"\"\n百度通用文字识别（高精度含位置版）OCR Provider\n提供多场景、多语种、高精度的整图文字检测和识别服务，支持返回文字位置信息\n\nAPI文档: https://ai.baidu.com/ai-doc/OCR/1k3h7y3db\n\"\"\"\nimport logging\nimport base64\nimport requests\nimport urllib.parse\nfrom typing import Dict, List, Any, Optional, Literal\nfrom PIL import Image\nimport io\nfrom tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type\n\nlogger = logging.getLogger(__name__)\n\n\n# 支持的语言类型\nLanguageType = Literal[\n    'auto_detect',  # 自动检测语言\n    'CHN_ENG',      # 中英文混合\n    'ENG',          # 英文\n    'JAP',          # 日语\n    'KOR',          # 韩语\n    'FRE',          # 法语\n    'SPA',          # 西班牙语\n    'POR',          # 葡萄牙语\n    'GER',          # 德语\n    'ITA',          # 意大利语\n    'RUS',          # 俄语\n    'DAN',          # 丹麦语\n    'DUT',          # 荷兰语\n    'MAL',          # 马来语\n    'SWE',          # 瑞典语\n    'IND',          # 印尼语\n    'POL',          # 波兰语\n    'ROM',          # 罗马尼亚语\n    'TUR',          # 土耳其语\n    'GRE',          # 希腊语\n    'HUN',          # 匈牙利语\n    'THA',          # 泰语\n    'VIE',          # 越南语\n    'ARA',          # 阿拉伯语\n    'HIN',          # 印地语\n]\n\n\nclass BaiduAccurateOCRProvider:\n    \"\"\"\n    百度高精度OCR Provider - 通用文字识别（高精度含位置版）\n    \n    特点:\n    - 高精度文字识别\n    - 支持25种语言\n    - 返回文字位置信息（支持行级别和字符级别）\n    - 支持图片朝向检测\n    - 支持段落输出\n    \"\"\"\n\n    def __init__(self, api_key: str):\n        \"\"\"\n        初始化百度高精度OCR Provider\n\n        Args:\n            api_key: 百度API Key（BCEv3格式：bce-v3/ALTAK-...）或Access Token\n        \"\"\"\n        self.api_key = api_key\n        self.api_url = \"https://aip.baidubce.com/rest/2.0/ocr/v1/accurate\"\n\n        if api_key.startswith('bce-v3/'):\n            logger.info(\"✅ 初始化百度高精度OCR Provider (使用BCEv3 API Key)\")\n        else:\n            logger.info(\"✅ 初始化百度高精度OCR Provider (使用Access Token)\")\n    \n    @retry(\n        stop=stop_after_attempt(3),  # 最多重试3次\n        wait=wait_exponential(multiplier=0.5, min=1, max=5),  # 指数避让: 1s, 2s, 4s\n        retry=retry_if_exception_type((requests.exceptions.RequestException, Exception)),\n        reraise=True\n    )\n    def recognize(\n        self,\n        image_path: str,\n        language_type: LanguageType = 'CHN_ENG',\n        recognize_granularity: Literal['big', 'small'] = 'big',\n        detect_direction: bool = False,\n        vertexes_location: bool = False,\n        paragraph: bool = False,\n        probability: bool = False,\n        char_probability: bool = False,\n        multidirectional_recognize: bool = False,\n        eng_granularity: Optional[Literal['word', 'letter']] = None,\n    ) -> Dict[str, Any]:\n        \"\"\"\n        识别图片中的文字（高精度含位置版）\n        \n        Args:\n            image_path: 图片路径\n            language_type: 识别语言类型，默认中英文混合\n            recognize_granularity: 是否定位单字符位置，big=不定位，small=定位\n            detect_direction: 是否检测图像朝向\n            vertexes_location: 是否返回文字外接多边形顶点位置\n            paragraph: 是否输出段落信息\n            probability: 是否返回每一行的置信度\n            char_probability: 是否返回单字符置信度（需要recognize_granularity=small）\n            multidirectional_recognize: 是否开启行级别的多方向文字识别\n            eng_granularity: 英文单字符结果维度（word/letter），当recognize_granularity=small时生效\n            \n        Returns:\n            识别结果字典，包含:\n            - log_id: 唯一日志ID\n            - words_result_num: 识别结果数\n            - words_result: 识别结果数组\n                - words: 识别的文字\n                - location: 位置信息 {left, top, width, height}\n                - chars: 单字符结果（当recognize_granularity=small时）\n                - probability: 置信度（当probability=true时）\n                - vertexes_location: 外接多边形顶点（当vertexes_location=true时）\n            - direction: 图像方向（当detect_direction=true时）\n            - paragraphs_result: 段落结果（当paragraph=true时）\n            - image_size: 原始图片尺寸\n        \"\"\"\n        logger.info(f\"🔍 开始高精度OCR识别: {image_path}\")\n        \n        try:\n            # 读取图片并转为base64\n            original_width, original_height = 0, 0\n            with Image.open(image_path) as img:\n                # 获取原始图片尺寸\n                original_width, original_height = img.size\n                logger.info(f\"📏 图片尺寸: {original_width}x{original_height}\")\n                \n                # 转换为RGB模式\n                if img.mode != 'RGB':\n                    img = img.convert('RGB')\n                \n                # 压缩图片(如果太大) - 最长边不超过8192px，最短边至少15px\n                max_size = 8192\n                min_size = 15\n                width, height = img.size\n                \n                if width < min_size or height < min_size:\n                    logger.warning(f\"⚠️ 图片太小: {width}x{height}, 最短边需要至少{min_size}px\")\n                \n                if width > max_size or height > max_size:\n                    ratio = min(max_size / width, max_size / height)\n                    new_size = (int(width * ratio), int(height * ratio))\n                    img = img.resize(new_size, Image.Resampling.LANCZOS)\n                    logger.info(f\"✂️ 压缩图片: {img.size}\")\n                \n                # 转为base64\n                buffer = io.BytesIO()\n                img.save(buffer, format='JPEG', quality=95)\n                image_bytes = buffer.getvalue()\n                image_base64 = base64.b64encode(image_bytes).decode('utf-8')\n                \n                # URL encode\n                image_encoded = urllib.parse.quote(image_base64)\n                logger.info(f\"📦 图片编码完成: base64={len(image_base64)} bytes\")\n            \n            # 构建请求头\n            headers = {\n                'Content-Type': 'application/x-www-form-urlencoded',\n                'Accept': 'application/json',\n            }\n            \n            # 选择认证方式\n            if self.api_key.startswith('bce-v3/'):\n                # 使用BCEv3签名认证 (Authorization头部)\n                headers['Authorization'] = f'Bearer {self.api_key}'\n                url = self.api_url\n                logger.info(\"🔐 使用BCEv3签名认证\")\n            else:\n                # 使用Access Token (URL参数)\n                url = f\"{self.api_url}?access_token={self.api_key}\"\n                logger.info(\"🔐 使用Access Token认证\")\n            \n            # 构建表单数据\n            form_data = {\n                'image': image_encoded,\n                'language_type': language_type,\n                'recognize_granularity': recognize_granularity,\n                'detect_direction': 'true' if detect_direction else 'false',\n                'vertexes_location': 'true' if vertexes_location else 'false',\n                'paragraph': 'true' if paragraph else 'false',\n                'probability': 'true' if probability else 'false',\n                'multidirectional_recognize': 'true' if multidirectional_recognize else 'false',\n            }\n            \n            if recognize_granularity == 'small' and char_probability:\n                form_data['char_probability'] = 'true'\n            \n            if recognize_granularity == 'small' and eng_granularity:\n                form_data['eng_granularity'] = eng_granularity\n            \n            # 转换为URL编码的表单数据\n            data = '&'.join([f\"{k}={v}\" for k, v in form_data.items()])\n            \n            logger.info(\"🌐 发送请求到百度高精度OCR API...\")\n            response = requests.post(url, headers=headers, data=data, timeout=60)\n            response.raise_for_status()\n            \n            result = response.json()\n            \n            # 检查错误\n            if 'error_code' in result:\n                error_msg = result.get('error_msg', 'Unknown error')\n                error_code = result.get('error_code')\n                logger.error(f\"❌ 百度API错误: [{error_code}] {error_msg}\")\n                raise Exception(f\"Baidu API error [{error_code}]: {error_msg}\")\n            \n            # 解析结果\n            log_id = result.get('log_id', '')\n            words_result_num = result.get('words_result_num', 0)\n            words_result = result.get('words_result', [])\n            direction = result.get('direction', None)\n            paragraphs_result_num = result.get('paragraphs_result_num', 0)\n            paragraphs_result = result.get('paragraphs_result', [])\n            \n            logger.info(f\"✅ 高精度OCR识别成功! log_id={log_id}, 识别到 {words_result_num} 行文字\")\n            \n            # 解析文字行信息\n            text_lines = []\n            for line in words_result:\n                line_info = {\n                    'text': line.get('words', ''),\n                    'location': line.get('location', {}),\n                    'bbox': self._location_to_bbox(line.get('location', {})),\n                }\n                \n                # 单字符结果\n                if 'chars' in line:\n                    line_info['chars'] = []\n                    for char in line['chars']:\n                        char_info = {\n                            'char': char.get('char', ''),\n                            'location': char.get('location', {}),\n                            'bbox': self._location_to_bbox(char.get('location', {})),\n                        }\n                        if 'char_prob' in char:\n                            char_info['probability'] = char['char_prob']\n                        line_info['chars'].append(char_info)\n                \n                # 置信度\n                if 'probability' in line:\n                    line_info['probability'] = line['probability']\n                \n                # 外接多边形顶点\n                if 'vertexes_location' in line:\n                    line_info['vertexes_location'] = line['vertexes_location']\n                \n                if 'finegrained_vertexes_location' in line:\n                    line_info['finegrained_vertexes_location'] = line['finegrained_vertexes_location']\n                \n                if 'min_finegrained_vertexes_location' in line:\n                    line_info['min_finegrained_vertexes_location'] = line['min_finegrained_vertexes_location']\n                \n                text_lines.append(line_info)\n            \n            # 解析段落信息\n            paragraphs = []\n            if paragraphs_result:\n                for para in paragraphs_result:\n                    para_info = {\n                        'words_result_idx': para.get('words_result_idx', []),\n                    }\n                    if 'finegrained_vertexes_location' in para:\n                        para_info['finegrained_vertexes_location'] = para['finegrained_vertexes_location']\n                    if 'min_finegrained_vertexes_location' in para:\n                        para_info['min_finegrained_vertexes_location'] = para['min_finegrained_vertexes_location']\n                    paragraphs.append(para_info)\n            \n            return {\n                'log_id': log_id,\n                'words_result_num': words_result_num,\n                'words_result': words_result,  # 原始结果\n                'text_lines': text_lines,  # 解析后的文字行\n                'direction': direction,\n                'paragraphs_result_num': paragraphs_result_num,\n                'paragraphs_result': paragraphs_result,  # 原始段落结果\n                'paragraphs': paragraphs,  # 解析后的段落\n                'image_size': (original_width, original_height),\n            }\n            \n        except Exception as e:\n            logger.error(f\"❌ 高精度OCR识别失败: {str(e)}\")\n            raise\n    \n    def _location_to_bbox(self, location: Dict[str, int]) -> List[int]:\n        \"\"\"\n        将location格式转换为bbox格式 [x0, y0, x1, y1]\n        \n        Args:\n            location: {left, top, width, height}\n            \n        Returns:\n            bbox [x0, y0, x1, y1]\n        \"\"\"\n        if not location:\n            return [0, 0, 0, 0]\n        \n        left = location.get('left', 0)\n        top = location.get('top', 0)\n        width = location.get('width', 0)\n        height = location.get('height', 0)\n        \n        return [left, top, left + width, top + height]\n    \n    def get_full_text(self, result: Dict[str, Any], separator: str = '\\n') -> str:\n        \"\"\"\n        从识别结果中提取完整文本\n        \n        Args:\n            result: recognize()返回的结果\n            separator: 行分隔符，默认换行\n            \n        Returns:\n            完整的文本字符串\n        \"\"\"\n        text_lines = result.get('text_lines', [])\n        return separator.join([line.get('text', '') for line in text_lines])\n    \n    def get_text_with_positions(self, result: Dict[str, Any]) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取带位置信息的文字列表\n        \n        Args:\n            result: recognize()返回的结果\n            \n        Returns:\n            文字位置列表，每项包含 text 和 bbox\n        \"\"\"\n        text_lines = result.get('text_lines', [])\n        return [\n            {\n                'text': line.get('text', ''),\n                'bbox': line.get('bbox', [0, 0, 0, 0]),\n            }\n            for line in text_lines\n        ]\n\n\ndef create_baidu_accurate_ocr_provider(\n    api_key: Optional[str] = None\n) -> Optional[BaiduAccurateOCRProvider]:\n    \"\"\"\n    创建百度高精度OCR Provider实例\n\n    Args:\n        api_key: 百度API Key（BCEv3格式或Access Token），如果不提供则从Flask config或环境变量读取\n\n    Returns:\n        BaiduAccurateOCRProvider实例，如果api_key不可用则返回None\n    \"\"\"\n    from config import Config\n\n    if not api_key:\n        # 优先从 Flask config 读取（数据库设置），然后从 Config（含 env 回退）\n        try:\n            from flask import current_app\n            api_key = current_app.config.get('BAIDU_API_KEY')\n        except RuntimeError:\n            pass  # 不在 Flask 上下文中\n        if not api_key:\n            api_key = Config.BAIDU_API_KEY\n\n    if not api_key:\n        logger.warning(\"⚠️ 未配置百度API Key, 跳过百度高精度OCR\")\n        return None\n\n    return BaiduAccurateOCRProvider(api_key)\n\n"
  },
  {
    "path": "backend/services/ai_providers/ocr/baidu_table_ocr_provider.py",
    "content": "\"\"\"\n百度表格识别OCR Provider\n提供基于百度AI的表格识别能力,支持精确到单元格级别的识别\n\nAPI文档: https://ai.baidu.com/ai-doc/OCR/1k3h7y3db\n\"\"\"\nimport logging\nimport base64\nimport requests\nimport urllib.parse\nfrom typing import Dict, List, Any, Optional\nfrom PIL import Image\nimport io\nfrom tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type\n\nlogger = logging.getLogger(__name__)\n\n\nclass BaiduTableOCRProvider:\n    \"\"\"百度表格OCR Provider - 支持BCEv3签名认证\"\"\"\n\n    def __init__(self, api_key: str):\n        \"\"\"\n        初始化百度表格OCR Provider\n\n        Args:\n            api_key: 百度API Key（BCEv3格式：bce-v3/ALTAK-...）或Access Token\n        \"\"\"\n        self.api_key = api_key\n        self.api_url = \"https://aip.baidubce.com/rest/2.0/ocr/v1/table\"\n\n        if api_key.startswith('bce-v3/'):\n            logger.info(\"✅ 初始化百度表格OCR Provider (使用BCEv3 API Key)\")\n        else:\n            logger.info(\"✅ 初始化百度表格OCR Provider (使用Access Token)\")\n    \n    @retry(\n        stop=stop_after_attempt(3),  # 最多重试3次\n        wait=wait_exponential(multiplier=0.5, min=1, max=5),  # 指数避让: 1s, 2s, 4s\n        retry=retry_if_exception_type((requests.exceptions.RequestException, Exception)),\n        reraise=True\n    )\n    def recognize_table(\n        self,\n        image_path: str,\n        cell_contents: bool = True,  # 默认开启，获取单元格文字位置\n        return_excel: bool = False\n    ) -> Dict[str, Any]:\n        \"\"\"\n        识别表格图片（带指数避让重试）\n        \n        Args:\n            image_path: 图片路径\n            cell_contents: 是否识别单元格内容位置信息，默认True\n            return_excel: 是否返回Excel格式，默认False\n            \n        Returns:\n            识别结果字典,包含:\n            - log_id: 日志ID\n            - table_num: 表格数量\n            - tables_result: 表格结果列表\n            - cells: 解析后的单元格列表(扁平化)\n            - image_size: 原始图片尺\n        \"\"\"\n        logger.info(f\"🔍 开始识别表格图片: {image_path}\")\n        \n        try:\n            # 读取图片并转为base64\n            original_width, original_height = 0, 0\n            with Image.open(image_path) as img:\n                # 获取原始图片尺寸\n                original_width, original_height = img.size\n                logger.info(f\"📏 图片尺寸: {original_width}x{original_height}\")\n                \n                # 转换为RGB模式\n                if img.mode != 'RGB':\n                    img = img.convert('RGB')\n                \n                # 压缩图片(如果太大) - 最长边不超过8192px，最短边至少15px\n                max_size = 8192\n                min_size = 15\n                width, height = img.size\n                \n                if width < min_size or height < min_size:\n                    logger.warning(f\"⚠️ 图片太小: {width}x{height}, 最短边需要至少{min_size}px\")\n                \n                if width > max_size or height > max_size:\n                    ratio = min(max_size / width, max_size / height)\n                    new_size = (int(width * ratio), int(height * ratio))\n                    img = img.resize(new_size, Image.Resampling.LANCZOS)\n                    logger.info(f\"✂️ 压缩图片: {img.size}\")\n                \n                # 转为base64\n                buffer = io.BytesIO()\n                img.save(buffer, format='JPEG', quality=95)\n                image_bytes = buffer.getvalue()\n                image_base64 = base64.b64encode(image_bytes).decode('utf-8')\n                \n                # URL encode\n                image_encoded = urllib.parse.quote(image_base64)\n                logger.info(f\"📦 图片编码完成: base64={len(image_base64)} bytes, urlencode={len(image_encoded)} bytes\")\n            \n            # 构建请求头\n            headers = {\n                'Content-Type': 'application/x-www-form-urlencoded',\n                'Accept': 'application/json',\n            }\n            \n            # 选择认证方式\n            if self.api_key.startswith('bce-v3/'):\n                # 使用BCEv3签名认证 (Authorization头部)\n                headers['Authorization'] = f'Bearer {self.api_key}'\n                url = self.api_url\n                logger.info(f\"🔐 使用BCEv3签名认证\")\n            else:\n                # 使用Access Token (URL参数)\n                url = f\"{self.api_url}?access_token={self.api_key}\"\n                logger.info(f\"🔐 使用Access Token认证\")\n            \n            # 构建表单数据\n            data = f\"image={image_encoded}&cell_contents={'true' if cell_contents else 'false'}&return_excel={'true' if return_excel else 'false'}\"\n            \n            logger.info(f\"🌐 发送请求到百度表格OCR API...\")\n            response = requests.post(url, headers=headers, data=data, timeout=60)\n            response.raise_for_status()\n            \n            result = response.json()\n            \n            # 检查错误\n            if 'error_code' in result:\n                error_msg = result.get('error_msg', 'Unknown error')\n                error_code = result.get('error_code')\n                logger.error(f\"❌ 百度API错误: [{error_code}] {error_msg}\")\n                raise Exception(f\"Baidu API error [{error_code}]: {error_msg}\")\n            \n            # 解析结果\n            log_id = result.get('log_id', '')\n            table_num = result.get('table_num', 0)\n            tables_result = result.get('tables_result', [])\n            excel_file = result.get('excel_file', None)\n            \n            logger.info(f\"✅ 表格识别成功! log_id={log_id}, 识别到 {table_num} 个表格\")\n            \n            # 解析单元格信息(扁平化)\n            cells = []\n            for table_idx, table in enumerate(tables_result):\n                table_location = table.get('table_location', [])\n                header = table.get('header', [])\n                body = table.get('body', [])\n                footer = table.get('footer', [])\n                \n                logger.info(f\"  表格 {table_idx + 1}: header={len(header)}, body={len(body)}, footer={len(footer)}\")\n                \n                # 解析表头\n                for idx, header_cell in enumerate(header):\n                    cell_info = {\n                        'table_idx': table_idx,\n                        'section': 'header',\n                        'section_idx': idx,\n                        'text': header_cell.get('words', ''),\n                        'bbox': self._location_to_bbox(header_cell.get('location', [])),\n                    }\n                    cells.append(cell_info)\n                \n                # 解析表体\n                for cell in body:\n                    cell_info = {\n                        'table_idx': table_idx,\n                        'section': 'body',\n                        'row_start': cell.get('row_start', 0),\n                        'row_end': cell.get('row_end', 0),\n                        'col_start': cell.get('col_start', 0),\n                        'col_end': cell.get('col_end', 0),\n                        'text': cell.get('words', ''),\n                        'bbox': self._location_to_bbox(cell.get('cell_location', [])),\n                        'contents': cell.get('contents', []),  # 单元格内文字分行信息\n                    }\n                    cells.append(cell_info)\n                \n                # 解析表尾\n                for idx, footer_cell in enumerate(footer):\n                    cell_info = {\n                        'table_idx': table_idx,\n                        'section': 'footer',\n                        'section_idx': idx,\n                        'text': footer_cell.get('words', ''),\n                        'bbox': self._location_to_bbox(footer_cell.get('location', [])),\n                    }\n                    cells.append(cell_info)\n            \n            return {\n                'log_id': log_id,\n                'table_num': table_num,\n                'tables_result': tables_result,\n                'cells': cells,\n                'image_size': (original_width, original_height),\n                'excel_file': excel_file,\n            }\n            \n        except Exception as e:\n            logger.error(f\"❌ 表格识别失败: {str(e)}\")\n            raise\n    \n    def _location_to_bbox(self, location: List[Dict[str, int]]) -> List[int]:\n        \"\"\"\n        将四个角点坐标转换为bbox格式 [x0, y0, x1, y1]\n        \n        Args:\n            location: 四个角点 [{x, y}, {x, y}, {x, y}, {x, y}]\n            \n        Returns:\n            bbox [x0, y0, x1, y1]\n        \"\"\"\n        if not location or len(location) < 2:\n            return [0, 0, 0, 0]\n        \n        xs = [p['x'] for p in location]\n        ys = [p['y'] for p in location]\n        \n        return [min(xs), min(ys), max(xs), max(ys)]\n    \n    def get_table_structure(self, cells: List[Dict[str, Any]]) -> Dict[str, Any]:\n        \"\"\"\n        从单元格列表中提取表格结构\n        \n        Args:\n            cells: 单元格列表\n            \n        Returns:\n            表格结构信息:\n            - rows: 行数\n            - cols: 列数\n            - cells_by_position: {(row, col): cell_info}\n        \"\"\"\n        if not cells:\n            return {'rows': 0, 'cols': 0, 'cells_by_position': {}}\n        \n        max_row = max(cell['row_end'] for cell in cells)\n        max_col = max(cell['col_end'] for cell in cells)\n        \n        cells_by_position = {}\n        for cell in cells:\n            # 使用起始位置作为key\n            key = (cell['row_start'], cell['col_start'])\n            cells_by_position[key] = cell\n        \n        return {\n            'rows': max_row,\n            'cols': max_col,\n            'cells_by_position': cells_by_position,\n        }\n\n\ndef create_baidu_table_ocr_provider(\n    api_key: Optional[str] = None\n) -> Optional[BaiduTableOCRProvider]:\n    \"\"\"\n    创建百度表格OCR Provider实例\n\n    Args:\n        api_key: 百度API Key（BCEv3格式或Access Token），如果不提供则从Flask config或环境变量读取\n\n    Returns:\n        BaiduTableOCRProvider实例，如果api_key不可用则返回None\n    \"\"\"\n    from config import Config\n\n    if not api_key:\n        # 优先从 Flask config 读取（数据库设置），然后从 Config（含 env 回退）\n        try:\n            from flask import current_app\n            api_key = current_app.config.get('BAIDU_API_KEY')\n        except RuntimeError:\n            pass  # 不在 Flask 上下文中\n        if not api_key:\n            api_key = Config.BAIDU_API_KEY\n\n    if not api_key:\n        logger.warning(\"⚠️ 未配置百度API Key, 跳过百度表格识别\")\n        return None\n\n    return BaiduTableOCRProvider(api_key)\n\n"
  },
  {
    "path": "backend/services/ai_providers/text/__init__.py",
    "content": "\"\"\"Text generation providers\"\"\"\nfrom .base import TextProvider, strip_think_tags\nfrom .genai_provider import GenAITextProvider\nfrom .openai_provider import OpenAITextProvider\nfrom .lazyllm_provider import LazyLLMTextProvider\n\n__all__ = ['TextProvider', 'GenAITextProvider', 'OpenAITextProvider', 'LazyLLMTextProvider', 'strip_think_tags']\n"
  },
  {
    "path": "backend/services/ai_providers/text/base.py",
    "content": "\"\"\"\nAbstract base class for text generation providers\n\"\"\"\nimport re\nfrom abc import ABC, abstractmethod\nfrom typing import Generator\n\n\ndef strip_think_tags(text: str) -> str:\n    \"\"\"Remove <think>...</think> blocks (including multiline) from AI responses.\"\"\"\n    if not text:\n        return text\n    return re.sub(r'<think>.*?</think>\\s*', '', text, flags=re.DOTALL).strip()\n\n\nclass TextProvider(ABC):\n    \"\"\"Abstract base class for text generation\"\"\"\n\n    @abstractmethod\n    def generate_text(self, prompt: str, thinking_budget: int = 1000) -> str:\n        \"\"\"\n        Generate text content from prompt\n\n        Args:\n            prompt: The input prompt for text generation\n            thinking_budget: Budget for thinking/reasoning (provider-specific)\n\n        Returns:\n            Generated text content\n        \"\"\"\n        pass\n\n    def generate_text_stream(self, prompt: str, thinking_budget: int = 0) -> Generator[str, None, None]:\n        \"\"\"\n        Stream text content from prompt, yielding chunks as they arrive.\n\n        Default implementation falls back to non-streaming generate_text.\n        Subclasses should override for true streaming support.\n        \"\"\"\n        yield self.generate_text(prompt, thinking_budget=thinking_budget)\n"
  },
  {
    "path": "backend/services/ai_providers/text/genai_provider.py",
    "content": "\"\"\"\nGoogle GenAI SDK — text generation provider\n\nOperates in two authentication modes selected at construction time:\n  * API-key mode  (Google AI Studio or compatible proxy)\n  * Vertex AI mode (GCP service-account credentials via GOOGLE_APPLICATION_CREDENTIALS)\n\"\"\"\nimport logging\nfrom typing import Generator\nfrom google import genai\nfrom google.genai import types\nfrom tenacity import retry, stop_after_attempt, wait_exponential\nfrom .base import TextProvider, strip_think_tags\nfrom config import get_config\nfrom ..genai_client import make_genai_client\n\nlogger = logging.getLogger(__name__)\n\n\ndef _log_retry(retry_state):\n    \"\"\"记录重试信息\"\"\"\n    logger.warning(\n        f\"GenAI 请求失败，正在重试 ({retry_state.attempt_number}/{get_config().GENAI_MAX_RETRIES + 1})，\"\n        f\"错误: {retry_state.outcome.exception() if retry_state.outcome else 'unknown'}\"\n    )\n\n\ndef _validate_response(response):\n    \"\"\"验证响应是否有效，无效则抛出异常触发重试\"\"\"\n    if response.text is None:\n        if hasattr(response, 'candidates') and response.candidates:\n            candidate = response.candidates[0]\n            if hasattr(candidate, 'finish_reason'):\n                logger.warning(f\"Response text is None, finish_reason: {candidate.finish_reason}\")\n            if hasattr(candidate, 'safety_ratings'):\n                logger.warning(f\"Safety ratings: {candidate.safety_ratings}\")\n        raise ValueError(\"AI model returned empty response (response.text is None)\")\n    return strip_think_tags(response.text)\n\n\nclass GenAITextProvider(TextProvider):\n    \"\"\"Text generation via Google GenAI SDK (AI Studio / Vertex AI)\"\"\"\n\n    def __init__(\n        self,\n        model: str = \"gemini-3-flash-preview\",\n        api_key: str = None,\n        api_base: str = None,\n        vertexai: bool = False,\n        project_id: str = None,\n        location: str = None,\n    ):\n        self.client = make_genai_client(\n            vertexai=vertexai,\n            api_key=api_key,\n            api_base=api_base,\n            project_id=project_id,\n            location=location,\n        )\n        self.model = model\n    \n    @retry(\n        stop=stop_after_attempt(get_config().GENAI_MAX_RETRIES + 1),\n        wait=wait_exponential(multiplier=1, min=2, max=10),\n        reraise=True,\n        before_sleep=_log_retry\n    )\n    def generate_text(self, prompt: str, thinking_budget: int = 0) -> str:\n        \"\"\"\n        Generate text using Google GenAI SDK\n        \n        Args:\n            prompt: The input prompt\n            thinking_budget: Thinking budget for the model (0 = disable thinking)\n            \n        Returns:\n            Generated text\n        \"\"\"\n        # 构建配置，只有在 thinking_budget > 0 时才启用推理模式\n        config_params = {}\n        if thinking_budget > 0:\n            config_params['thinking_config'] = types.ThinkingConfig(thinking_budget=thinking_budget)\n        \n        response = self.client.models.generate_content(\n            model=self.model,\n            contents=prompt,\n            config=types.GenerateContentConfig(**config_params) if config_params else None,\n        )\n        return _validate_response(response)\n    \n    @retry(\n        stop=stop_after_attempt(get_config().GENAI_MAX_RETRIES + 1),\n        wait=wait_exponential(multiplier=1, min=2, max=10),\n        reraise=True,\n        before_sleep=_log_retry\n    )\n    def generate_with_image(self, prompt: str, image_path: str, thinking_budget: int = 0) -> str:\n        \"\"\"\n        Generate text with image input using Google GenAI SDK (multimodal)\n        \n        Args:\n            prompt: The input prompt\n            image_path: Path to the image file\n            thinking_budget: Thinking budget for the model (0 = disable thinking)\n            \n        Returns:\n            Generated text\n        \"\"\"\n        from PIL import Image\n        \n        # 加载图片\n        img = Image.open(image_path)\n        \n        # 构建多模态内容\n        contents = [img, prompt]\n        \n        # 构建配置，只有在 thinking_budget > 0 时才启用推理模式\n        config_params = {}\n        if thinking_budget > 0:\n            config_params['thinking_config'] = types.ThinkingConfig(thinking_budget=thinking_budget)\n        \n        response = self.client.models.generate_content(\n            model=self.model,\n            contents=contents,\n            config=types.GenerateContentConfig(**config_params) if config_params else None,\n        )\n        return _validate_response(response)\n\n    def generate_text_stream(self, prompt: str, thinking_budget: int = 0) -> Generator[str, None, None]:\n        \"\"\"Stream text using Google GenAI SDK's generate_content_stream.\"\"\"\n        config_params = {}\n        if thinking_budget > 0:\n            config_params['thinking_config'] = types.ThinkingConfig(thinking_budget=thinking_budget)\n\n        response = self.client.models.generate_content_stream(\n            model=self.model,\n            contents=prompt,\n            config=types.GenerateContentConfig(**config_params) if config_params else None,\n        )\n        for chunk in response:\n            # Skip thinking chunks, only yield text content\n            if chunk.text:\n                yield chunk.text"
  },
  {
    "path": "backend/services/ai_providers/text/lazyllm_provider.py",
    "content": "\"\"\"\nLazyllm framework for text generation\nSupports modes:\n- Qwen\n- Deepseek\n- doubao\n- GLM\n- MINIMAX\n- sensenova\n- ...\n\"\"\"\nimport threading\nfrom .base import TextProvider, strip_think_tags\nfrom ..lazyllm_env import ensure_lazyllm_namespace_key\n\nclass LazyLLMTextProvider(TextProvider):\n    \"\"\"Text generation using lazyllm\"\"\"\n    def __init__(self, source: str = 'deepseek', model: str = \"deepseek-v3-1-terminus\"):\n        \"\"\"\n        Initialize lazyllm text provider\n\n        Args:\n            source: text model provider, support qwen,doubao,deepseek,siliconflow,glm...\n            model: Model name to use\n            type: Category of the online service. Defaults to ``llm``.\n        \"\"\"\n        try:\n            import lazyllm\n        except ModuleNotFoundError as exc:\n            raise RuntimeError(\n                \"lazyllm is required when AI_PROVIDER_FORMAT=lazyllm. \"\n                \"Please install backend dependencies including lazyllm.\"\n            ) from exc\n\n        self._source = source\n        self._model = model\n        self._vlm_client = None\n        self._vlm_lock = threading.Lock()\n        ensure_lazyllm_namespace_key(source, namespace='BANANA')\n        self.client = lazyllm.namespace('BANANA').OnlineModule(\n            source = source,\n            model = model,\n            type = 'llm',\n            )\n        \n    def generate_text(self, prompt, thinking_budget = 1000):\n        message = self.client(prompt)\n        return strip_think_tags(message)\n\n    def generate_with_image(self, prompt: str, image_path: str, thinking_budget: int = 0) -> str:\n        if self._vlm_client is None:\n            with self._vlm_lock:\n                if self._vlm_client is None:\n                    import lazyllm\n                    ensure_lazyllm_namespace_key(self._source, namespace='BANANA')\n                    self._vlm_client = lazyllm.namespace('BANANA').OnlineModule(\n                        source=self._source, model=self._model, type='vlm',\n                    )\n        message = self._vlm_client(prompt, lazyllm_files=[image_path])\n        return strip_think_tags(message)\n"
  },
  {
    "path": "backend/services/ai_providers/text/openai_provider.py",
    "content": "\"\"\"\nOpenAI SDK implementation for text generation\n\"\"\"\nimport base64\nimport logging\nfrom typing import Generator\nfrom openai import OpenAI\nfrom .base import TextProvider, strip_think_tags\nfrom config import get_config\n\nlogger = logging.getLogger(__name__)\n\n\nclass OpenAITextProvider(TextProvider):\n    \"\"\"Text generation using OpenAI SDK (compatible with Gemini via proxy)\"\"\"\n    \n    def __init__(self, api_key: str, api_base: str = None, model: str = \"gemini-3-flash-preview\"):\n        \"\"\"\n        Initialize OpenAI text provider\n        \n        Args:\n            api_key: API key\n            api_base: API base URL (e.g., https://aihubmix.com/v1)\n            model: Model name to use\n        \"\"\"\n        self.client = OpenAI(\n            api_key=api_key,\n            base_url=api_base,\n            timeout=get_config().OPENAI_TIMEOUT,  # set timeout from config\n            max_retries=get_config().OPENAI_MAX_RETRIES  # set max retries from config\n        )\n        self.model = model\n    \n    def generate_text(self, prompt: str, thinking_budget: int = 0) -> str:\n        \"\"\"\n        Generate text using OpenAI SDK\n        \n        Args:\n            prompt: The input prompt\n            thinking_budget: Not used in OpenAI format, kept for interface compatibility (0 = default)\n            \n        Returns:\n            Generated text\n        \"\"\"\n        response = self.client.chat.completions.create(\n            model=self.model,\n            messages=[\n                {\"role\": \"user\", \"content\": prompt}\n            ]\n        )\n        return strip_think_tags(response.choices[0].message.content)\n\n    def generate_text_stream(self, prompt: str, thinking_budget: int = 0) -> Generator[str, None, None]:\n        \"\"\"Stream text using OpenAI SDK with stream=True.\"\"\"\n        response = self.client.chat.completions.create(\n            model=self.model,\n            messages=[{\"role\": \"user\", \"content\": prompt}],\n            stream=True,\n        )\n        for chunk in response:\n            delta = chunk.choices[0].delta if chunk.choices else None\n            if delta and delta.content:\n                yield delta.content\n\n    def generate_with_image(self, prompt: str, image_path: str, thinking_budget: int = 0) -> str:\n        \"\"\"Generate text with image input using OpenAI-compatible chat completions.\"\"\"\n        with open(image_path, \"rb\") as image_file:\n            encoded = base64.b64encode(image_file.read()).decode(\"ascii\")\n\n        response = self.client.chat.completions.create(\n            model=self.model,\n            messages=[\n                {\n                    \"role\": \"user\",\n                    \"content\": [\n                        {\"type\": \"text\", \"text\": prompt},\n                        {\n                            \"type\": \"image_url\",\n                            \"image_url\": {\"url\": f\"data:image/png;base64,{encoded}\"},\n                        },\n                    ],\n                }\n            ],\n        )\n\n        message_content = response.choices[0].message.content\n        if isinstance(message_content, str):\n            return strip_think_tags(message_content)\n\n        parts = []\n        for item in message_content or []:\n            text = item.get(\"text\") if isinstance(item, dict) else getattr(item, \"text\", None)\n            if text:\n                parts.append(text)\n        return strip_think_tags(\"\\n\".join(parts))\n"
  },
  {
    "path": "backend/services/ai_service.py",
    "content": "\"\"\"\nAI Service - handles all AI model interactions\nBased on demo.py and gemini_genai.py\nTODO: use structured output API\n\"\"\"\nimport os\nimport json\nimport re\nimport logging\nimport requests\nfrom typing import List, Dict, Optional, Union\nfrom textwrap import dedent\nfrom PIL import Image\nfrom tenacity import retry, stop_after_attempt, retry_if_exception_type\nfrom .prompts import (\n    get_outline_generation_prompt,\n    get_outline_parsing_prompt,\n    get_page_description_prompt,\n    get_all_descriptions_stream_prompt,\n    get_image_generation_prompt,\n    get_image_edit_prompt,\n    get_description_to_outline_prompt,\n    get_description_split_prompt,\n    get_outline_refinement_prompt,\n    get_descriptions_refinement_prompt,\n    get_ppt_page_content_extraction_prompt,\n    get_layout_caption_prompt,\n    get_style_extraction_prompt,\n    get_outline_generation_prompt_markdown,\n    get_outline_parsing_prompt_markdown,\n    get_description_to_outline_prompt_markdown,\n)\nfrom .ai_providers import get_text_provider, get_image_provider, get_caption_provider, TextProvider, ImageProvider\nfrom config import get_config\n\nlogger = logging.getLogger(__name__)\n\n\nclass ProjectContext:\n    \"\"\"项目上下文数据类，统一管理 AI 需要的所有项目信息\"\"\"\n    \n    def __init__(self, project_or_dict, reference_files_content: Optional[List[Dict[str, str]]] = None):\n        \"\"\"\n        Args:\n            project_or_dict: 项目对象（Project model）或项目字典（project.to_dict()）\n            reference_files_content: 参考文件内容列表\n        \"\"\"\n        # 支持直接传入 Project 对象，避免 to_dict() 调用，提升性能\n        if hasattr(project_or_dict, 'idea_prompt'):\n            # 是 Project 对象\n            self.idea_prompt = project_or_dict.idea_prompt\n            self.outline_text = project_or_dict.outline_text\n            self.description_text = project_or_dict.description_text\n            self.creation_type = project_or_dict.creation_type or 'idea'\n            self.outline_requirements = project_or_dict.outline_requirements\n            self.description_requirements = project_or_dict.description_requirements\n        else:\n            # 是字典\n            self.idea_prompt = project_or_dict.get('idea_prompt')\n            self.outline_text = project_or_dict.get('outline_text')\n            self.description_text = project_or_dict.get('description_text')\n            self.creation_type = project_or_dict.get('creation_type', 'idea')\n            self.outline_requirements = project_or_dict.get('outline_requirements')\n            self.description_requirements = project_or_dict.get('description_requirements')\n\n        self.reference_files_content = reference_files_content or []\n\n    def to_dict(self) -> Dict:\n        \"\"\"转换为字典，方便传递\"\"\"\n        return {\n            'idea_prompt': self.idea_prompt,\n            'outline_text': self.outline_text,\n            'description_text': self.description_text,\n            'creation_type': self.creation_type,\n            'outline_requirements': self.outline_requirements,\n            'description_requirements': self.description_requirements,\n            'reference_files_content': self.reference_files_content\n        }\n\n\nclass AIService:\n    \"\"\"Service for AI model interactions using pluggable providers\"\"\"\n    \n    def __init__(self, text_provider: TextProvider = None, image_provider: ImageProvider = None, caption_provider: TextProvider = None):\n        \"\"\"\n        Initialize AI service with providers\n        \n        Args:\n            text_provider: Optional pre-configured TextProvider. If None, created from factory.\n            image_provider: Optional pre-configured ImageProvider. If None, created from factory.\n        \"\"\"\n        config = get_config()\n\n        # 优先使用 Flask app.config（可由 Settings 覆盖），否则回退到 Config 默认值\n        try:\n            from flask import current_app, has_app_context\n        except ImportError:\n            current_app = None  # type: ignore\n            has_app_context = lambda: False  # type: ignore\n\n        if has_app_context() and current_app and hasattr(current_app, \"config\"):\n            self.text_model = current_app.config.get(\"TEXT_MODEL\", config.TEXT_MODEL)\n            self.image_model = current_app.config.get(\"IMAGE_MODEL\", config.IMAGE_MODEL)\n            # 分离的文本和图像推理配置\n            self.enable_text_reasoning = current_app.config.get(\"ENABLE_TEXT_REASONING\", False)\n            self.text_thinking_budget = current_app.config.get(\"TEXT_THINKING_BUDGET\", 1024)\n            self.enable_image_reasoning = current_app.config.get(\"ENABLE_IMAGE_REASONING\", False)\n            self.image_thinking_budget = current_app.config.get(\"IMAGE_THINKING_BUDGET\", 1024)\n        else:\n            self.text_model = config.TEXT_MODEL\n            self.image_model = config.IMAGE_MODEL\n            self.enable_text_reasoning = False\n            self.text_thinking_budget = 1024\n            self.enable_image_reasoning = False\n            self.image_thinking_budget = 1024\n        \n        # Caption model for multimodal (image→text) tasks\n        if has_app_context() and current_app and hasattr(current_app, \"config\"):\n            self.caption_model = current_app.config.get(\"IMAGE_CAPTION_MODEL\", config.IMAGE_CAPTION_MODEL)\n        else:\n            self.caption_model = config.IMAGE_CAPTION_MODEL\n\n        # Use provided providers or create from factory based on AI_PROVIDER_FORMAT (from Flask config or env var)\n        self.text_provider = text_provider or get_text_provider(model=self.text_model)\n        self.image_provider = image_provider or get_image_provider(model=self.image_model)\n        self.caption_provider = caption_provider or get_caption_provider(model=self.caption_model)\n    \n    def _get_text_thinking_budget(self) -> int:\n        \"\"\"\n        获取文本生成的思考负载\n        \n        Returns:\n            如果启用文本推理则返回配置的 budget，否则返回 0\n        \"\"\"\n        return self.text_thinking_budget if self.enable_text_reasoning else 0\n    \n    def _get_image_thinking_budget(self) -> int:\n        \"\"\"\n        获取图像生成的思考负载\n        \n        Returns:\n            如果启用图像推理则返回配置的 budget，否则返回 0\n        \"\"\"\n        return self.image_thinking_budget if self.enable_image_reasoning else 0\n    \n    @staticmethod\n    def extract_image_urls_from_markdown(text: str) -> List[str]:\n        \"\"\"\n        从 markdown 文本中提取图片 URL\n        \n        Args:\n            text: Markdown 文本，可能包含 ![](url) 格式的图片\n            \n        Returns:\n            图片 URL 列表（包括 http/https URL 和 /files/ 开头的本地路径）\n        \"\"\"\n        if not text:\n            return []\n        \n        # 匹配 markdown 图片语法: ![](url) 或 ![alt](url)\n        pattern = r'!\\[.*?\\]\\((.*?)\\)'\n        matches = re.findall(pattern, text)\n        \n        # 过滤掉空字符串，支持 http/https URL 和 /files/ 开头的本地路径（包括 mineru、materials 等）\n        urls = []\n        for url in matches:\n            url = url.strip()\n            if url and (url.startswith('http://') or url.startswith('https://') or url.startswith('/files/')):\n                urls.append(url)\n        \n        return urls\n    \n    @staticmethod\n    def remove_markdown_images(text: str) -> str:\n        \"\"\"\n        从文本中移除 Markdown 图片链接，只保留 alt text（描述文字）\n        \n        Args:\n            text: 包含 Markdown 图片语法的文本\n            \n        Returns:\n            移除图片链接后的文本，保留描述文字\n        \"\"\"\n        if not text:\n            return text\n        \n        # 将 ![描述文字](url) 替换为 描述文字\n        # 如果没有描述文字（空的 alt text），则完全删除该图片链接\n        def replace_image(match):\n            alt_text = match.group(1).strip()\n            # 如果有描述文字，保留它；否则删除整个链接\n            return alt_text if alt_text else ''\n        \n        pattern = r'!\\[(.*?)\\]\\([^\\)]+\\)'\n        cleaned_text = re.sub(pattern, replace_image, text)\n        \n        # 清理可能产生的多余空行\n        cleaned_text = re.sub(r'\\n\\s*\\n\\s*\\n', '\\n\\n', cleaned_text)\n        \n        return cleaned_text\n    \n    @retry(\n        stop=stop_after_attempt(3),\n        retry=retry_if_exception_type((json.JSONDecodeError, ValueError)),\n        reraise=True\n    )\n    def generate_json(self, prompt: str, thinking_budget: int = 1000) -> Union[Dict, List]:\n        \"\"\"\n        生成并解析JSON，如果解析失败则重新生成\n        \n        Args:\n            prompt: 生成提示词\n            thinking_budget: 思考预算（会根据 enable_text_reasoning 配置自动调整）\n            \n        Returns:\n            解析后的JSON对象（字典或列表）\n            \n        Raises:\n            json.JSONDecodeError: JSON解析失败（重试3次后仍失败）\n        \"\"\"\n        # 调用AI生成文本（根据 enable_text_reasoning 配置调整 thinking_budget）\n        actual_budget = self._get_text_thinking_budget()\n        response_text = self.text_provider.generate_text(prompt, thinking_budget=actual_budget)\n        \n        # 清理响应文本：移除markdown代码块标记和多余空白\n        cleaned_text = response_text.strip().strip(\"```json\").strip(\"```\").strip()\n        \n        try:\n            return json.loads(cleaned_text)\n        except json.JSONDecodeError as e:\n            logger.warning(f\"JSON解析失败，将重新生成。原始文本: {cleaned_text[:200]}... 错误: {str(e)}\")\n            raise\n    \n    @retry(\n        stop=stop_after_attempt(3),\n        retry=retry_if_exception_type((json.JSONDecodeError, ValueError)),\n        reraise=True\n    )\n    def generate_json_with_image(self, prompt: str, image_path: str, thinking_budget: int = 1000) -> Union[Dict, List]:\n        \"\"\"\n        带图片输入的JSON生成，如果解析失败则重新生成（最多重试3次）\n        \n        Args:\n            prompt: 生成提示词\n            image_path: 图片文件路径\n            thinking_budget: 思考预算（会根据 enable_text_reasoning 配置自动调整）\n            \n        Returns:\n            解析后的JSON对象（字典或列表）\n            \n        Raises:\n            json.JSONDecodeError: JSON解析失败（重试3次后仍失败）\n            ValueError: caption_provider 不支持图片输入\n        \"\"\"\n        # 使用 caption_provider（支持图片输入的多模态模型）\n        actual_budget = self._get_text_thinking_budget()\n        provider = self.caption_provider\n        if hasattr(provider, 'generate_with_image'):\n            response_text = provider.generate_with_image(\n                prompt=prompt,\n                image_path=image_path,\n                thinking_budget=actual_budget\n            )\n        elif hasattr(provider, 'generate_text_with_images'):\n            response_text = provider.generate_text_with_images(\n                prompt=prompt,\n                images=[image_path],\n                thinking_budget=actual_budget\n            )\n        else:\n            raise ValueError(\"caption_provider 不支持图片输入\")\n        \n        # 清理响应文本：移除markdown代码块标记和多余空白\n        cleaned_text = response_text.strip().removeprefix(\"```json\").removeprefix(\"```\").removesuffix(\"```\").strip()\n        \n        try:\n            return json.loads(cleaned_text)\n        except json.JSONDecodeError as e:\n            logger.warning(f\"JSON解析失败（带图片），将重新生成。原始文本: {cleaned_text[:200]}... 错误: {str(e)}\")\n            raise\n    \n    @staticmethod\n    def _convert_mineru_path_to_local(mineru_path: str) -> Optional[str]:\n        \"\"\"\n        将 /files/mineru/{extract_id}/{rel_path} 格式的路径转换为本地文件系统路径（支持前缀匹配）\n        \n        Args:\n            mineru_path: MinerU URL 路径，格式为 /files/mineru/{extract_id}/{rel_path}\n            \n        Returns:\n            本地文件系统路径，如果转换失败则返回 None\n        \"\"\"\n        from utils.path_utils import find_mineru_file_with_prefix\n        \n        matched_path = find_mineru_file_with_prefix(mineru_path)\n        return str(matched_path) if matched_path else None\n    \n    @staticmethod\n    def download_image_from_url(url: str) -> Optional[Image.Image]:\n        \"\"\"\n        从 URL 下载图片并返回 PIL Image 对象\n        \n        Args:\n            url: 图片 URL\n            \n        Returns:\n            PIL Image 对象，如果下载失败则返回 None\n        \"\"\"\n        try:\n            logger.debug(f\"Downloading image from URL: {url}\")\n            response = requests.get(url, timeout=30, stream=True)\n            response.raise_for_status()\n            \n            # 从响应内容创建 PIL Image\n            image = Image.open(response.raw)\n            # 确保图片被加载\n            image.load()\n            logger.debug(f\"Successfully downloaded image: {image.size}, {image.mode}\")\n            return image\n        except Exception as e:\n            logger.error(f\"Failed to download image from {url}: {str(e)}\")\n            return None\n    \n    def generate_outline(self, project_context: ProjectContext, language: str = None) -> List[Dict]:\n        \"\"\"\n        Generate PPT outline from idea prompt\n        Based on demo.py gen_outline()\n        \n        Args:\n            project_context: 项目上下文对象，包含所有原始信息\n            \n        Returns:\n            List of outline items (may contain parts with pages or direct pages)\n        \"\"\"\n        outline_prompt = get_outline_generation_prompt(project_context, language)\n        outline = self.generate_json(outline_prompt, thinking_budget=1000)\n        return outline\n\n    @staticmethod\n    def parse_markdown_outline(markdown: str) -> List[Dict]:\n        \"\"\"\n        Parse markdown outline into structured page data.\n\n        Format:\n          # Part Name        → sets current part\n          ## Page Title       → starts a new page\n          - Point text        → adds a bullet point to current page\n\n        Returns list of dicts: [{\"title\": ..., \"points\": [...], \"part\": ...}, ...]\n        \"\"\"\n        pages = []\n        current_part = None\n        current_page = None\n\n        for line in markdown.split('\\n'):\n            stripped = line.strip()\n            if not stripped:\n                continue\n\n            if stripped.startswith('# ') and not stripped.startswith('## '):\n                # Part header\n                current_part = stripped[2:].strip()\n            elif stripped.startswith('## '):\n                # New page — flush previous\n                if current_page:\n                    pages.append(current_page)\n                current_page = {\n                    'title': stripped[3:].strip(),\n                    'points': [],\n                }\n                if current_part:\n                    current_page['part'] = current_part\n            elif stripped.startswith('- ') and current_page is not None:\n                current_page['points'].append(stripped[2:].strip())\n\n        # Flush last page\n        if current_page:\n            pages.append(current_page)\n\n        return pages\n\n    def generate_outline_stream(self, project_context: ProjectContext, language: str = None):\n        \"\"\"\n        Stream outline generation, yielding each completed page as it's detected.\n\n        Yields dicts: {\"title\": ..., \"points\": [...], \"part\": ...}\n        \"\"\"\n        creation_type = project_context.creation_type or 'idea'\n\n        if creation_type == 'outline':\n            prompt = get_outline_parsing_prompt_markdown(project_context, language)\n        elif creation_type == 'descriptions':\n            prompt = get_description_to_outline_prompt_markdown(project_context, language)\n        else:\n            prompt = get_outline_generation_prompt_markdown(project_context, language)\n\n        actual_budget = self._get_text_thinking_budget()\n        buffer = \"\"\n        current_part = None\n        current_page = None\n        stream_complete = False\n\n        for chunk in self.text_provider.generate_text_stream(prompt, thinking_budget=actual_budget):\n            buffer += chunk\n\n            # Process complete lines from buffer\n            while '\\n' in buffer:\n                line, buffer = buffer.split('\\n', 1)\n                stripped = line.strip()\n\n                if not stripped:\n                    continue\n\n                if stripped == '<!-- END -->':\n                    stream_complete = True\n                    continue\n\n                if stripped.startswith('# ') and not stripped.startswith('## '):\n                    current_part = stripped[2:].strip()\n                elif stripped.startswith('## '):\n                    # New page detected — yield previous page\n                    if current_page:\n                        yield current_page\n                    current_page = {\n                        'title': stripped[3:].strip(),\n                        'points': [],\n                    }\n                    if current_part:\n                        current_page['part'] = current_part\n                elif stripped.startswith('- ') and current_page is not None:\n                    current_page['points'].append(stripped[2:].strip())\n\n        # Process remaining buffer (same logic as main loop)\n        if buffer.strip():\n            buffer += '\\n'\n            while '\\n' in buffer:\n                line, buffer = buffer.split('\\n', 1)\n                stripped = line.strip()\n                if not stripped:\n                    continue\n                if stripped == '<!-- END -->':\n                    stream_complete = True\n                    continue\n                if stripped.startswith('# ') and not stripped.startswith('## '):\n                    current_part = stripped[2:].strip()\n                elif stripped.startswith('## '):\n                    if current_page:\n                        yield current_page\n                    current_page = {\n                        'title': stripped[3:].strip(),\n                        'points': [],\n                    }\n                    if current_part:\n                        current_page['part'] = current_part\n                elif stripped.startswith('- ') and current_page is not None:\n                    current_page['points'].append(stripped[2:].strip())\n\n        # Yield last page\n        if current_page:\n            yield current_page\n\n        # Yield completion sentinel\n        yield {'__stream_complete__': stream_complete}\n    \n    def parse_outline_text(self, project_context: ProjectContext, language: str = None) -> List[Dict]:\n        \"\"\"\n        Parse user-provided outline text into structured outline format\n        This method analyzes the text and splits it into pages without modifying the original text\n        \n        Args:\n            project_context: 项目上下文对象，包含所有原始信息\n        \n        Returns:\n            List of outline items (may contain parts with pages or direct pages)\n        \"\"\"\n        parse_prompt = get_outline_parsing_prompt(project_context, language)\n        outline = self.generate_json(parse_prompt, thinking_budget=1000)\n        return outline\n    \n    def flatten_outline(self, outline: List[Dict]) -> List[Dict]:\n        \"\"\"\n        Flatten outline structure to page list\n        Based on demo.py flatten_outline()\n        \"\"\"\n        pages = []\n        for item in outline:\n            if \"part\" in item and \"pages\" in item:\n                # This is a part, expand its pages\n                for page in item[\"pages\"]:\n                    page_with_part = page.copy()\n                    page_with_part[\"part\"] = item[\"part\"]\n                    pages.append(page_with_part)\n            else:\n                # This is a direct page\n                pages.append(item)\n        return pages\n    \n    @staticmethod\n    def _parse_extra_fields(text: str, field_names: list) -> tuple:\n        \"\"\"\n        从描述文本中解析额外字段，返回 (cleaned_text, extra_fields_dict)。\n\n        遍历 field_names，按出现顺序依次提取每个字段的内容。\n        两个相邻字段之间的文本属于前一个字段。\n        \"\"\"\n        if not field_names:\n            return text, {}\n\n        extra_fields = {}\n        # 找到所有字段在文本中的起始位置\n        positions = []\n        for name in field_names:\n            match = re.search(rf'\\n{re.escape(name)}[：:]\\s*', text)\n            if match:\n                positions.append((match.start(), match.end(), name))\n\n        if not positions:\n            return text, {}\n\n        # 按位置排序\n        positions.sort(key=lambda x: x[0])\n\n        # 提取每个字段的值\n        for i, (start, end, name) in enumerate(positions):\n            if i + 1 < len(positions):\n                value = text[end:positions[i + 1][0]].strip()\n            else:\n                value = text[end:].strip()\n            # 清理 HTML 注释标记\n            value = re.sub(r'<!--.*?-->', '', value).strip()\n            if value:\n                extra_fields[name] = value\n\n        # 清理后的描述文本（截取到第一个字段之前）\n        cleaned_text = text[:positions[0][0]].strip()\n\n        return cleaned_text, extra_fields\n\n    @staticmethod\n    def _get_extra_field_names() -> list:\n        \"\"\"从 Settings 读取配置的额外字段名列表。\"\"\"\n        try:\n            from models import Settings\n            settings = Settings.get_settings()\n            return settings.get_description_extra_fields()\n        except Exception:\n            logger.warning(\"Failed to get extra field names from settings\", exc_info=True)\n            return ['视觉元素', '视觉焦点', '排版布局', '演讲者备注']\n\n    def generate_page_description(self, project_context: ProjectContext, outline: List[Dict],\n                                 page_outline: Dict, page_index: int, language='zh',\n                                 detail_level: str = 'default') -> Dict:\n        \"\"\"\n        Generate description for a single page\n        Based on demo.py gen_desc() logic\n\n        Args:\n            project_context: 项目上下文对象，包含所有原始信息\n            outline: Complete outline\n            page_outline: Outline for this specific page\n            page_index: Page number (1-indexed)\n            detail_level: Description detail level (concise/default/detailed)\n\n        Returns:\n            Dict with 'text' and optional 'extra_fields'\n        \"\"\"\n        extra_field_names = self._get_extra_field_names()\n        part_info = f\"\\nThis page belongs to: {page_outline['part']}\" if 'part' in page_outline else \"\"\n\n        desc_prompt = get_page_description_prompt(\n            project_context=project_context,\n            outline=outline,\n            page_outline=page_outline,\n            page_index=page_index,\n            part_info=part_info,\n            language=language,\n            detail_level=detail_level,\n            extra_fields=extra_field_names,\n        )\n\n        # 根据 enable_text_reasoning 配置调整 thinking_budget\n        actual_budget = self._get_text_thinking_budget()\n        response_text = self.text_provider.generate_text(desc_prompt, thinking_budget=actual_budget)\n\n        text = dedent(response_text)\n        description_text, extra_fields = self._parse_extra_fields(text, extra_field_names)\n\n        result = {'text': description_text}\n        if extra_fields:\n            result['extra_fields'] = extra_fields\n        return result\n\n    def generate_descriptions_stream(self, project_context: ProjectContext,\n                                     outline: List[Dict], flat_pages: List[Dict],\n                                     language: str = 'zh',\n                                     detail_level: str = 'default'):\n        \"\"\"\n        Stream description generation for all pages, yielding each page as it's completed.\n\n        Yields dicts: {page_index, description_text, extra_fields}\n        Final yield: {__stream_complete__: bool}\n        \"\"\"\n        extra_field_names = self._get_extra_field_names()\n\n        prompt = get_all_descriptions_stream_prompt(\n            project_context=project_context,\n            outline=outline,\n            flat_pages=flat_pages,\n            language=language,\n            detail_level=detail_level,\n            extra_fields=extra_field_names,\n        )\n\n        # Build regex pattern to detect any configured extra field header\n        field_pattern = self._build_extra_field_pattern(extra_field_names)\n\n        actual_budget = self._get_text_thinking_budget()\n        buffer = \"\"\n        page_index = -1\n        current_lines: list = []\n        current_field: Optional[str] = None  # None = description, str = field name\n        extra_fields: Dict[str, str] = {}\n        stream_complete = False\n\n        def _build_page_result():\n            \"\"\"Build result dict from accumulated state.\"\"\"\n            desc_text = \"\\n\".join(current_lines).strip()\n            result: Dict = {\n                'page_index': page_index,\n                'description_text': desc_text,\n            }\n            if extra_fields:\n                result['extra_fields'] = dict(extra_fields)\n            return result\n\n        def _reset_page_state():\n            nonlocal current_lines, current_field, extra_fields\n            current_lines = []\n            current_field = None\n            extra_fields = {}\n\n        def _process_line(line: str, stripped: str):\n            nonlocal page_index, current_field, stream_complete\n\n            if stripped == '<!-- BEGIN -->':\n                if page_index < 0:\n                    page_index = 0\n                return 'continue'\n\n            if stripped == '<!-- END -->':\n                stream_complete = True\n                return 'continue'\n\n            if stripped == '<!-- PAGE_END -->':\n                if page_index >= 0 and (current_lines or extra_fields):\n                    return 'yield_page'\n                return 'continue'\n\n            if page_index < 0:\n                return 'continue'\n\n            # Check for extra field header\n            if field_pattern:\n                field_match = field_pattern.match(stripped)\n                if field_match:\n                    field_name = field_match.group(1)\n                    current_field = field_name\n                    value = field_match.group(2).strip()\n                    if value:\n                        extra_fields[field_name] = value\n                    return 'continue'\n\n            if not stripped:\n                return 'continue'\n\n            if current_field:\n                # Append to current extra field (multi-line)\n                if current_field in extra_fields:\n                    extra_fields[current_field] += \"\\n\" + stripped\n                else:\n                    extra_fields[current_field] = stripped\n            else:\n                current_lines.append(line.rstrip())\n            return 'continue'\n\n        for chunk in self.text_provider.generate_text_stream(prompt, thinking_budget=actual_budget):\n            buffer += chunk\n\n            while '\\n' in buffer:\n                line, buffer = buffer.split('\\n', 1)\n                stripped = line.strip()\n                action = _process_line(line, stripped)\n\n                if action == 'yield_page':\n                    yield _build_page_result()\n                    _reset_page_state()\n                    page_index += 1\n\n        # Process remaining buffer\n        if buffer.strip():\n            for line in buffer.split('\\n'):\n                stripped = line.strip()\n                action = _process_line(line, stripped)\n                if action == 'yield_page':\n                    yield _build_page_result()\n                    _reset_page_state()\n                    page_index += 1\n\n        # Yield last page if not yet yielded\n        if page_index >= 0 and current_lines:\n            yield _build_page_result()\n\n        yield {'__stream_complete__': stream_complete}\n\n    @staticmethod\n    def _build_extra_field_pattern(field_names: list):\n        \"\"\"Build a compiled regex pattern that matches any extra field header.\"\"\"\n        if not field_names:\n            return None\n        escaped = '|'.join(re.escape(name) for name in field_names)\n        return re.compile(rf'^({escaped})[：:]\\s*(.*)')\n    \n    def generate_outline_text(self, outline: List[Dict]) -> str:\n        \"\"\"\n        Convert outline to text format for prompts\n        Based on demo.py gen_outline_text()\n        \"\"\"\n        text_parts = []\n        for i, item in enumerate(outline, 1):\n            if \"part\" in item and \"pages\" in item:\n                text_parts.append(f\"{i}. {item['part']}\")\n            else:\n                text_parts.append(f\"{i}. {item.get('title', 'Untitled')}\")\n        result = \"\\n\".join(text_parts)\n        return dedent(result)\n    \n    def generate_image_prompt(self, outline: List[Dict], page: Dict,\n                            page_desc: str, page_index: int,\n                            has_material_images: bool = False,\n                            extra_requirements: Optional[str] = None,\n                            language='zh',\n                            has_template: bool = True,\n                            aspect_ratio: str = \"16:9\") -> str:\n        \"\"\"\n        Generate image generation prompt for a page\n        Based on demo.py gen_prompts()\n        \n        Args:\n            outline: Complete outline\n            page: Page outline data\n            page_desc: Page description text\n            page_index: Page number (1-indexed)\n            has_material_images: 是否有素材图片（从项目描述中提取的图片）\n            extra_requirements: Optional extra requirements to apply to all pages\n            language: Output language\n            has_template: 是否有模板图片（False表示无模板图模式）\n        \n        Returns:\n            Image generation prompt\n        \"\"\"\n        outline_text = self.generate_outline_text(outline)\n        \n        # Determine current section\n        if 'part' in page:\n            current_section = page['part']\n        else:\n            current_section = f\"{page.get('title', 'Untitled')}\"\n        \n        # 在传给文生图模型之前，移除 Markdown 图片链接\n        # 图片本身已经通过 additional_ref_images 传递，只保留文字描述\n        cleaned_page_desc = self.remove_markdown_images(page_desc)\n        \n        prompt = get_image_generation_prompt(\n            page_desc=cleaned_page_desc,\n            outline_text=outline_text,\n            current_section=current_section,\n            has_material_images=has_material_images,\n            extra_requirements=extra_requirements,\n            language=language,\n            has_template=has_template,\n            page_index=page_index,\n            aspect_ratio=aspect_ratio\n        )\n        \n        return prompt\n    \n    def generate_image(self, prompt: str, ref_image_path: Optional[str] = None, \n                      aspect_ratio: str = \"16:9\", resolution: str = \"2K\",\n                      additional_ref_images: Optional[List[Union[str, Image.Image]]] = None) -> Optional[Image.Image]:\n        \"\"\"\n        Generate image using configured image provider\n        Based on gemini_genai.py gen_image()\n        \n        Args:\n            prompt: Image generation prompt\n            ref_image_path: Path to reference image (optional). If None, will generate based on prompt only.\n            aspect_ratio: Image aspect ratio\n            resolution: Image resolution (note: OpenAI format only supports 1K)\n            additional_ref_images: 额外的参考图片列表，可以是本地路径、URL 或 PIL Image 对象\n        \n        Returns:\n            PIL Image object or None if failed\n        \n        Raises:\n            Exception with detailed error message if generation fails\n        \"\"\"\n        try:\n            logger.debug(f\"Reference image: {ref_image_path}\")\n            if additional_ref_images:\n                logger.debug(f\"Additional reference images: {len(additional_ref_images)}\")\n            logger.debug(f\"Config - aspect_ratio: {aspect_ratio}, resolution: {resolution}\")\n\n            # 构建参考图片列表\n            ref_images = []\n            \n            # 添加主参考图片（如果提供了路径）\n            if ref_image_path:\n                if not os.path.exists(ref_image_path):\n                    raise FileNotFoundError(f\"Reference image not found: {ref_image_path}\")\n                main_ref_image = Image.open(ref_image_path)\n                ref_images.append(main_ref_image)\n            \n            # 添加额外的参考图片\n            if additional_ref_images:\n                for ref_img in additional_ref_images:\n                    if isinstance(ref_img, Image.Image):\n                        # 已经是 PIL Image 对象\n                        ref_images.append(ref_img)\n                    elif isinstance(ref_img, str):\n                        # 可能是本地路径或 URL\n                        if os.path.exists(ref_img):\n                            # 本地路径\n                            ref_images.append(Image.open(ref_img))\n                        elif ref_img.startswith('http://') or ref_img.startswith('https://'):\n                            # URL，需要下载\n                            downloaded_img = self.download_image_from_url(ref_img)\n                            if downloaded_img:\n                                ref_images.append(downloaded_img)\n                            else:\n                                logger.warning(f\"Failed to download image from URL: {ref_img}, skipping...\")\n                        elif ref_img.startswith('/files/mineru/'):\n                            # MinerU 本地文件路径，需要转换为文件系统路径（支持前缀匹配）\n                            local_path = self._convert_mineru_path_to_local(ref_img)\n                            if local_path and os.path.exists(local_path):\n                                ref_images.append(Image.open(local_path))\n                                logger.debug(f\"Loaded MinerU image from local path: {local_path}\")\n                            else:\n                                logger.warning(f\"MinerU image file not found (with prefix matching): {ref_img}, skipping...\")\n                        elif ref_img.startswith('/files/'):\n                            # 通用 /files/ 路径（materials、项目文件等），转换为文件系统路径\n                            upload_folder = get_config().UPLOAD_FOLDER\n                            relative_path = ref_img[len('/files/'):].lstrip('/')\n                            local_path = os.path.abspath(os.path.join(upload_folder, relative_path))\n                            if not local_path.startswith(os.path.abspath(upload_folder)):\n                                logger.warning(f\"Path traversal attempt blocked: {ref_img}, skipping...\")\n                            elif os.path.exists(local_path):\n                                ref_images.append(Image.open(local_path))\n                                logger.debug(f\"Loaded image from local path: {local_path}\")\n                            else:\n                                logger.warning(f\"Local file not found: {local_path} (from {ref_img}), skipping...\")\n                        else:\n                            logger.warning(f\"Invalid image reference: {ref_img}, skipping...\")\n            \n            logger.debug(f\"Calling image provider for generation with {len(ref_images)} reference images...\")\n            logger.debug(f\"Enable image reasoning/thinking: {self.enable_image_reasoning}, budget: {self._get_image_thinking_budget()}\")\n            \n            # 使用 image_provider 生成图片\n            # 根据 enable_image_reasoning 配置控制图像生成的思考模式\n            return self.image_provider.generate_image(\n                prompt=prompt,\n                ref_images=ref_images if ref_images else None,\n                aspect_ratio=aspect_ratio,\n                resolution=resolution,\n                enable_thinking=self.enable_image_reasoning,\n                thinking_budget=self._get_image_thinking_budget()\n            )\n            \n        except Exception as e:\n            error_detail = f\"Error generating image: {type(e).__name__}: {str(e)}\"\n            logger.error(error_detail, exc_info=True)\n            raise Exception(error_detail) from e\n    \n    def edit_image(self, prompt: str, current_image_path: str,\n                  aspect_ratio: str = \"16:9\", resolution: str = \"2K\",\n                  original_description: str = None,\n                  additional_ref_images: Optional[List[Union[str, Image.Image]]] = None) -> Optional[Image.Image]:\n        \"\"\"\n        Edit existing image with natural language instruction\n        Uses current image as reference\n        \n        Args:\n            prompt: Edit instruction\n            current_image_path: Path to current page image\n            aspect_ratio: Image aspect ratio\n            resolution: Image resolution\n            original_description: Original page description to include in prompt\n            additional_ref_images: 额外的参考图片列表，可以是本地路径、URL 或 PIL Image 对象\n        \n        Returns:\n            PIL Image object or None if failed\n        \"\"\"\n        # Build edit instruction with original description if available\n        edit_instruction = get_image_edit_prompt(\n            edit_instruction=prompt,\n            original_description=original_description\n        )\n        return self.generate_image(edit_instruction, current_image_path, aspect_ratio, resolution, additional_ref_images)\n    \n    def parse_description_to_outline(self, project_context: ProjectContext, language='zh') -> List[Dict]:\n        \"\"\"\n        从描述文本解析出大纲结构\n        \n        Args:\n            project_context: 项目上下文对象，包含所有原始信息\n        \n        Returns:\n            List of outline items (may contain parts with pages or direct pages)\n        \"\"\"\n        parse_prompt = get_description_to_outline_prompt(project_context, language)\n        outline = self.generate_json(parse_prompt, thinking_budget=1000)\n        return outline\n    \n    def parse_description_to_page_descriptions(self, project_context: ProjectContext, \n                                               outline: List[Dict],\n                                               language='zh') -> List[str]:\n        \"\"\"\n        从描述文本切分出每页描述\n        \n        Args:\n            project_context: 项目上下文对象，包含所有原始信息\n            outline: 已解析出的大纲结构\n        \n        Returns:\n            List of page descriptions (strings), one for each page in the outline\n        \"\"\"\n        split_prompt = get_description_split_prompt(project_context, outline, language)\n        descriptions = self.generate_json(split_prompt, thinking_budget=1000)\n        \n        # 确保返回的是字符串列表\n        if isinstance(descriptions, list):\n            return [str(desc) for desc in descriptions]\n        else:\n            raise ValueError(\"Expected a list of page descriptions, but got: \" + str(type(descriptions)))\n    \n    def refine_outline(self, current_outline: List[Dict], user_requirement: str,\n                      project_context: ProjectContext,\n                      previous_requirements: Optional[List[str]] = None,\n                      language='zh') -> List[Dict]:\n        \"\"\"\n        根据用户要求修改已有大纲\n        \n        Args:\n            current_outline: 当前的大纲结构\n            user_requirement: 用户的新要求\n            project_context: 项目上下文对象，包含所有原始信息\n            previous_requirements: 之前的修改要求列表（可选）\n        \n        Returns:\n            修改后的大纲结构\n        \"\"\"\n        refinement_prompt = get_outline_refinement_prompt(\n            current_outline=current_outline,\n            user_requirement=user_requirement,\n            project_context=project_context,\n            previous_requirements=previous_requirements,\n            language=language\n        )\n        outline = self.generate_json(refinement_prompt, thinking_budget=1000)\n        return outline\n    \n    def refine_descriptions(self, current_descriptions: List[Dict], user_requirement: str,\n                           project_context: ProjectContext,\n                           outline: List[Dict] = None,\n                           previous_requirements: Optional[List[str]] = None,\n                           language='zh') -> List[str]:\n        \"\"\"\n        根据用户要求修改已有页面描述\n        \n        Args:\n            current_descriptions: 当前的页面描述列表，每个元素包含 {index, title, description_content}\n            user_requirement: 用户的新要求\n            project_context: 项目上下文对象，包含所有原始信息\n            outline: 完整的大纲结构（可选）\n            previous_requirements: 之前的修改要求列表（可选）\n        \n        Returns:\n            修改后的页面描述列表（字符串列表）\n        \"\"\"\n        refinement_prompt = get_descriptions_refinement_prompt(\n            current_descriptions=current_descriptions,\n            user_requirement=user_requirement,\n            project_context=project_context,\n            outline=outline,\n            previous_requirements=previous_requirements,\n            language=language\n        )\n        descriptions = self.generate_json(refinement_prompt, thinking_budget=1000)\n\n        # 确保返回的是字符串列表\n        if isinstance(descriptions, list):\n            return [str(desc) for desc in descriptions]\n        else:\n            raise ValueError(\"Expected a list of page descriptions, but got: \" + str(type(descriptions)))\n\n    def extract_page_content(self, markdown_text: str, language: str = 'zh') -> Dict:\n        \"\"\"\n        从 fileparser 解析出的 markdown 文本中提取页面结构化内容\n\n        Args:\n            markdown_text: 单页 PDF 解析出的 markdown 文本\n            language: 输出语言\n\n        Returns:\n            Dict with keys: title, points, description\n        \"\"\"\n        prompt = get_ppt_page_content_extraction_prompt(markdown_text, language=language)\n        result = self.generate_json(prompt, thinking_budget=1000)\n\n        # Ensure required fields exist\n        if not isinstance(result, dict):\n            raise ValueError(f\"Expected dict, got {type(result)}\")\n\n        result.setdefault('title', '')\n        result.setdefault('points', [])\n        result.setdefault('description', '')\n\n        return result\n\n    def _generate_text_from_image(self, prompt: str, image_path: str) -> str:\n        \"\"\"Helper to generate text from a prompt and an image, using caption_provider.\"\"\"\n        actual_budget = self._get_text_thinking_budget()\n        provider = self.caption_provider\n\n        if hasattr(provider, 'generate_with_image'):\n            response_text = provider.generate_with_image(\n                prompt=prompt,\n                image_path=image_path,\n                thinking_budget=actual_budget\n            )\n        elif hasattr(provider, 'generate_text_with_images'):\n            response_text = provider.generate_text_with_images(\n                prompt=prompt,\n                images=[image_path],\n                thinking_budget=actual_budget\n            )\n        else:\n            raise ValueError(\"caption_provider 不支持图片输入\")\n\n        return response_text.strip()\n\n    def generate_layout_caption(self, image_path: str) -> str:\n        \"\"\"使用 caption model 描述 PPT 页面的排版布局\"\"\"\n        return self._generate_text_from_image(get_layout_caption_prompt(), image_path)\n\n    def extract_style_description(self, image_path: str) -> str:\n        \"\"\"从图片中提取风格描述\"\"\"\n        return self._generate_text_from_image(get_style_extraction_prompt(), image_path)\n\n"
  },
  {
    "path": "backend/services/ai_service_manager.py",
    "content": "\"\"\"\nAIService singleton manager for optimizing provider initialization\n\nThis module provides a singleton pattern implementation for AIService to avoid\nrepeated initialization of AI providers (TextProvider and ImageProvider) on every request.\n\nBenefits:\n- Reuses AI provider instances across requests\n- Reduces initialization overhead\n- Better resource management\n- Thread-safe for Flask multi-threaded environment\n\nUsage:\n    from services.ai_service_manager import get_ai_service\n    \n    # In your controller\n    ai_service = get_ai_service()\n    outline = ai_service.generate_outline(project_context)\n\"\"\"\n\nimport logging\nfrom threading import Lock\nfrom typing import Optional\nfrom flask import current_app, has_app_context\nfrom .ai_service import AIService\nfrom .ai_providers import get_text_provider, get_image_provider, get_caption_provider, TextProvider, ImageProvider\n\nlogger = logging.getLogger(__name__)\n\n# Global singleton instance\n_ai_service_instance: Optional[AIService] = None\n_lock = Lock()\n\n# Provider cache to avoid re-initialization when models don't change\n_text_provider_cache: dict = {}\n_image_provider_cache: dict = {}\n_caption_provider_cache: dict = {}\n_cache_lock = Lock()\n\n\ndef _get_cached_text_provider(model: str) -> TextProvider:\n    \"\"\"\n    Get or create a cached text provider instance\n    \n    Args:\n        model: Model name to use\n        \n    Returns:\n        Cached or new TextProvider instance\n    \"\"\"\n    with _cache_lock:\n        if model not in _text_provider_cache:\n            logger.info(f\"Creating new TextProvider for model: {model}\")\n            _text_provider_cache[model] = get_text_provider(model=model)\n        else:\n            logger.debug(f\"Reusing cached TextProvider for model: {model}\")\n        return _text_provider_cache[model]\n\n\ndef _get_cached_image_provider(model: str) -> ImageProvider:\n    \"\"\"\n    Get or create a cached image provider instance\n    \n    Args:\n        model: Model name to use\n        \n    Returns:\n        Cached or new ImageProvider instance\n    \"\"\"\n    with _cache_lock:\n        if model not in _image_provider_cache:\n            logger.info(f\"Creating new ImageProvider for model: {model}\")\n            _image_provider_cache[model] = get_image_provider(model=model)\n        else:\n            logger.debug(f\"Reusing cached ImageProvider for model: {model}\")\n        return _image_provider_cache[model]\n\n\ndef _get_cached_caption_provider(model: str) -> TextProvider:\n    \"\"\"Get or create a cached caption provider instance\"\"\"\n    with _cache_lock:\n        if model not in _caption_provider_cache:\n            logger.info(f\"Creating new CaptionProvider for model: {model}\")\n            _caption_provider_cache[model] = get_caption_provider(model=model)\n        return _caption_provider_cache[model]\n\n\ndef get_ai_service(force_new: bool = False) -> AIService:\n    \"\"\"\n    Get the singleton AIService instance with optimized provider caching\n    \n    This function creates and returns a singleton AIService instance that reuses\n    AI providers (TextProvider and ImageProvider) across requests, significantly\n    reducing initialization overhead.\n    \n    Args:\n        force_new: If True, forces creation of a new instance (useful for testing)\n        \n    Returns:\n        AIService singleton instance with cached providers\n        \n    Note:\n        The providers are cached per model name. If TEXT_MODEL or IMAGE_MODEL\n        changes in Flask config, new providers will be created automatically.\n    \"\"\"\n    global _ai_service_instance\n    \n    if force_new:\n        with _lock:\n            logger.info(\"Force creating new AIService instance\")\n            _ai_service_instance = None\n    \n    if _ai_service_instance is None:\n        with _lock:\n            # Double-check locking pattern\n            if _ai_service_instance is None:\n                logger.info(\"Initializing AIService singleton with provider caching\")\n                \n                # Get model names from Flask config or use defaults\n                from config import get_config\n                config = get_config()\n                \n                if has_app_context() and current_app and hasattr(current_app, \"config\"):\n                    text_model = current_app.config.get(\"TEXT_MODEL\", config.TEXT_MODEL)\n                    image_model = current_app.config.get(\"IMAGE_MODEL\", config.IMAGE_MODEL)\n                    caption_model = current_app.config.get(\"IMAGE_CAPTION_MODEL\", config.IMAGE_CAPTION_MODEL)\n                else:\n                    text_model = config.TEXT_MODEL\n                    image_model = config.IMAGE_MODEL\n                    caption_model = config.IMAGE_CAPTION_MODEL\n\n                # Get cached providers\n                text_provider = _get_cached_text_provider(text_model)\n                image_provider = _get_cached_image_provider(image_model)\n                caption_provider = _get_cached_caption_provider(caption_model)\n\n                # Create AIService with cached providers\n                _ai_service_instance = AIService(\n                    text_provider=text_provider,\n                    image_provider=image_provider,\n                    caption_provider=caption_provider\n                )\n\n                logger.info(f\"AIService singleton created with models: text={text_model}, image={image_model}, caption={caption_model}\")\n    \n    return _ai_service_instance\n\n\ndef clear_ai_service_cache():\n    \"\"\"\n    Clear the AIService singleton and provider cache\n    \n    This is useful when:\n    - Configuration changes (API keys, endpoints, models)\n    - Testing scenarios requiring fresh instances\n    - Memory cleanup needed\n    \n    Note:\n    - Uses nested locks to ensure atomic cache clearing operation\n    - Prevents race conditions where new instances could be created\n      with stale cached providers during the clearing process\n    \"\"\"\n    global _ai_service_instance\n    \n    with _lock:\n        _ai_service_instance = None\n        logger.info(\"AIService singleton cache cleared\")\n        with _cache_lock:\n            _text_provider_cache.clear()\n            _image_provider_cache.clear()\n            _caption_provider_cache.clear()\n            logger.info(\"Provider cache cleared\")\n\n\ndef get_provider_cache_info() -> dict:\n    \"\"\"\n    Get information about cached providers (for debugging/monitoring)\n    \n    Returns:\n        Dictionary with cache statistics\n    \"\"\"\n    with _cache_lock:\n        return {\n            \"text_providers\": list(_text_provider_cache.keys()),\n            \"image_providers\": list(_image_provider_cache.keys()),\n            \"caption_providers\": list(_caption_provider_cache.keys()),\n            \"total_cached\": len(_text_provider_cache) + len(_image_provider_cache) + len(_caption_provider_cache)\n        }\n"
  },
  {
    "path": "backend/services/export_service.py",
    "content": "\"\"\"\nExport Service - handles PPTX and PDF export\nBased on demo.py create_pptx_from_images()\n\"\"\"\nimport math\nimport os\nimport json\nimport logging\nimport tempfile\nimport base64\nimport hashlib\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import List, Dict, Any, Optional, Tuple\nfrom textwrap import dedent\nfrom dataclasses import dataclass, field\nfrom pptx import Presentation\nfrom pptx.util import Inches\nfrom PIL import Image\nimport io\nimport tempfile\nimport img2pdf\nimport fitz  # PyMuPDF\nlogger = logging.getLogger(__name__)\n\n\nclass ExportError(Exception):\n    \"\"\"\n    导出过程中的错误异常\n\n    当 fail_fast=True 时，任何导出错误都会抛出此异常，\n    包含详细的错误信息和帮助提示。\n    \"\"\"\n    def __init__(self, message: str, error_type: str = 'unknown', details: Dict[str, Any] = None, help_text: str = None):\n        \"\"\"\n        Args:\n            message: 错误消息\n            error_type: 错误类型 (style_extraction, text_render, image_add, inpaint, config, service)\n            details: 详细错误信息\n            help_text: 帮助提示文本\n        \"\"\"\n        super().__init__(message)\n        self.message = message\n        self.error_type = error_type\n        self.details = details or {}\n        self.help_text = help_text or self._get_default_help_text(error_type)\n\n    def _get_default_help_text(self, error_type: str) -> str:\n        \"\"\"根据错误类型返回默认帮助提示\"\"\"\n        help_texts = {\n            'style_extraction': '样式提取失败可能是由于百度OCR API配置问题。请检查「项目设置 -> 导出设置」中的配置，或尝试切换到「MinerU提取」方法。',\n            'text_render': '文本渲染失败可能是由于字体或编码问题。请检查页面内容是否包含特殊字符。',\n            'image_add': '图片添加失败可能是由于图片文件损坏或路径错误。请尝试重新生成该页面的图片。',\n            'inpaint': '背景修复失败可能是由于API配置问题。请检查「项目设置 -> 导出设置」中的背景图获取方法配置。',\n            'config': '配置错误。请检查「项目设置 -> 导出设置」中的相关配置。',\n            'service': '服务不可用。请稍后重试或联系管理员。',\n        }\n        return help_texts.get(error_type, '如果问题持续出现，可以在「项目设置 -> 导出设置」中开启「返回半成品」选项以跳过错误继续导出。')\n\n    def to_dict(self) -> Dict[str, Any]:\n        \"\"\"转换为字典格式\"\"\"\n        return {\n            'message': self.message,\n            'error_type': self.error_type,\n            'details': self.details,\n            'help_text': self.help_text\n        }\n\n\n@dataclass\nclass ExportWarnings:\n    \"\"\"\n    导出过程中收集的警告信息\n    \n    用于追踪哪些操作没有按预期执行，并反馈给前端\n    \"\"\"\n    # 样式提取失败的元素\n    style_extraction_failed: List[Dict[str, Any]] = field(default_factory=list)\n    \n    # 文本渲染失败的元素\n    text_render_failed: List[Dict[str, Any]] = field(default_factory=list)\n    \n    # 图片添加失败\n    image_add_failed: List[Dict[str, Any]] = field(default_factory=list)\n    \n    # JSON 解析失败（重试后仍失败）\n    json_parse_failed: List[Dict[str, Any]] = field(default_factory=list)\n    \n    # 其他警告\n    other_warnings: List[str] = field(default_factory=list)\n    \n    def add_style_extraction_failed(self, element_id: str, reason: str):\n        \"\"\"记录样式提取失败\"\"\"\n        self.style_extraction_failed.append({\n            'element_id': element_id,\n            'reason': reason\n        })\n    \n    def add_text_render_failed(self, text: str, reason: str):\n        \"\"\"记录文本渲染失败\"\"\"\n        self.text_render_failed.append({\n            'text': text[:50] + '...' if len(text) > 50 else text,\n            'reason': reason\n        })\n    \n    def add_image_failed(self, path: str, reason: str):\n        \"\"\"记录图片添加失败\"\"\"\n        self.image_add_failed.append({\n            'path': path,\n            'reason': reason\n        })\n    \n    def add_json_parse_failed(self, context: str, reason: str):\n        \"\"\"记录 JSON 解析失败\"\"\"\n        self.json_parse_failed.append({\n            'context': context,\n            'reason': reason\n        })\n    \n    def add_warning(self, message: str):\n        \"\"\"添加其他警告\"\"\"\n        self.other_warnings.append(message)\n    \n    def has_warnings(self) -> bool:\n        \"\"\"是否有警告\"\"\"\n        return bool(\n            self.style_extraction_failed or \n            self.text_render_failed or \n            self.image_add_failed or\n            self.json_parse_failed or\n            self.other_warnings\n        )\n    \n    def to_summary(self) -> List[str]:\n        \"\"\"生成警告摘要（适合前端展示）\"\"\"\n        summary = []\n        \n        if self.style_extraction_failed:\n            summary.append(f\"⚠️ {len(self.style_extraction_failed)} 个文本元素样式提取失败\")\n        \n        if self.text_render_failed:\n            summary.append(f\"⚠️ {len(self.text_render_failed)} 个文本元素渲染失败\")\n        \n        if self.image_add_failed:\n            summary.append(f\"⚠️ {len(self.image_add_failed)} 张图片添加失败\")\n        \n        if self.json_parse_failed:\n            summary.append(f\"⚠️ {len(self.json_parse_failed)} 次 AI 响应解析失败\")\n        \n        for warning in self.other_warnings[:5]:  # 最多显示5条其他警告\n            summary.append(f\"⚠️ {warning}\")\n        \n        if len(self.other_warnings) > 5:\n            summary.append(f\"  ...还有 {len(self.other_warnings) - 5} 条其他警告\")\n        \n        return summary\n    \n    def to_dict(self) -> Dict[str, Any]:\n        \"\"\"转换为字典（详细信息）\"\"\"\n        return {\n            'style_extraction_failed': self.style_extraction_failed,\n            'text_render_failed': self.text_render_failed,\n            'image_add_failed': self.image_add_failed,\n            'json_parse_failed': self.json_parse_failed,\n            'other_warnings': self.other_warnings,\n            'total_warnings': (\n                len(self.style_extraction_failed) + \n                len(self.text_render_failed) + \n                len(self.image_add_failed) +\n                len(self.json_parse_failed) +\n                len(self.other_warnings)\n            )\n        }\n\n\ndef _get_page_size_inches(aspect_ratio: str = '16:9', base: float = 10.0) -> Tuple[float, float]:\n    \"\"\"Return (width, height) in inches for a given aspect ratio string.\"\"\"\n    try:\n        w, h = (float(x) for x in aspect_ratio.split(':'))\n        if not (math.isfinite(w) and math.isfinite(h) and w > 0 and h > 0):\n            raise ValueError(f\"invalid dimensions: {w}:{h}\")\n    except (ValueError, AttributeError) as e:\n        logger.warning(f\"Invalid aspect ratio '{aspect_ratio}', falling back to 16:9: {e}\")\n        w, h = 16.0, 9.0\n    if w >= h:\n        return base, base * h / w\n    else:\n        return base * w / h, base\n\n\nclass ExportService:\n    \"\"\"Service for exporting presentations\"\"\"\n\n    # NOTE: clean background生成功能已迁移到解耦的InpaintProvider实现\n    # - DefaultInpaintProvider: 基于mask的精确区域重绘（Volcengine）\n    # - GenerativeEditInpaintProvider: 基于生成式大模型的整图编辑重绘（Gemini等）\n    # 使用方式: from services.image_editability import InpaintProviderFactory\n\n    @staticmethod\n    def _build_style_extraction_error(\n        message: str,\n        *,\n        element_id: Optional[str] = None,\n        text_content: Optional[str] = None,\n        page_idx: Optional[int] = None\n    ) -> ExportError:\n        details: Dict[str, Any] = {}\n        if element_id:\n            details['element_id'] = element_id\n        if text_content:\n            details['text_content'] = text_content[:50]\n        if page_idx is not None:\n            details['page'] = page_idx + 1\n\n        lowered = message.lower()\n        if '不支持图片输入' in message or 'support image input' in lowered:\n            help_text = (\n                '当前用于图片样式提取的 caption/image_caption 模型不支持图片输入。'\n                '请在设置中改成支持视觉输入的模型，或检查 OpenAI 格式下的 image caption provider / model 配置。'\n            )\n        else:\n            help_text = (\n                '文本样式提取依赖视觉模型分析文本截图。请检查 image caption provider、模型名与 API 权限；'\n                '如果只想先拿到可编辑结果，也可以在「项目设置 -> 导出设置」中开启「返回半成品」。'\n            )\n\n        return ExportError(\n            message=f\"文本样式提取失败: {message}\",\n            error_type='style_extraction',\n            details=details,\n            help_text=help_text,\n        )\n    \n    @staticmethod\n    def create_pptx_from_images(image_paths: List[str], output_file: str = None, aspect_ratio: str = '16:9') -> bytes:\n        \"\"\"\n        Create PPTX file from image paths\n        Based on demo.py create_pptx_from_images()\n        \n        Args:\n            image_paths: List of absolute paths to images\n            output_file: Optional output file path (if None, returns bytes)\n        \n        Returns:\n            PPTX file as bytes if output_file is None\n        \"\"\"\n        # Create presentation\n        prs = Presentation()\n        \n        # Set author/date metadata for exported PPTX\n        try:\n            core = prs.core_properties\n            now = datetime.now(timezone.utc)\n            core.author = \"banana-slides\"\n            core.last_modified_by = \"banana-slides\"\n            core.created = now\n            core.modified = now\n            core.last_printed = None\n        except Exception as e:\n            logger.warning(f\"Failed to set core properties: {e}\")\n        \n        # Set slide dimensions based on aspect ratio\n        page_w, page_h = _get_page_size_inches(aspect_ratio)\n        prs.slide_width = Inches(page_w)\n        prs.slide_height = Inches(page_h)\n        \n        # Add each image as a slide\n        for image_path in image_paths:\n            if not os.path.exists(image_path):\n                logger.warning(f\"Image not found: {image_path}\")\n                continue\n            \n            # Add blank slide layout (layout 6 is typically blank)\n            blank_slide_layout = prs.slide_layouts[6]\n            slide = prs.slides.add_slide(blank_slide_layout)\n            \n            # Add image to fill entire slide\n            slide.shapes.add_picture(\n                image_path,\n                left=0,\n                top=0,\n                width=prs.slide_width,\n                height=prs.slide_height\n            )\n        \n        # Save or return bytes\n        if output_file:\n            prs.save(output_file)\n            return None\n        else:\n            # Save to bytes\n            pptx_bytes = io.BytesIO()\n            prs.save(pptx_bytes)\n            pptx_bytes.seek(0)\n            return pptx_bytes.getvalue()\n    \n    @staticmethod\n    def create_pdf_from_images(image_paths: List[str], output_file: str = None, aspect_ratio: str = '16:9') -> Optional[bytes]:\n        \"\"\"\n        Create PDF file from image paths using img2pdf (low memory usage)\n\n        Args:\n            image_paths: List of absolute paths to images\n            output_file: Optional output file path (if None, returns bytes)\n\n        Returns:\n            PDF file as bytes if output_file is None, otherwise None\n        \"\"\"\n        # Validate images exist and log warnings for missing files\n        valid_paths = []\n        for p in image_paths:\n            if os.path.exists(p):\n                valid_paths.append(p)\n            else:\n                logger.warning(f\"Image not found and will be skipped for PDF export: {p}\")\n\n        if not valid_paths:\n            raise ValueError(\"No valid images found for PDF export\")\n\n        try:\n            logger.info(f\"Using img2pdf for PDF export ({len(valid_paths)} pages, low memory mode)\")\n\n            page_w, page_h = _get_page_size_inches(aspect_ratio)\n            layout_fun = img2pdf.get_layout_fun(\n                pagesize=(img2pdf.in_to_pt(page_w), img2pdf.in_to_pt(page_h)),\n                fit=img2pdf.FitMode.fill,\n            )\n\n            # Convert images to PDF\n            pdf_bytes = img2pdf.convert(valid_paths, layout_fun=layout_fun)\n\n            # Add metadata\n            pdf_bytes = ExportService._add_pdf_metadata(pdf_bytes)\n\n            if output_file:\n                with open(output_file, \"wb\") as f:\n                    f.write(pdf_bytes)\n                return None\n            else:\n                return pdf_bytes\n        except (img2pdf.ImageOpenError, ValueError, IOError) as e:\n            logger.warning(f\"img2pdf conversion failed: {e}. Falling back to Pillow (high memory usage).\")\n            return ExportService.create_pdf_from_images_pillow(valid_paths, output_file, aspect_ratio)\n\n    @staticmethod\n    def _add_pdf_metadata(pdf_bytes: bytes) -> bytes:\n        \"\"\"Add author metadata to PDF (including XMP for Windows compatibility)\"\"\"\n        try:\n            doc = fitz.open(stream=pdf_bytes, filetype=\"pdf\")\n\n            doc.set_metadata({\n                \"author\": \"banana-slides\",\n                \"producer\": \"banana-slides\",\n                \"creator\": \"banana-slides\"\n            })\n\n            now = datetime.now(timezone.utc)\n            iso_time = now.isoformat()\n\n            content_hash = hashlib.md5(pdf_bytes[:1024]).hexdigest()\n\n            xmp = dedent(f'''\\\n                <?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n                <x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n                  <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n                    <rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n                      <dc:creator><rdf:Seq><rdf:li>banana-slides</rdf:li></rdf:Seq></dc:creator>\n                    </rdf:Description>\n                    <rdf:Description rdf:about=\"\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\">\n                      <pdf:Producer>banana-slides</pdf:Producer>\n                    </rdf:Description>\n                    <rdf:Description rdf:about=\"\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\">\n                      <xmp:CreatorTool>banana-slides</xmp:CreatorTool>\n                      <xmp:CreateDate>{iso_time}</xmp:CreateDate>\n                      <xmp:MetadataDate>{iso_time}</xmp:MetadataDate>\n                    </rdf:Description>\n                    <rdf:Description rdf:about=\"\" xmlns:xmpMM=\"http://ns.adobe.com/xap/1.0/mm/\">\n                      <xmpMM:DocumentID>uuid:{content_hash}</xmpMM:DocumentID>\n                    </rdf:Description>\n                  </rdf:RDF>\n                </x:xmpmeta>\n                <?xpacket end=\"w\"?>''')\n            doc.set_xml_metadata(xmp)\n\n            return doc.tobytes()\n        except Exception as e:\n            logger.warning(f\"Failed to add PDF metadata: {e}\")\n            return pdf_bytes\n\n    @staticmethod\n    def create_pdf_from_images_pillow(image_paths: List[str], output_file: str = None, aspect_ratio: str = '16:9') -> Optional[bytes]:\n        \"\"\"\n        Create PDF file from image paths using Pillow (original method)\n\n        Note: This method loads all images into memory at once.\n        For large projects (50+ pages with 20MB/page), use create_pdf_from_images instead.\n\n        Args:\n            image_paths: List of absolute paths to images\n            output_file: Optional output file path (if None, returns bytes)\n\n        Returns:\n            PDF file as bytes if output_file is None, otherwise None\n        \"\"\"\n        images = []\n        page_w, page_h = _get_page_size_inches(aspect_ratio)\n\n        # Load all images\n        for image_path in image_paths:\n            if not os.path.exists(image_path):\n                logger.warning(f\"Image not found: {image_path}\")\n                continue\n\n            img = Image.open(image_path)\n\n            # Convert to RGB if necessary (PDF requires RGB)\n            if img.mode != 'RGB':\n                img = img.convert('RGB')\n\n            # Set DPI so PDF page matches target dimensions\n            img.info['dpi'] = (img.width / page_w, img.height / page_h)\n\n            images.append(img)\n\n        if not images:\n            raise ValueError(\"No valid images found for PDF export\")\n\n        # Save as PDF\n        if output_file:\n            images[0].save(\n                output_file,\n                save_all=True,\n                append_images=images[1:],\n                format='PDF'\n            )\n            return None\n        else:\n            # Save to bytes\n            pdf_bytes = io.BytesIO()\n            images[0].save(\n                pdf_bytes,\n                save_all=True,\n                append_images=images[1:],\n                format='PDF'\n            )\n            pdf_bytes.seek(0)\n            return ExportService._add_pdf_metadata(pdf_bytes.getvalue())\n       \n    @staticmethod\n    def _add_mineru_text_to_slide(builder, slide, text_item: Dict[str, Any], scale_x: float = 1.0, scale_y: float = 1.0):\n        \"\"\"\n        Add text item from MinerU to slide\n        \n        Args:\n            builder: PPTXBuilder instance\n            slide: Target slide\n            text_item: Text item from MinerU content_list\n            scale_x: X-axis scale factor\n            scale_y: Y-axis scale factor\n        \"\"\"\n        text = text_item.get('text', '').strip()\n        if not text:\n            return\n        \n        bbox = text_item.get('bbox')\n        if not bbox or len(bbox) != 4:\n            logger.warning(f\"Invalid bbox for text item: {text_item}\")\n            return\n        \n        original_bbox = bbox.copy()\n        \n        # Apply scale factors to bbox\n        x0, y0, x1, y1 = bbox\n        bbox = [\n            int(x0 * scale_x),\n            int(y0 * scale_y),\n            int(x1 * scale_x),\n            int(y1 * scale_y)\n        ]\n        \n        if scale_x != 1.0 or scale_y != 1.0:\n            logger.debug(f\"Text bbox scaled: {original_bbox} -> {bbox} (scale: {scale_x:.3f}x{scale_y:.3f})\")\n        \n        # Determine text level (only used for styling like bold, NOT for font size)\n        # Font size is purely calculated from bbox dimensions\n        item_type = text_item.get('type', 'text')\n        text_level = text_item.get('text_level')\n        \n        # Map to level for styling purposes (bold titles)\n        if item_type == 'title' or text_level == 1:\n            level = 'title'  # Will be bold\n        else:\n            level = 'default'\n        \n        # Add text element\n        # Note: text_level is only used for bold styling, not font size calculation\n        try:\n            builder.add_text_element(\n                slide=slide,\n                text=text,\n                bbox=bbox,\n                text_level=level  # For styling (bold) only, not font size\n            )\n        except Exception as e:\n            logger.error(f\"Failed to add text element: {str(e)}\")\n    \n    @staticmethod\n    def _add_table_cell_elements_to_slide(\n        builder,\n        slide,\n        cell_elements: List[Dict[str, Any]],\n        scale_x: float = 1.0,\n        scale_y: float = 1.0\n    ):\n        \"\"\"\n        Add table cell elements as individual text boxes to slide\n        这些单元格元素已经有正确的全局bbox坐标\n        \n        Args:\n            builder: PPTXBuilder instance\n            slide: Target slide\n            cell_elements: List of EditableElement (table_cell type)\n            scale_x: X-axis scale factor\n            scale_y: Y-axis scale factor\n        \"\"\"\n        from pptx.util import Pt\n        from pptx.dml.color import RGBColor\n        \n        logger.info(f\"开始添加表格单元格元素，共 {len(cell_elements)} 个\")\n        \n        for cell_elem in cell_elements:\n            text = cell_elem.get('content', '')\n            bbox_global = cell_elem.get('bbox_global', {})\n            \n            if not text.strip():\n                continue\n            \n            # bbox_global已经是全局坐标，直接使用并应用缩放\n            x0 = bbox_global.get('x0', 0)\n            y0 = bbox_global.get('y0', 0)\n            x1 = bbox_global.get('x1', 0)\n            y1 = bbox_global.get('y1', 0)\n            \n            # 构建bbox列表 [x0, y0, x1, y1] 并应用缩放\n            bbox = [\n                int(x0 * scale_x),\n                int(y0 * scale_y),\n                int(x1 * scale_x),\n                int(y1 * scale_y)\n            ]\n            \n            try:\n                # 使用已有的 add_text_element 方法添加文本框（不添加边框）\n                builder.add_text_element(\n                    slide=slide,\n                    text=text,\n                    bbox=bbox,\n                    text_level=None,\n                    align='center'\n                )\n                \n                logger.debug(f\"  添加单元格: '{text[:10]}...' at bbox {bbox}\")\n                \n            except Exception as e:\n                logger.warning(f\"添加单元格失败: {e}\")\n        \n        logger.info(f\"✓ 表格单元格添加完成，共 {len(cell_elements)} 个\")\n    \n    @staticmethod\n    def _add_mineru_image_to_slide(\n        builder,\n        slide,\n        image_item: Dict[str, Any],\n        mineru_dir: Path,\n        scale_x: float = 1.0,\n        scale_y: float = 1.0\n    ):\n        \"\"\"\n        Add image or table item from MinerU to slide\n        \n        Args:\n            builder: PPTXBuilder instance\n            slide: Target slide\n            image_item: Image/table item from MinerU content_list\n            mineru_dir: MinerU result directory\n            scale_x: X-axis scale factor\n            scale_y: Y-axis scale factor\n        \"\"\"\n        bbox = image_item.get('bbox')\n        if not bbox or len(bbox) != 4:\n            logger.warning(f\"Invalid bbox for image item: {image_item}\")\n            return\n        \n        original_bbox = bbox.copy()\n        \n        # Apply scale factors to bbox\n        x0, y0, x1, y1 = bbox\n        bbox = [\n            int(x0 * scale_x),\n            int(y0 * scale_y),\n            int(x1 * scale_x),\n            int(y1 * scale_y)\n        ]\n        \n        if scale_x != 1.0 or scale_y != 1.0:\n            logger.debug(f\"Item bbox scaled: {original_bbox} -> {bbox} (scale: {scale_x:.3f}x{scale_y:.3f})\")\n        \n        # Check if this is a table with子元素 (cells from Baidu OCR)\n        item_type = image_item.get('element_type') or image_item.get('type', 'image')\n        children = image_item.get('children', [])\n        \n        logger.debug(f\"Processing {item_type} element, has {len(children)} children\")\n        \n        if children and item_type == 'table':\n            # Add editable table from child elements (cells)\n            try:\n                # Filter only table_cell elements\n                cell_elements = [child for child in children if child.get('element_type') == 'table_cell']\n                \n                if cell_elements:\n                    logger.info(f\"添加可编辑表格（{len(cell_elements)}个单元格）\")\n                    ExportService._add_table_cell_elements_to_slide(\n                        builder=builder,\n                        slide=slide,\n                        cell_elements=cell_elements,\n                        scale_x=scale_x,\n                        scale_y=scale_y\n                    )\n                    return  # Table added successfully\n            except Exception as e:\n                logger.exception(\"Failed to add table cells, falling back to image\")\n                # Fall through to add as image instead\n        \n        # Check if this is a table with HTML data (legacy)\n        html_table = image_item.get('html_table')\n        if html_table and item_type == 'table':\n            # Add editable table from HTML\n            try:\n                builder.add_table_element(\n                    slide=slide,\n                    html_table=html_table,\n                    bbox=bbox\n                )\n                logger.info(f\"Added editable table at bbox {bbox}\")\n                return  # Table added successfully\n            except Exception as e:\n                logger.error(f\"Failed to add table: {str(e)}, falling back to image\")\n                # Fall through to add as image instead\n        \n        # Add as image (either image type or table fallback)\n        img_path_str = image_item.get('img_path', '')\n        if not img_path_str:\n            logger.warning(f\"No img_path in item: {image_item}\")\n            return\n        \n        # Try to find the image file\n        # MinerU may store images in 'images/' subdirectory\n        possible_paths = [\n            mineru_dir / img_path_str,\n            mineru_dir / 'images' / Path(img_path_str).name,\n            mineru_dir / Path(img_path_str).name,\n        ]\n        \n        image_path = None\n        for path in possible_paths:\n            if path.exists():\n                image_path = str(path)\n                break\n        \n        if not image_path:\n            logger.warning(f\"Image file not found: {img_path_str}\")\n            # Add placeholder\n            builder.add_image_placeholder(slide, bbox)\n            return\n        \n        # Add image element\n        try:\n            builder.add_image_element(\n                slide=slide,\n                image_path=image_path,\n                bbox=bbox\n            )\n        except Exception as e:\n            logger.error(f\"Failed to add image element: {str(e)}\")\n    \n    @staticmethod\n    def _collect_text_elements_for_extraction(\n        elements: List,  # List[EditableElement]\n        depth: int = 0\n    ) -> List[tuple]:\n        \"\"\"\n        递归收集所有需要提取样式的文本元素\n        \n        Args:\n            elements: EditableElement列表\n            depth: 当前递归深度\n        \n        Returns:\n            元组列表，每个元组为 (element_id, image_path, text_content)\n        \"\"\"\n        text_items = []\n        \n        for elem in elements:\n            elem_type = elem.element_type\n            \n            # 文本类型元素需要提取样式\n            if elem_type in ['text', 'title', 'table_cell', 'list', 'paragraph', 'header', 'footer', 'heading', 'table_caption', 'image_caption']:\n                if elem.content and elem.image_path and os.path.exists(elem.image_path):\n                    text = elem.content.strip()\n                    if text:\n                        text_items.append((elem.element_id, elem.image_path, text))\n            \n            # 递归处理子元素\n            if hasattr(elem, 'children') and elem.children:\n                child_items = ExportService._collect_text_elements_for_extraction(\n                    elements=elem.children,\n                    depth=depth + 1\n                )\n                text_items.extend(child_items)\n        \n        return text_items\n    \n    @staticmethod\n    def _batch_extract_text_styles(\n        text_items: List[tuple],\n        text_attribute_extractor,\n        max_workers: int = 8\n    ) -> Dict[str, Any]:\n        \"\"\"\n        批量并行提取文本样式（逐个裁剪区域分析）\n        \n        此方法对每一段文字的裁剪区域单独进行分析。\n        经测试，此方法效果较好，目前仍在使用。\n        \n        备选方案：_batch_extract_text_styles_with_full_image 可一次性分析全图所有文本。\n        \n        Args:\n            text_items: 元组列表，每个元组为 (element_id, image_path, text_content)\n            text_attribute_extractor: 文本属性提取器\n            max_workers: 并发数\n        \n        Returns:\n            字典，key为element_id，value为TextStyleResult\n        \"\"\"\n        from concurrent.futures import ThreadPoolExecutor, as_completed\n        \n        if not text_items or not text_attribute_extractor:\n            return {}\n        \n        logger.info(f\"并行提取 {len(text_items)} 个文本元素的样式（并发数: {max_workers}）...\")\n        \n        results = {}\n        \n        def extract_single(item):\n            element_id, image_path, text_content = item\n            try:\n                style = text_attribute_extractor.extract(\n                    image=image_path,\n                    text_content=text_content\n                )\n                return element_id, style\n            except Exception as e:\n                logger.warning(f\"提取文字样式失败 [{element_id}]: {e}\")\n                return element_id, None\n        \n        with ThreadPoolExecutor(max_workers=max_workers) as executor:\n            futures = {executor.submit(extract_single, item): item[0] for item in text_items}\n            \n            for future in as_completed(futures):\n                element_id, style = future.result()\n                if style is not None:\n                    results[element_id] = style\n        \n        logger.info(f\"✓ 文本样式提取完成，成功 {len(results)}/{len(text_items)} 个\")\n        return results\n    \n    @staticmethod\n    def _collect_text_elements_for_batch_extraction(\n        elements: List,  # List[EditableElement]\n        depth: int = 0\n    ) -> List[Dict[str, Any]]:\n        \"\"\"\n        递归收集所有需要批量提取样式的文本元素（新格式，包含bbox）\n        \n        Args:\n            elements: EditableElement列表\n            depth: 当前递归深度\n        \n        Returns:\n            字典列表，每个字典包含 element_id, bbox, content\n        \"\"\"\n        text_items = []\n        \n        for elem in elements:\n            elem_type = elem.element_type\n            \n            # 文本类型元素需要提取样式\n            if elem_type in ['text', 'title', 'table_cell', 'list', 'paragraph', 'header', 'footer', 'heading', 'table_caption', 'image_caption']:\n                if elem.content:\n                    text = elem.content.strip()\n                    if text:\n                        # 使用全局坐标 bbox_global\n                        bbox = elem.bbox_global if hasattr(elem, 'bbox_global') and elem.bbox_global else elem.bbox\n                        text_items.append({\n                            'element_id': elem.element_id,\n                            'bbox': [bbox.x0, bbox.y0, bbox.x1, bbox.y1],\n                            'content': text\n                        })\n            \n            # 递归处理子元素\n            if hasattr(elem, 'children') and elem.children:\n                child_items = ExportService._collect_text_elements_for_batch_extraction(\n                    elements=elem.children,\n                    depth=depth + 1\n                )\n                text_items.extend(child_items)\n        \n        return text_items\n    \n    @staticmethod\n    def _batch_extract_text_styles_with_full_image(\n        editable_images: List,  # List[EditableImage]\n        text_attribute_extractor,\n        max_workers: int = 4\n    ) -> Dict[str, Any]:\n        \"\"\"\n        【新逻辑】使用全图批量提取所有文本样式\n        \n        新方法：给 caption model 提供全图，以及提取后的所有文本 bbox 和内容，\n        让模型一次性分析所有文本的样式属性（颜色、粗体、对齐等）。\n        \n        优势：模型可以看到全局信息，分析更准确。\n        \n        Args:\n            editable_images: EditableImage列表，每个对应一张PPT页面\n            text_attribute_extractor: 文本属性提取器（需要有 extract_batch_with_full_image 方法）\n            max_workers: 并发处理页面数\n        \n        Returns:\n            字典，key为element_id，value为TextStyleResult\n        \"\"\"\n        from concurrent.futures import ThreadPoolExecutor, as_completed\n        \n        if not editable_images or not text_attribute_extractor:\n            return {}\n        \n        # 检查提取器是否支持批量提取\n        if not hasattr(text_attribute_extractor, 'extract_batch_with_full_image'):\n            logger.warning(\"提取器不支持 extract_batch_with_full_image 方法，回退到旧逻辑\")\n            # 回退到旧逻辑\n            all_text_items = []\n            for editable_img in editable_images:\n                text_items = ExportService._collect_text_elements_for_extraction(editable_img.elements)\n                all_text_items.extend(text_items)\n            return ExportService._batch_extract_text_styles(\n                text_items=all_text_items,\n                text_attribute_extractor=text_attribute_extractor,\n                max_workers=max_workers * 2\n            )\n        \n        logger.info(f\"【新逻辑】使用全图批量分析 {len(editable_images)} 页的文本样式...\")\n        \n        all_results = {}\n        \n        def process_single_page(editable_img, page_idx):\n            \"\"\"处理单个页面的文本样式提取\"\"\"\n            try:\n                # 收集该页面的所有文本元素\n                text_elements = ExportService._collect_text_elements_for_batch_extraction(\n                    editable_img.elements\n                )\n                \n                if not text_elements:\n                    logger.info(f\"  页面 {page_idx + 1}: 无文本元素\")\n                    return {}\n                \n                logger.info(f\"  页面 {page_idx + 1}: 分析 {len(text_elements)} 个文本元素...\")\n                \n                # 使用原始图片路径作为全图\n                full_image_path = editable_img.image_path\n                \n                # 调用批量提取方法\n                page_results = text_attribute_extractor.extract_batch_with_full_image(\n                    full_image=full_image_path,\n                    text_elements=text_elements\n                )\n                \n                logger.info(f\"  页面 {page_idx + 1}: 成功提取 {len(page_results)} 个元素的样式\")\n                return page_results\n                \n            except Exception as e:\n                logger.error(f\"页面 {page_idx + 1} 文本样式提取失败: {e}\", exc_info=True)\n                return {}\n        \n        # 并发处理所有页面\n        with ThreadPoolExecutor(max_workers=max_workers) as executor:\n            futures = {\n                executor.submit(process_single_page, img, idx): idx \n                for idx, img in enumerate(editable_images)\n            }\n            \n            for future in as_completed(futures):\n                page_idx = futures[future]\n                try:\n                    page_results = future.result()\n                    all_results.update(page_results)\n                except Exception as e:\n                    logger.error(f\"页面 {page_idx + 1} 处理失败: {e}\")\n        \n        total_elements = sum(\n            len(ExportService._collect_text_elements_for_batch_extraction(img.elements))\n            for img in editable_images\n        )\n        logger.info(f\"✓ 全图批量文本样式提取完成，成功 {len(all_results)}/{total_elements} 个\")\n        \n        return all_results\n    \n    @staticmethod\n    def _batch_extract_text_styles_hybrid(\n        editable_images: List,  # List[EditableImage]\n        text_attribute_extractor,\n        max_workers: int = 8,\n        fail_fast: bool = False\n    ) -> Tuple[Dict[str, Any], List[Tuple[str, str]]]:\n        \"\"\"\n        【混合策略】结合全局识别和单个裁剪识别的优势\n        \n        策略：\n        - 全局识别（全图分析）：获取 is_bold、is_italic、is_underline、text_alignment\n          因为这些属性需要看整体布局和上下文才能判断准确\n        - 单个裁剪识别：获取 font_color\n          因为颜色需要精确看局部像素才能识别准确\n        \n        Args:\n            editable_images: EditableImage列表，每个对应一张PPT页面\n            text_attribute_extractor: 文本属性提取器\n            max_workers: 并发数\n        \n        Returns:\n            (results, failed_extractions):\n            - results: 字典，key为element_id，value为TextStyleResult（合并后的结果）\n            - failed_extractions: 失败列表，每项为 (element_id, error_reason)\n        \"\"\"\n        from concurrent.futures import ThreadPoolExecutor, as_completed\n        from services.image_editability.text_attribute_extractors import TextStyleResult\n        \n        if not editable_images or not text_attribute_extractor:\n            return {}, []\n        \n        # 检查提取器是否支持批量提取\n        if not hasattr(text_attribute_extractor, 'extract_batch_with_full_image'):\n            logger.warning(\"提取器不支持混合策略，回退到单个裁剪识别\")\n            all_text_items = []\n            for editable_img in editable_images:\n                text_items = ExportService._collect_text_elements_for_extraction(editable_img.elements)\n                all_text_items.extend(text_items)\n            results = ExportService._batch_extract_text_styles(\n                text_items=all_text_items,\n                text_attribute_extractor=text_attribute_extractor,\n                max_workers=max_workers\n            )\n            return results, []  # 回退方法暂不收集失败信息\n        \n        logger.info(f\"【混合策略】开始分析 {len(editable_images)} 页的文本样式...\")\n        logger.info(f\"  - 全局识别: is_bold, is_italic, is_underline, text_alignment\")\n        logger.info(f\"  - 单个识别: font_color\")\n        \n        # Step 1: 收集所有文本元素\n        all_text_items = []  # 用于单个裁剪识别 (element_id, image_path, content)\n        page_text_elements = {}  # 用于全局识别 {page_idx: [text_elements]}\n        \n        for page_idx, editable_img in enumerate(editable_images):\n            # 收集用于单个裁剪识别的数据\n            text_items = ExportService._collect_text_elements_for_extraction(editable_img.elements)\n            all_text_items.extend(text_items)\n            \n            # 收集用于全局识别的数据\n            batch_elements = ExportService._collect_text_elements_for_batch_extraction(editable_img.elements)\n            if batch_elements:\n                page_text_elements[page_idx] = {\n                    'image_path': editable_img.image_path,\n                    'elements': batch_elements\n                }\n        \n        if not all_text_items:\n            return {}\n        \n        # Step 2: 并行执行两种识别\n        global_results = {}  # 全局识别结果\n        local_results = {}   # 单个裁剪识别结果\n        \n        def extract_global_for_page(page_idx, page_data):\n            \"\"\"全局识别单页\"\"\"\n            try:\n                results = text_attribute_extractor.extract_batch_with_full_image(\n                    full_image=page_data['image_path'],\n                    text_elements=page_data['elements']\n                )\n                return page_idx, results, None\n            except Exception as e:\n                logger.warning(f\"全局识别页面 {page_idx + 1} 失败: {e}\")\n                return page_idx, {}, str(e)\n        \n        # 收集失败信息\n        failed_extractions = []  # [(element_id, reason), ...]\n        \n        def extract_local_single(item):\n            \"\"\"单个裁剪识别\"\"\"\n            element_id, image_path, text_content = item\n            try:\n                style = text_attribute_extractor.extract(\n                    image=image_path,\n                    text_content=text_content\n                )\n                # Check for real success: style must exist and not be an error result\n                # (CaptionModelTextAttributeExtractor returns TextStyleResult(confidence=0.0, metadata={'error':...}) on failure)\n                is_error = style and style.confidence == 0.0 and style.metadata.get('error')\n                if style and not is_error:\n                    return element_id, style, None\n                else:\n                    error_msg = style.metadata.get('error', '样式提取返回空') if style else \"样式提取返回空\"\n                    if fail_fast:\n                        raise ExportService._build_style_extraction_error(\n                            error_msg,\n                            element_id=element_id,\n                            text_content=text_content\n                        )\n                    return element_id, None, error_msg\n            except ExportError:\n                raise  # 重新抛出 ExportError\n            except Exception as e:\n                logger.warning(f\"单个识别失败 [{element_id}]: {e}\")\n                if fail_fast:\n                    raise ExportService._build_style_extraction_error(\n                        str(e),\n                        element_id=element_id,\n                        text_content=text_content\n                    )\n                return element_id, None, str(e)\n        \n        # 并发执行全局识别和单个裁剪识别\n        logger.info(f\"  并发执行: 全局识别 {len(page_text_elements)} 页 + 单个识别 {len(all_text_items)} 个元素...\")\n        \n        with ThreadPoolExecutor(max_workers=max_workers) as executor:\n            # 提交全局识别任务\n            global_futures = {\n                executor.submit(extract_global_for_page, idx, data): ('global', idx)\n                for idx, data in page_text_elements.items()\n            }\n            \n            # 提交单个裁剪识别任务\n            local_futures = {\n                executor.submit(extract_local_single, item): ('local', item[0])\n                for item in all_text_items\n            }\n            \n            # 收集全局识别结果\n            for future in as_completed(global_futures):\n                task_type, page_idx = global_futures[future]\n                try:\n                    _, page_results, page_error = future.result()\n                    global_results.update(page_results)\n                    expected_element_ids = {\n                        element['element_id'] for element in page_text_elements[page_idx]['elements']\n                    }\n                    missing_element_ids = expected_element_ids - set(page_results.keys())\n                    if page_error:\n                        if fail_fast:\n                            raise ExportService._build_style_extraction_error(page_error, page_idx=page_idx)\n                        failed_extractions.extend(\n                            (element_id, f\"全局识别失败: {page_error}\")\n                            for element_id in expected_element_ids\n                        )\n                    elif missing_element_ids:\n                        reason = \"全局识别未返回完整结果\"\n                        if fail_fast:\n                            raise ExportService._build_style_extraction_error(reason, page_idx=page_idx)\n                        failed_extractions.extend((element_id, reason) for element_id in missing_element_ids)\n                except Exception as e:\n                    logger.error(f\"全局识别任务失败: {e}\")\n                    if fail_fast:\n                        if isinstance(e, ExportError):\n                            raise\n                        raise ExportService._build_style_extraction_error(str(e), page_idx=page_idx) from e\n                    expected_element_ids = [\n                        element['element_id'] for element in page_text_elements[page_idx]['elements']\n                    ]\n                    failed_extractions.extend(\n                        (element_id, f\"全局识别失败: {e}\")\n                        for element_id in expected_element_ids\n                    )\n            \n            # 收集单个裁剪识别结果\n            for future in as_completed(local_futures):\n                task_type, element_id = local_futures[future]\n                try:\n                    elem_id, style, error = future.result()\n                    if style is not None:\n                        local_results[elem_id] = style\n                    if error:\n                        failed_extractions.append((elem_id, error))\n                except Exception as e:\n                    logger.error(f\"单个识别任务失败: {e}\")\n                    if fail_fast:\n                        raise\n                    failed_extractions.append((element_id, str(e)))\n        \n        # Step 3: 合并结果\n        # 优先使用全局识别的布局属性，使用单个识别的颜色属性\n        merged_results = {}\n        \n        all_element_ids = set(global_results.keys()) | set(local_results.keys())\n        \n        for element_id in all_element_ids:\n            global_style = global_results.get(element_id)\n            local_style = local_results.get(element_id)\n            \n            if global_style and local_style:\n                # 混合：颜色用单个识别（包括 colored_segments），布局用全局识别\n                merged_results[element_id] = TextStyleResult(\n                    font_color_rgb=local_style.font_color_rgb,  # 单个识别的颜色\n                    colored_segments=local_style.colored_segments,  # 单个识别的多颜色片段\n                    is_bold=global_style.is_bold,              # 全局识别的粗体\n                    is_italic=global_style.is_italic,          # 全局识别的斜体\n                    is_underline=global_style.is_underline,    # 全局识别的下划线\n                    text_alignment=global_style.text_alignment, # 全局识别的对齐\n                    confidence=0.9,\n                    metadata={\n                        'source': 'hybrid',\n                        'color_source': 'local',\n                        'layout_source': 'global'\n                    }\n                )\n            elif local_style:\n                # 只有单个识别结果\n                merged_results[element_id] = local_style\n            elif global_style:\n                # 只有全局识别结果\n                merged_results[element_id] = global_style\n        \n        logger.info(f\"✓ 混合策略完成: 全局识别 {len(global_results)} 个, 单个识别 {len(local_results)} 个, 合并 {len(merged_results)} 个, 失败 {len(failed_extractions)} 个\")\n        \n        return merged_results, failed_extractions\n    \n    @staticmethod\n    def create_editable_pptx_with_recursive_analysis(\n        image_paths: List[str] = None,\n        output_file: str = None,\n        slide_width_pixels: int = 1920,\n        slide_height_pixels: int = 1080,\n        max_depth: int = 2,\n        max_workers: int = 8,\n        editable_images: List = None,  # 可选：直接传入已分析的EditableImage列表\n        text_attribute_extractor = None,  # 可选：文字属性提取器，用于提取颜色、粗体、斜体等样式\n        progress_callback = None,  # 可选：进度回调函数 (step, message, percent) -> None\n        export_extractor_method: str = 'hybrid',  # 组件提取方法: mineru, hybrid\n        export_inpaint_method: str = 'hybrid',  # 背景修复方法: generative, baidu, hybrid\n        fail_fast: bool = True  # 是否在遇到错误时立即停止（False则收集警告继续）\n    ) -> Tuple[Optional[bytes], ExportWarnings]:\n        \"\"\"\n        使用递归图片可编辑化服务创建可编辑PPTX\n        \n        这是新的架构方法，使用ImageEditabilityService进行递归版面分析。\n        \n        两种使用方式：\n        1. 传入 image_paths：自动分析图片并生成PPTX\n        2. 传入 editable_images：直接使用已分析的结果（避免重复分析）\n        \n        配置（如 MinerU token）自动从 Flask app.config 获取。\n        \n        Args:\n            image_paths: 图片路径列表（可选，与editable_images二选一）\n            output_file: 输出文件路径（可选）\n            slide_width_pixels: 目标幻灯片宽度\n            slide_height_pixels: 目标幻灯片高度\n            max_depth: 最大递归深度\n            max_workers: 并发处理数\n            editable_images: 已分析的EditableImage列表（可选，与image_paths二选一）\n            text_attribute_extractor: 文字属性提取器（可选），用于提取文字颜色、粗体、斜体等样式\n                可通过 TextAttributeExtractorFactory.create_caption_model_extractor() 创建\n            export_extractor_method: 组件提取方法 ('mineru' 或 'hybrid'，默认 'hybrid')\n            export_inpaint_method: 背景修复方法 ('generative', 'baidu', 'hybrid'，默认 'hybrid')\n            fail_fast: 是否在遇到错误时立即停止（默认 True）。设为 False 则收集警告继续导出。\n\n        Returns:\n            (pptx_bytes, warnings): 元组，包含 PPTX 字节流和警告信息\n            - pptx_bytes: PPTX 文件字节流（如果 output_file 为 None），否则为 None\n            - warnings: ExportWarnings 对象，包含所有警告信息\n        \"\"\"\n        from services.image_editability import ServiceConfig, ImageEditabilityService\n        from utils.pptx_builder import PPTXBuilder\n        \n        # 初始化警告收集器\n        warnings = ExportWarnings()\n        \n        # 辅助函数：报告进度\n        def report_progress(step: str, message: str, percent: int):\n            logger.info(f\"[进度 {percent}%] {step}: {message}\")\n            if progress_callback:\n                try:\n                    progress_callback(step, message, percent)\n                except Exception as e:\n                    logger.warning(f\"进度回调失败: {e}\")\n        \n        # 如果已提供分析结果，直接使用；否则需要分析\n        if editable_images is not None:\n            logger.info(f\"使用已提供的 {len(editable_images)} 个分析结果创建PPTX\")\n            report_progress(\"准备\", f\"使用已有分析结果（{len(editable_images)} 页）\", 10)\n        else:\n            if not image_paths:\n                raise ValueError(\"必须提供 image_paths 或 editable_images 之一\")\n            \n            total_pages = len(image_paths)\n            logger.info(f\"开始使用递归分析方法创建可编辑PPTX，共 {total_pages} 页\")\n            report_progress(\"开始\", f\"准备分析 {total_pages} 页幻灯片...\", 0)\n            \n            # 1. 创建ImageEditabilityService（配置自动从 Flask config 获取，使用项目导出设置）\n            logger.info(f\"使用导出设置: extractor={export_extractor_method}, inpaint={export_inpaint_method}\")\n            config = ServiceConfig.from_defaults(\n                max_depth=max_depth,\n                extractor_method=export_extractor_method,\n                inpaint_method=export_inpaint_method\n            )\n            editability_service = ImageEditabilityService(config)\n            \n            # 2. 并发处理所有页面，生成EditableImage结构\n            report_progress(\"版面分析\", f\"开始分析 {total_pages} 张图片（并发数: {max_workers}）...\", 5)\n            from concurrent.futures import ThreadPoolExecutor, as_completed\n            \n            editable_images = []\n            completed_count = 0\n            with ThreadPoolExecutor(max_workers=max_workers) as executor:\n                futures = {\n                    executor.submit(editability_service.make_image_editable, img_path): idx\n                    for idx, img_path in enumerate(image_paths)\n                }\n                \n                results = [None] * len(image_paths)\n                for future in as_completed(futures):\n                    idx = futures[future]\n                    try:\n                        results[idx] = future.result()\n                        completed_count += 1\n                        # 版面分析占 5% - 40% 的进度\n                        percent = 5 + int(35 * completed_count / total_pages)\n                        report_progress(\"版面分析\", f\"已完成第 {completed_count}/{total_pages} 页的版面分析\", percent)\n                    except Exception as e:\n                        logger.error(f\"处理图片 {image_paths[idx]} 失败: {e}\")\n                        raise\n                \n                editable_images = results\n        \n        # 2.5. 使用混合策略提取所有文本元素的样式（如果提供了提取器）\n        # 混合策略：全局识别（粗体/斜体/下划线/对齐）+ 单个裁剪识别（颜色）\n        text_styles_cache = {}\n        if text_attribute_extractor:\n            report_progress(\"样式提取\", \"开始提取文本样式（混合策略）...\", 45)\n            \n            # 统计文本元素数量\n            total_text_count = sum(\n                len(ExportService._collect_text_elements_for_extraction(img.elements))\n                for img in editable_images\n            )\n            \n            if total_text_count > 0:\n                report_progress(\"样式提取\", f\"混合策略分析 {total_text_count} 个文本元素...\", 50)\n                text_styles_cache, failed_extractions = ExportService._batch_extract_text_styles_hybrid(\n                    editable_images=editable_images,\n                    text_attribute_extractor=text_attribute_extractor,\n                    max_workers=max_workers * 2,\n                    fail_fast=fail_fast\n                )\n                \n                # 记录样式提取失败的元素（详细）\n                for element_id, reason in failed_extractions:\n                    warnings.add_style_extraction_failed(element_id, reason)\n                \n                # 记录汇总信息\n                extracted_count = len(text_styles_cache)\n                failed_count = len(failed_extractions)\n                if failed_count > 0:\n                    logger.warning(f\"样式提取: {failed_count}/{total_text_count} 个元素失败\")\n                \n                report_progress(\"样式提取\", f\"✓ 完成 {extracted_count}/{total_text_count} 个文本样式提取（{failed_count} 个失败）\", 70)\n        \n        report_progress(\"构建PPTX\", \"开始构建可编辑PPTX文件...\", 75)\n        \n        # 4. 创建PPTX构建器\n        builder = PPTXBuilder()\n        builder.create_presentation()\n        builder.setup_presentation_size(slide_width_pixels, slide_height_pixels)\n        \n        # 5. 为每个页面构建幻灯片\n        total_pages = len(editable_images)\n        for page_idx, editable_img in enumerate(editable_images):\n            # 构建PPTX占 75% - 95% 的进度\n            percent = 75 + int(20 * page_idx / total_pages)\n            report_progress(\"构建PPTX\", f\"构建第 {page_idx + 1}/{total_pages} 页...\", percent)\n            logger.info(f\"  构建第 {page_idx + 1}/{total_pages} 页...\")\n            \n            # 创建空白幻灯片\n            slide = builder.add_blank_slide()\n            \n            # 添加背景图（参考原实现，使用slide.shapes.add_picture）\n            if editable_img.clean_background and os.path.exists(editable_img.clean_background):\n                logger.info(f\"    添加clean background: {editable_img.clean_background}\")\n                try:\n                    slide.shapes.add_picture(\n                        editable_img.clean_background,\n                        left=0,\n                        top=0,\n                        width=builder.prs.slide_width,\n                        height=builder.prs.slide_height\n                    )\n                except Exception as e:\n                    logger.error(f\"Failed to add background: {e}\")\n            else:\n                # 回退到原图\n                logger.info(f\"    使用原图作为背景: {editable_img.image_path}\")\n                try:\n                    slide.shapes.add_picture(\n                        editable_img.image_path,\n                        left=0,\n                        top=0,\n                        width=builder.prs.slide_width,\n                        height=builder.prs.slide_height\n                    )\n                except Exception as e:\n                    logger.error(f\"Failed to add background: {e}\")\n            \n            # 添加所有元素（递归地）\n            # 计算缩放比例：将原始图片坐标映射到统一的幻灯片坐标\n            # 背景图已经缩放到幻灯片尺寸，所以元素坐标也需要相应缩放\n            scale_x = slide_width_pixels / editable_img.width\n            scale_y = slide_height_pixels / editable_img.height\n            logger.info(f\"    元素数量: {len(editable_img.elements)}, 图片尺寸: {editable_img.width}x{editable_img.height}, \"\n                       f\"幻灯片尺寸: {slide_width_pixels}x{slide_height_pixels}, 缩放比例: {scale_x:.3f}x{scale_y:.3f}\")\n            \n            ExportService._add_editable_elements_to_slide(\n                builder=builder,\n                slide=slide,\n                elements=editable_img.elements,\n                scale_x=scale_x,\n                scale_y=scale_y,\n                depth=0,\n                text_styles_cache=text_styles_cache,  # 使用预提取的样式缓存\n                warnings=warnings,  # 收集警告\n                fail_fast=fail_fast  # 传递 fail_fast 参数\n            )\n            \n            logger.info(f\"    ✓ 第 {page_idx + 1} 页完成，添加了 {len(editable_img.elements)} 个元素\")\n        \n        # 5. 保存或返回字节流\n        report_progress(\"保存文件\", \"正在保存PPTX文件...\", 95)\n        if output_file:\n            builder.save(output_file)\n            report_progress(\"完成\", f\"✓ 可编辑PPTX已保存\", 100)\n            logger.info(f\"✓ 可编辑PPTX已保存: {output_file}\")\n            \n            # 输出警告摘要\n            if warnings.has_warnings():\n                logger.warning(f\"导出完成，但有 {len(warnings.to_summary())} 条警告\")\n            \n            return None, warnings\n        else:\n            pptx_bytes = builder.to_bytes()\n            report_progress(\"完成\", f\"✓ 可编辑PPTX已生成\", 100)\n            logger.info(f\"✓ 可编辑PPTX已生成（{len(pptx_bytes)} 字节）\")\n            \n            # 输出警告摘要\n            if warnings.has_warnings():\n                logger.warning(f\"导出完成，但有 {len(warnings.to_summary())} 条警告\")\n            \n            return pptx_bytes, warnings\n    \n    @staticmethod\n    def _add_editable_elements_to_slide(\n        builder,\n        slide,\n        elements: List,  # List[EditableElement]\n        scale_x: float = 1.0,\n        scale_y: float = 1.0,\n        depth: int = 0,\n        text_styles_cache: Dict[str, Any] = None,  # 预提取的文本样式缓存，key为element_id\n        warnings: 'ExportWarnings' = None,  # 警告收集器\n        fail_fast: bool = False  # 是否在遇到错误时立即停止\n    ):\n        \"\"\"\n        递归地将EditableElement添加到幻灯片\n        \n        Args:\n            builder: PPTXBuilder实例\n            slide: 幻灯片对象\n            elements: EditableElement列表\n            scale_x: X轴缩放因子\n            scale_y: Y轴缩放因子\n            depth: 当前递归深度\n            text_styles_cache: 预提取的文本样式缓存（可选），由 _batch_extract_text_styles 生成\n        \n        Note:\n            elem.image_path 现在是绝对路径，无需额外的目录参数\n        \"\"\"\n        if text_styles_cache is None:\n            text_styles_cache = {}\n        \n        for elem in elements:\n            elem_type = elem.element_type\n            \n            # 根据深度决定使用局部坐标还是全局坐标\n            # depth=0: 顶层元素，使用局部坐标（bbox）\n            # depth>0: 子元素，需要使用全局坐标（bbox_global）\n            if depth == 0:\n                bbox = elem.bbox  # 顶层元素使用局部坐标\n            else:\n                bbox = elem.bbox_global if hasattr(elem, 'bbox_global') and elem.bbox_global else elem.bbox\n            \n            # 转换BBox对象为列表并应用缩放\n            bbox_list = [\n                int(bbox.x0 * scale_x),\n                int(bbox.y0 * scale_y),\n                int(bbox.x1 * scale_x),\n                int(bbox.y1 * scale_y)\n            ]\n            \n            logger.info(f\"{'  ' * depth}  添加元素: type={elem_type}, bbox={bbox_list}, content={elem.content[:30] if elem.content else None}, image_path={elem.image_path}, 使用{'全局' if depth > 0 else '局部'}坐标\")\n            \n            # 根据类型添加元素（参考原实现的_add_mineru_text_to_slide和_add_mineru_image_to_slide）\n            if elem_type in ['text', 'title', 'list', 'paragraph', 'header', 'footer', 'heading', 'table_caption', 'image_caption']:\n                # 添加文本（参考_add_mineru_text_to_slide）\n                if elem.content:\n                    text = elem.content.strip()\n                    if text:\n                        try:\n                            # 确定文本级别\n                            level = 'title' if elem_type in ['title', 'heading'] else 'default'\n                            \n                            # 从缓存获取预提取的文字样式\n                            text_style = text_styles_cache.get(elem.element_id)\n                            if text_style:\n                                logger.debug(f\"{'  ' * depth}  使用缓存的文字样式: color={text_style.font_color_rgb}, bold={text_style.is_bold}\")\n                            \n                            builder.add_text_element(\n                                slide=slide,\n                                text=text,\n                                bbox=bbox_list,\n                                text_level=level,\n                                text_style=text_style\n                            )\n                        except Exception as e:\n                            logger.warning(f\"添加文本元素失败: {e}\")\n                            if fail_fast:\n                                raise ExportError(\n                                    message=f\"添加文本元素失败: {str(e)}\",\n                                    error_type='text_render',\n                                    details={'text': text[:50], 'bbox': bbox_list}\n                                )\n                            if warnings:\n                                warnings.add_text_render_failed(text, str(e))\n            \n            elif elem_type == 'table_cell':\n                # 添加表格单元格（带边框的文本框）\n                if elem.content:\n                    text = elem.content.strip()\n                    if text:\n                        try:\n                            # 从缓存获取预提取的文字样式\n                            text_style = text_styles_cache.get(elem.element_id)\n                            \n                            # 表格单元格已经在上面统一处理了bbox_global和缩放\n                            # 直接使用bbox_list即可\n                            builder.add_text_element(\n                                slide=slide,\n                                text=text,\n                                bbox=bbox_list,\n                                text_level=None,\n                                align='center',\n                                text_style=text_style\n                            )\n\n                        except Exception as e:\n                            logger.warning(f\"添加单元格失败: {e}\")\n                            if fail_fast:\n                                raise ExportError(\n                                    message=f\"添加表格单元格失败: {str(e)}\",\n                                    error_type='text_render',\n                                    details={'text': text[:50], 'bbox': bbox_list}\n                                )\n                            if warnings:\n                                warnings.add_text_render_failed(text, str(e))\n            \n            elif elem_type == 'table':\n                # 如果表格有子元素（单元格），使用inpainted背景 + 单元格\n                if elem.children and elem.inpainted_background_path:\n                    logger.info(f\"{'  ' * depth}    表格有 {len(elem.children)} 个单元格，使用可编辑格式\")\n                    \n                    # 先添加inpainted背景（干净的表格框架）\n                    if os.path.exists(elem.inpainted_background_path):\n                        try:\n                            builder.add_image_element(\n                                slide=slide,\n                                image_path=elem.inpainted_background_path,\n                                bbox=bbox_list\n                            )\n                        except Exception as e:\n                            logger.error(f\"Failed to add table background: {e}\")\n                    \n                    # 递归添加单元格\n                    ExportService._add_editable_elements_to_slide(\n                        builder=builder,\n                        slide=slide,\n                        elements=elem.children,\n                        scale_x=scale_x,\n                        scale_y=scale_y,\n                        depth=depth + 1,\n                        text_styles_cache=text_styles_cache,\n                        warnings=warnings,\n                        fail_fast=fail_fast\n                    )\n                else:\n                    # 没有子元素，添加整体表格图片\n                    # elem.image_path 现在是绝对路径\n                    if elem.image_path and os.path.exists(elem.image_path):\n                        try:\n                            builder.add_image_element(\n                                slide=slide,\n                                image_path=elem.image_path,\n                                bbox=bbox_list\n                            )\n                        except Exception as e:\n                            logger.error(f\"Failed to add table image: {e}\")\n                    else:\n                        logger.warning(f\"Table image not found: {elem.image_path}\")\n                        builder.add_image_placeholder(slide, bbox_list)\n            \n            elif elem_type in ['image', 'figure', 'chart']:\n                # 检查是否应该使用递归渲染\n                should_use_recursive_render = False\n                \n                if elem.children and elem.inpainted_background_path:\n                    # 检查是否有任意子元素占据父元素绝大部分面积\n                    parent_area = (bbox.x1 - bbox.x0) * (bbox.y1 - bbox.y0)\n                    max_child_coverage_ratio = 0.85  # 阈值\n                    has_dominant_child = False\n                    \n                    for child in elem.children:\n                        if hasattr(child, 'bbox_global') and child.bbox_global:\n                            child_bbox = child.bbox_global\n                        else:\n                            child_bbox = child.bbox\n                        \n                        child_area = child_bbox.area\n                        coverage_ratio = child_area / parent_area if parent_area > 0 else 0\n                        \n                        if coverage_ratio > max_child_coverage_ratio:\n                            logger.info(f\"{'  ' * depth}    子元素 {child.element_id} 占父元素面积 {coverage_ratio*100:.1f}% (>{max_child_coverage_ratio*100:.0f}%)，跳过递归渲染，直接使用原图\")\n                            has_dominant_child = True\n                            break\n                    \n                    should_use_recursive_render = not has_dominant_child\n                \n                # 如果有子元素且应该递归渲染\n                if should_use_recursive_render:\n                    logger.debug(f\"{'  ' * depth}    元素有 {len(elem.children)} 个子元素，递归添加\")\n                    \n                    # 先添加inpainted背景\n                    if os.path.exists(elem.inpainted_background_path):\n                        try:\n                            builder.add_image_element(slide, elem.inpainted_background_path, bbox_list)\n                        except Exception as e:\n                            logger.error(f\"Failed to add inpainted background: {e}\")\n                    \n                    # 递归添加子元素\n                    ExportService._add_editable_elements_to_slide(\n                        builder=builder,\n                        slide=slide,\n                        elements=elem.children,\n                        scale_x=scale_x,\n                        scale_y=scale_y,\n                        depth=depth + 1,\n                        text_styles_cache=text_styles_cache,\n                        warnings=warnings,\n                        fail_fast=fail_fast\n                    )\n                else:\n                    # 没有子元素或子元素占比过大，直接添加原图\n                    # elem.image_path 现在是绝对路径\n                    if elem.image_path and os.path.exists(elem.image_path):\n                        try:\n                            builder.add_image_element(\n                                slide=slide,\n                                image_path=elem.image_path,\n                                bbox=bbox_list\n                            )\n                        except Exception as e:\n                            logger.error(f\"Failed to add image: {e}\")\n                    else:\n                        logger.warning(f\"Image file not found: {elem.image_path}\")\n                        builder.add_image_placeholder(slide, bbox_list)\n            \n            else:\n                # 其他类型\n                logger.debug(f\"{'  ' * depth}  跳过未知类型: {elem_type}\")\n    \n"
  },
  {
    "path": "backend/services/file_parser_service.py",
    "content": "\"\"\"\nFile Parser Service - handles file parsing using MinerU service and image captioning\n\"\"\"\nimport os\nimport re\nimport time\nimport logging\nimport zipfile\nimport io\nimport base64\nimport requests\nimport tempfile\nfrom typing import Optional, List\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom PIL import Image\nfrom markitdown import MarkItDown\nfrom services.ai_providers.lazyllm_env import ensure_lazyllm_namespace_key, get_lazyllm_api_key\nfrom services.ai_providers.text import strip_think_tags\n\nlogger = logging.getLogger(__name__)\n\n\ndef _get_ai_provider_format(provider_format: str = None) -> str:\n    \"\"\"Get the configured AI provider format\n    \n    Priority:\n        1. Provided provider_format parameter\n        2. Flask app.config['AI_PROVIDER_FORMAT'] (from database settings)\n        3. Environment variable AI_PROVIDER_FORMAT\n        4. Default: 'gemini'\n    \n    Args:\n        provider_format: Optional provider format string. If not provided, reads from Flask config or environment variable.\n    \"\"\"\n    if provider_format:\n        return provider_format.lower()\n    \n    # Try to get from Flask app config first (database settings)\n    try:\n        from flask import current_app\n        if current_app and hasattr(current_app, 'config'):\n            config_value = current_app.config.get('AI_PROVIDER_FORMAT')\n            if config_value:\n                return str(config_value).lower()\n    except RuntimeError:\n        # Not in Flask application context\n        pass\n    \n    # Fallback to environment variable\n    return os.getenv('AI_PROVIDER_FORMAT', 'gemini').lower()\n\n\nclass FileParserService:\n    \"\"\"Service for parsing files using MinerU and enhancing with image captions\"\"\"\n    \n    def __init__(self, mineru_token: str, mineru_api_base: str = \"https://mineru.net\",\n                 google_api_key: str = \"\", google_api_base: str = \"\",\n                 openai_api_key: str = \"\", openai_api_base: str = \"\",\n                 image_caption_model: str = \"gemini-3-flash-preview\",\n                 lazyllm_image_caption_source: str = \"\", \n                 provider_format: str = None,\n                 mineru_model_version: str = \"vlm\",\n                 ):\n        \"\"\"\n        Initialize the file parser service\n        \n        Args:\n            mineru_token: MinerU API token\n            mineru_api_base: MinerU API base URL\n            google_api_key: Google Gemini API key for image captioning (used when AI_PROVIDER_FORMAT=gemini)\n            google_api_base: Google Gemini API base URL\n            openai_api_key: OpenAI API key for image captioning (used when AI_PROVIDER_FORMAT=openai)\n            openai_api_base: OpenAI API base URL\n            image_caption_model: Model to use for image captioning\n            lazyllm_image_caption_source: image caption model provider for lazyllm\n            provider_format: AI provider format ('gemini' or 'openai'). If not provided, reads from environment variable.\n            mineru_model_version: MinerU model version ('vlm' or 'pipeline'). Default is 'vlm'.\n        \"\"\"\n        self.mineru_token = mineru_token\n        self.mineru_api_base = mineru_api_base\n        self.mineru_model_version = mineru_model_version\n        self.get_upload_url_api = f\"{mineru_api_base}/api/v4/file-urls/batch\"\n        self.get_result_api_template = f\"{mineru_api_base}/api/v4/extract-results/batch/{{}}\"\n        \n        # Store config for lazy initialization\n        self._google_api_key = google_api_key\n        self._google_api_base = google_api_base\n        self._openai_api_key = openai_api_key\n        self._openai_api_base = openai_api_base\n        self._image_caption_model = image_caption_model\n        self._lazyllm_image_caption_source = lazyllm_image_caption_source\n        \n        # Clients will be initialized lazily based on AI_PROVIDER_FORMAT\n        self._gemini_client = None\n        self._openai_client = None\n        self._lazyllm_client = None\n        self._provider_format = _get_ai_provider_format(provider_format)\n    \n    def _get_gemini_client(self):\n        \"\"\"Lazily initialize Gemini client\"\"\"\n        if self._gemini_client is None and self._google_api_key:\n            from google import genai\n            from google.genai import types\n            self._gemini_client = genai.Client(\n                http_options=types.HttpOptions(base_url=self._google_api_base) if self._google_api_base else None,\n                api_key=self._google_api_key\n            )\n        return self._gemini_client\n    \n    def _get_openai_client(self):\n        \"\"\"Lazily initialize OpenAI client\"\"\"\n        if self._openai_client is None and self._openai_api_key:\n            from openai import OpenAI\n            self._openai_client = OpenAI(\n                api_key=self._openai_api_key,\n                base_url=self._openai_api_base\n            )\n        return self._openai_client\n    \n    def _get_lazyllm_client(self):\n        \"\"\"Lazily initialize LazyLLM client\"\"\"\n        if self._lazyllm_client is None:\n            import lazyllm\n            source = self._lazyllm_image_caption_source or \"qwen\"\n            model = self._image_caption_model or \"qwen-vl-plus\"\n            ensure_lazyllm_namespace_key(source, namespace='BANANA')\n\n            self._lazyllm_client = lazyllm.namespace('BANANA').OnlineModule(\n                source=source,\n                model=model,\n                type=\"vlm\",\n            )\n        return self._lazyllm_client\n    \n    def _can_generate_captions(self) -> bool:\n        \"\"\"Check if image caption generation is available\"\"\"\n        if self._provider_format == 'openai':\n            return bool(self._openai_api_key)\n        elif self._provider_format == 'lazyllm':\n            source = self._lazyllm_image_caption_source or \"qwen\"\n            return bool(get_lazyllm_api_key(source, namespace='BANANA'))\n        else:\n            return bool(self._google_api_key)\n    \n    def parse_file(self, file_path: str, filename: str) -> tuple[Optional[str], Optional[str], Optional[str], Optional[str], int]:\n        \"\"\"\n        Parse a file using MinerU service and enhance with image captions\n        \n        Args:\n            file_path: Path to the file to parse\n            filename: Original filename\n            \n        Returns:\n            Tuple of (batch_id, markdown_content, extract_id, error_message, failed_image_count)\n            - batch_id: MinerU batch ID (for tracking, None for text files)\n            - markdown_content: Parsed markdown with enhanced image descriptions\n            - extract_id: Unique ID for the extracted files directory (None for text files)\n            - error_message: Error message if parsing failed\n            - failed_image_count: Number of images that failed to generate captions\n        \"\"\"\n        try:\n            # Check if it's a plain text file that doesn't need MinerU parsing\n            file_ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''\n            \n            if file_ext in ['txt', 'md', 'markdown']:\n                logger.info(f\"File {filename} is a plain text file, reading directly...\")\n                return self._parse_text_file(file_path, filename)\n            \n            # Check if it's a spreadsheet file (xlsx, csv) - use markitdown\n            if file_ext in ['xlsx', 'xls', 'csv']:\n                logger.info(f\"File {filename} is a spreadsheet file, using markitdown...\")\n                return self._parse_spreadsheet_file(file_path, filename)\n            \n            # For other file types, use MinerU service\n            logger.info(f\"File {filename} requires MinerU parsing...\")\n            \n            # Step 1: Get upload URL\n            logger.info(f\"Step 1/4: Requesting upload URL for {filename}...\")\n            batch_id, upload_url, error = self._get_upload_url(filename)\n            if error:\n                return None, None, None, error, 0\n            \n            logger.info(f\"Got upload URL. Batch ID: {batch_id}\")\n            \n            # Step 2: Upload file\n            logger.info(f\"Step 2/4: Uploading file {filename}...\")\n            error = self._upload_file(file_path, upload_url)\n            if error:\n                return batch_id, None, None, error, 0\n            \n            logger.info(\"File uploaded successfully.\")\n            \n            # Step 3: Poll for parsing result\n            logger.info(\"Step 3/4: Waiting for parsing to complete...\")\n            markdown_content, extract_id, error = self._poll_result(batch_id)\n            if error:\n                return batch_id, None, None, error, 0\n            \n            logger.info(\"File parsed successfully.\")\n            \n            # Step 4: Enhance markdown with image captions\n            if markdown_content and self._can_generate_captions():\n                logger.info(\"Step 4/4: Enhancing markdown with image captions...\")\n                enhanced_content, failed_count = self._enhance_markdown_with_captions(markdown_content)\n                if failed_count > 0:\n                    logger.warning(f\"Markdown enhanced with image captions, but {failed_count} images failed to generate captions.\")\n                else:\n                    logger.info(\"Markdown enhanced with image captions (all images succeeded).\")\n                return batch_id, enhanced_content, extract_id, None, failed_count\n            else:\n                logger.info(\"Skipping image caption enhancement (caption model unavailable).\")\n                return batch_id, markdown_content, extract_id, None, 0\n            \n        except Exception as e:\n            error_msg = f\"Unexpected error during file parsing: {str(e)}\"\n            logger.error(error_msg, exc_info=True)\n            return None, None, None, error_msg, 0\n    \n    def _parse_text_file(self, file_path: str, filename: str) -> tuple[Optional[str], Optional[str], Optional[str], Optional[str], int]:\n        \"\"\"\n        Parse plain text file directly without MinerU\n        \n        Args:\n            file_path: Path to the file\n            filename: Original filename\n            \n        Returns:\n            Tuple of (batch_id, markdown_content, extract_id, error_message, failed_image_count)\n        \"\"\"\n        try:\n            # Read file content\n            with open(file_path, 'r', encoding='utf-8') as f:\n                content = f.read()\n            \n            logger.info(f\"Text file read successfully: {len(content)} characters\")\n            \n            # Enhance markdown with image captions if it contains images\n            if content and self._can_generate_captions():\n                # Check if content has markdown images\n                if '![' in content and '](' in content:\n                    logger.info(\"Text file contains images, enhancing with captions...\")\n                    enhanced_content, failed_count = self._enhance_markdown_with_captions(content)\n                    if failed_count > 0:\n                        logger.warning(f\"Text file enhanced with image captions, but {failed_count} images failed to generate captions.\")\n                    else:\n                        logger.info(\"Text file enhanced with image captions (all images succeeded).\")\n                    return None, enhanced_content, None, None, failed_count\n            \n            return None, content, None, None, 0\n            \n        except UnicodeDecodeError:\n            # Try with different encoding\n            try:\n                with open(file_path, 'r', encoding='gbk') as f:\n                    content = f.read()\n                logger.info(f\"Text file read successfully with GBK encoding: {len(content)} characters\")\n                \n                if content and self._can_generate_captions() and '![' in content and '](' in content:\n                    logger.info(\"Text file contains images, enhancing with captions...\")\n                    enhanced_content, failed_count = self._enhance_markdown_with_captions(content)\n                    if failed_count > 0:\n                        logger.warning(f\"Text file enhanced with image captions, but {failed_count} images failed to generate captions.\")\n                    else:\n                        logger.info(\"Text file enhanced with image captions (all images succeeded).\")\n                    return None, enhanced_content, None, None, failed_count\n                \n                return None, content, None, None, 0\n            except Exception as e:\n                error_msg = f\"Failed to read text file with multiple encodings: {str(e)}\"\n                logger.error(error_msg)\n                return None, None, None, error_msg, 0\n        except Exception as e:\n            error_msg = f\"Failed to read text file: {str(e)}\"\n            logger.error(error_msg)\n            return None, None, None, error_msg, 0\n    \n    def _parse_spreadsheet_file(self, file_path: str, filename: str) -> tuple[Optional[str], Optional[str], Optional[str], Optional[str], int]:\n        \"\"\"\n        Parse spreadsheet files (xlsx, xls, csv) using markitdown\n        \n        Args:\n            file_path: Path to the file\n            filename: Original filename\n            \n        Returns:\n            Tuple of (batch_id, markdown_content, extract_id, error_message, failed_image_count)\n        \"\"\"\n        try:\n            # Use markitdown to convert spreadsheet to markdown\n            md = MarkItDown()\n            result = md.convert(file_path)\n            markdown_content = result.text_content\n            \n            logger.info(f\"Spreadsheet file converted successfully: {len(markdown_content)} characters\")\n            \n            # Spreadsheet files typically don't have images, so no need for caption enhancement\n            return None, markdown_content, None, None, 0\n            \n        except Exception as e:\n            error_msg = f\"Failed to parse spreadsheet file: {str(e)}\"\n            logger.error(error_msg, exc_info=True)\n            return None, None, None, error_msg, 0\n    \n    def _get_upload_url(self, filename: str) -> tuple[Optional[str], Optional[str], Optional[str]]:\n        \"\"\"Get upload URL from MinerU\"\"\"\n        headers = {\n            \"Content-Type\": \"application/json\",\n            \"Authorization\": f\"Bearer {self.mineru_token}\"\n        }\n        \n        upload_data = {\n            \"files\": [{\"name\": filename}],\n            \"model_version\": self.mineru_model_version  # \"vlm\" or \"pipeline\"\n        }\n        \n        try:\n            response = requests.post(\n                self.get_upload_url_api,\n                headers=headers,\n                json=upload_data,\n                timeout=30\n            )\n            response.raise_for_status()\n            result = response.json()\n            \n            if result.get(\"code\") != 0:\n                error_msg = f\"Failed to get upload URL: {result.get('msg')}\"\n                logger.error(error_msg)\n                return None, None, error_msg\n            \n            batch_id = result[\"data\"][\"batch_id\"]\n            upload_url = result[\"data\"][\"file_urls\"][0]\n            return batch_id, upload_url, None\n            \n        except requests.exceptions.RequestException as e:\n            error_msg = f\"Network error while requesting upload URL: {str(e)}\"\n            logger.error(error_msg)\n            return None, None, error_msg\n    \n    def _upload_file(self, file_path: str, upload_url: str) -> Optional[str]:\n        \"\"\"Upload file to MinerU\"\"\"\n        try:\n            with open(file_path, 'rb') as f:\n                response = requests.put(\n                    upload_url,\n                    data=f,\n                    headers={\"Authorization\": None},  # Remove auth for upload\n                    timeout=300  # 5 minutes timeout for large files\n                )\n                response.raise_for_status()\n            return None\n            \n        except requests.exceptions.RequestException as e:\n            error_msg = f\"File upload failed: {str(e)}\"\n            logger.error(error_msg)\n            return error_msg\n        except IOError as e:\n            error_msg = f\"Failed to read file: {str(e)}\"\n            logger.error(error_msg)\n            return error_msg\n    \n    def _poll_result(self, batch_id: str, max_wait_time: int = 600) -> tuple[Optional[str], Optional[str], Optional[str]]:\n        \"\"\"Poll for parsing result\n        \n        Returns:\n            Tuple of (markdown_content, extract_id, error_message)\n        \"\"\"\n        headers = {\n            \"Content-Type\": \"application/json\",\n            \"Authorization\": f\"Bearer {self.mineru_token}\"\n        }\n        \n        result_url = self.get_result_api_template.format(batch_id)\n        start_time = time.time()\n        \n        while True:\n            if time.time() - start_time > max_wait_time:\n                error_msg = f\"Parsing timeout after {max_wait_time} seconds\"\n                logger.error(error_msg)\n                return None, None, error_msg\n            \n            try:\n                response = requests.get(result_url, headers=headers, timeout=30)\n                response.raise_for_status()\n                task_info = response.json()\n                \n                if task_info.get(\"code\") != 0:\n                    error_msg = f\"Failed to query task status: {task_info.get('msg')}\"\n                    logger.error(error_msg)\n                    return None, None, error_msg\n                \n                task_status = task_info[\"data\"][\"extract_result\"][0][\"state\"]\n                \n                if task_status == \"done\":\n                    logger.info(\"File parsing completed!\")\n                    full_zip_url = task_info[\"data\"][\"extract_result\"][0][\"full_zip_url\"]\n                    # Download and extract markdown\n                    return self._download_markdown(full_zip_url)\n                elif task_status == \"failed\":\n                    err_msg = task_info[\"data\"][\"extract_result\"][0].get(\"err_msg\", \"Unknown error\")\n                    error_msg = f\"File parsing failed: {err_msg}\"\n                    logger.error(error_msg)\n                    return None, None, error_msg\n                else:\n                    logger.debug(f\"Current task status: {task_status}, waiting...\")\n                    time.sleep(2)  # Wait 2 seconds before next poll\n                    \n            except requests.exceptions.RequestException as e:\n                logger.warning(f\"Network error while polling result: {str(e)}, retrying...\")\n                time.sleep(2)\n    \n    def _download_markdown(self, zip_url: str) -> tuple[Optional[str], Optional[str], Optional[str]]:\n        \"\"\"Download and extract markdown from result zip, save images to local server\n        \n        Returns:\n            Tuple of (markdown_content, extract_id, error_message)\n        \"\"\"\n        try:\n            response = requests.get(zip_url, timeout=60)\n            response.raise_for_status()\n            \n            # Generate unique directory name for this extraction\n            import uuid\n            extract_id = str(uuid.uuid4())[:8]\n            \n            # Get upload folder from Flask config (we'll need to pass this)\n            # For now, use a hardcoded path relative to project root\n            import os\n            from pathlib import Path\n            \n            # Navigate to project root (assuming this file is in backend/services/)\n            current_file = Path(__file__).resolve()\n            backend_dir = current_file.parent.parent\n            project_root = backend_dir.parent\n            \n            # Create directory for mineru extracts\n            mineru_storage = project_root / 'uploads' / 'mineru_files' / extract_id\n            mineru_storage.mkdir(parents=True, exist_ok=True)\n            \n            logger.info(f\"Extracting ZIP to: {mineru_storage}\")\n            \n            markdown_content = None\n            markdown_file_path = None\n            \n            with zipfile.ZipFile(io.BytesIO(response.content)) as z:\n                # Extract all files\n                z.extractall(mineru_storage)\n                logger.info(f\"Extracted {len(z.namelist())} files from ZIP\")\n                \n                # Find markdown file (usually full.md or similar)\n                for name in z.namelist():\n                    if name.endswith('.md') or name.endswith('.MD'):\n                        markdown_file_path = name\n                        md_full_path = mineru_storage / name\n                        with open(md_full_path, 'r', encoding='utf-8') as f:\n                            markdown_content = f.read()\n                        logger.info(f\"Found markdown file: {name}\")\n                        break\n                \n                if not markdown_content:\n                    error_msg = \"No markdown file found in result zip\"\n                    logger.error(error_msg)\n                    return None, None, error_msg\n            \n            # Replace relative image paths with local server URLs\n            markdown_content = self._replace_image_paths(\n                markdown_content, \n                markdown_file_path,\n                extract_id\n            )\n            \n            return markdown_content, extract_id, None\n                \n        except requests.exceptions.RequestException as e:\n            error_msg = f\"Failed to download result: {str(e)}\"\n            logger.error(error_msg)\n            return None, None, error_msg\n        except zipfile.BadZipFile:\n            error_msg = \"Downloaded file is not a valid ZIP archive\"\n            logger.error(error_msg)\n            return None, None, error_msg\n        except Exception as e:\n            error_msg = f\"Failed to process ZIP file: {str(e)}\"\n            logger.error(error_msg)\n            return None, None, error_msg\n    \n    @staticmethod\n    def extract_header_footer_from_layout(extract_id: str) -> str:\n        \"\"\"\n        从 MinerU layout.json 的 discarded_blocks 中提取页眉页脚文本。\n\n        Args:\n            extract_id: MinerU 解析结果的 extract_id\n\n        Returns:\n            提取到的页眉页脚文本，如无则返回空字符串\n        \"\"\"\n        import json\n        from pathlib import Path\n\n        current_file = Path(__file__).resolve()\n        project_root = current_file.parent.parent.parent\n        mineru_dir = project_root / 'uploads' / 'mineru_files' / extract_id\n        layout_file = mineru_dir / 'layout.json'\n\n        if not layout_file.exists():\n            return ''\n\n        try:\n            with open(layout_file, 'r', encoding='utf-8') as f:\n                layout_data = json.load(f)\n\n            if 'pdf_info' not in layout_data or not layout_data['pdf_info']:\n                return ''\n\n            texts = []\n            for page_info in layout_data['pdf_info']:\n                for block in page_info.get('discarded_blocks', []):\n                    block_type = block.get('type', '')\n                    if block_type not in ('header', 'footer'):\n                        continue\n                    for line in block.get('lines', []):\n                        for span in line.get('spans', []):\n                            if span.get('type') == 'text' and span.get('content', '').strip():\n                                content = span['content'].strip()\n                                if content != '#':\n                                    texts.append(content)\n\n            return '\\n'.join(texts)\n        except Exception as e:\n            logger.warning(f\"Failed to extract header/footer from layout.json: {e}\")\n            return ''\n\n    def _replace_image_paths(self, markdown_content: str, markdown_file_path: str, extract_id: str) -> str:\n        \"\"\"Replace relative image paths in markdown with local server URLs\"\"\"\n        import os\n        \n        # Get the directory where the markdown file is located (within the extracted ZIP)\n        md_dir = os.path.dirname(markdown_file_path)\n        \n        def replace_link(match):\n            alt_text = match.group(1)\n            img_path = match.group(2)\n            \n            # Skip if already an absolute URL\n            if img_path.startswith(('http://', 'https://')):\n                return match.group(0)\n            \n            # Handle /file/ or /files/ paths (MinerU may generate these)\n            # These are relative to the extracted directory\n            if img_path.startswith('/file/') or img_path.startswith('/files/'):\n                # Remove leading slash and use as relative path\n                rel_path = img_path.lstrip('/')\n                # Remove 'file/' or 'files/' prefix if present\n                if rel_path.startswith('file/'):\n                    rel_path = rel_path[5:]  # Remove 'file/' prefix\n                elif rel_path.startswith('files/'):\n                    rel_path = rel_path[6:]  # Remove 'files/' prefix\n            else:\n                # Calculate the relative path from the markdown file\n                if md_dir:\n                    # Normalize path separators\n                    rel_path = os.path.normpath(os.path.join(md_dir, img_path)).replace('\\\\', '/')\n                else:\n                    rel_path = img_path.replace('\\\\', '/')\n            \n            # Construct the local server URL\n            # The files are served at /files/mineru/{extract_id}/{rel_path}\n            new_url = f\"/files/mineru/{extract_id}/{rel_path[:15]}.{rel_path.split('.')[-1]}\" # \"images/...(8)\"\n            \n            logger.debug(f\"Replacing image path: {img_path} -> {new_url}\")\n            return f\"![{alt_text}]({new_url})\"\n        \n        # Match markdown image syntax\n        pattern = r\"!\\[(.*?)\\]\\((.*?)\\)\"\n        replaced_content = re.sub(pattern, replace_link, markdown_content)\n        \n        return replaced_content\n    \n    def _enhance_markdown_with_captions(self, markdown_content: str) -> tuple[str, int]:\n        \"\"\"\n        Enhance markdown by adding captions to images that don't have alt text\n        \n        Args:\n            markdown_content: Original markdown content\n            \n        Returns:\n            Tuple of (enhanced_markdown, failed_image_count)\n        \"\"\"\n        if not self._can_generate_captions():\n            return markdown_content, 0\n        \n        # Extract all image URLs from markdown (both with and without alt text)\n        # Support both http/https URLs and relative paths\n        image_pattern = r'!\\[(.*?)\\]\\(([^\\)]+)\\)'\n        matches = list(re.finditer(image_pattern, markdown_content))\n        \n        logger.info(f\"Found {len(matches)} markdown image references\")\n        \n        if not matches:\n            logger.info(\"No markdown image syntax found\")\n            return markdown_content, 0\n        \n        # Filter to only images without alt text (empty brackets)\n        images_to_caption = []\n        for match in matches:\n            alt_text = match.group(1).strip()\n            image_url = match.group(2).strip()\n            logger.debug(f\"Image found: alt='{alt_text}', url='{image_url}'\")\n            \n            if not alt_text:  # Only process images with empty alt text\n                images_to_caption.append(match)\n        \n        if not images_to_caption:\n            logger.info(f\"Found {len(matches)} images in markdown, but all have descriptions. Skipping caption generation.\")\n            return markdown_content, 0\n        \n        logger.info(f\"Found {len(images_to_caption)} images without descriptions out of {len(matches)} total, generating captions...\")\n        \n        # Generate captions in parallel (only for images without alt text)\n        image_urls = [match.group(2) for match in images_to_caption]\n        captions, failed_count = self._generate_captions_parallel(image_urls)\n        \n        # Log results\n        success_count = len(images_to_caption) - failed_count\n        logger.info(f\"Image caption generation completed: {success_count} succeeded, {failed_count} failed out of {len(images_to_caption)} total\")\n        \n        # Replace image syntax with captioned version (in reverse order to maintain positions)\n        enhanced_content = markdown_content\n        for match, caption in zip(reversed(images_to_caption), reversed(captions)):\n            old_text = match.group(0)\n            url = match.group(2)\n            # Use caption as alt text (empty if generation failed)\n            new_text = f\"![{caption}]({url})\"\n            enhanced_content = enhanced_content[:match.start()] + new_text + enhanced_content[match.end():]\n        \n        return enhanced_content, failed_count\n    \n    def _generate_captions_parallel(self, image_urls: List[str], max_workers: int = 12, max_retries: int = 3) -> tuple[List[str], int]:\n        \"\"\"\n        Generate captions for multiple images in parallel with retry mechanism\n        \n        Args:\n            image_urls: List of image URLs\n            max_workers: Maximum number of parallel workers\n            max_retries: Maximum number of retries for each image\n            \n        Returns:\n            Tuple of (list of captions, number of failed images)\n        \"\"\"\n        captions = [\"\"] * len(image_urls)\n        failed_count = 0\n        \n        def generate_with_retry(url: str, idx: int) -> tuple[int, str, bool]:\n            \"\"\"Generate caption with retry logic\"\"\"\n            for attempt in range(max_retries):\n                try:\n                    caption = self._generate_single_caption(url)\n                    if caption:\n                        logger.debug(f\"Generated caption for image {idx + 1}/{len(image_urls)} (attempt {attempt + 1})\")\n                        return (idx, caption, True)\n                    else:\n                        logger.warning(f\"Empty caption for image {idx + 1} (attempt {attempt + 1}/{max_retries})\")\n                except Exception as e:\n                    logger.warning(f\"Failed to generate caption for image {idx + 1} (attempt {attempt + 1}/{max_retries}): {str(e)}\")\n                    if attempt < max_retries - 1:\n                        import time\n                        time.sleep(1 * (attempt + 1))  # Exponential backoff: 1s, 2s, 3s\n            \n            # All retries failed\n            logger.error(f\"Failed to generate caption for image {idx + 1} after {max_retries} attempts\")\n            return (idx, \"\", False)\n        \n        with ThreadPoolExecutor(max_workers=max_workers) as executor:\n            future_to_idx = {\n                executor.submit(generate_with_retry, url, idx): idx\n                for idx, url in enumerate(image_urls)\n            }\n            \n            for future in as_completed(future_to_idx):\n                try:\n                    idx, caption, success = future.result()\n                    captions[idx] = caption\n                    if not success:\n                        failed_count += 1\n                except Exception as e:\n                    idx = future_to_idx[future]\n                    logger.error(f\"Unexpected error generating caption for image {idx + 1}: {str(e)}\")\n                    failed_count += 1\n        \n        return captions, failed_count\n    \n    def _generate_single_caption(self, image_url: str) -> str:\n        \"\"\"\n        Generate caption for a single image (supports both HTTP URLs and local paths)\n        \n        Args:\n            image_url: URL or local path of the image\n            \n        Returns:\n            Generated caption\n        \"\"\"\n        try:\n            # Load image based on URL type\n            if image_url.startswith('http://') or image_url.startswith('https://'):\n                # Download from HTTP(S) URL\n                response = requests.get(image_url, timeout=30)\n                response.raise_for_status()\n                image = Image.open(io.BytesIO(response.content))\n            elif image_url.startswith('/files/mineru/'):\n                # Local MinerU extracted file with prefix matching support\n                from utils.path_utils import find_mineru_file_with_prefix\n                \n                # Find file with prefix matching\n                img_path = find_mineru_file_with_prefix(image_url)\n                \n                if img_path is None or not img_path.exists():\n                    logger.warning(f\"Local image file not found (with prefix matching): {image_url}\")\n                    return \"\"\n                \n                image = Image.open(img_path)\n            else:\n                # Unsupported path type\n                logger.warning(f\"Unsupported image path type: {image_url}\")\n                return \"\"\n            \n            # Generate caption based on provider format\n            prompt = \"请用一句简短的中文描述这张图片的主要内容。只返回描述文字，不要其他解释。\"\n            \n            if self._provider_format == 'openai':\n                # Use OpenAI SDK format\n                client = self._get_openai_client()\n                if not client:\n                    logger.warning(\"OpenAI client not initialized, skipping caption generation\")\n                    return \"\"\n                \n                # Encode image to base64\n                buffered = io.BytesIO()\n                if image.mode in ('RGBA', 'LA', 'P'):\n                    image = image.convert('RGB')\n                image.save(buffered, format=\"JPEG\", quality=95)\n                base64_image = base64.b64encode(buffered.getvalue()).decode('utf-8')\n                \n                response = client.chat.completions.create(\n                    model=self._image_caption_model,\n                    messages=[\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\"type\": \"image_url\", \"image_url\": {\"url\": f\"data:image/jpeg;base64,{base64_image}\"}},\n                                {\"type\": \"text\", \"text\": prompt}\n                            ]\n                        }\n                    ],\n                    temperature=0.3\n                )\n                caption = response.choices[0].message.content.strip()\n            elif self._provider_format == 'lazyllm':\n                # Use LazyLLM format\n                client = self._get_lazyllm_client()\n                with tempfile.NamedTemporaryFile(prefix='lazyllm_ref_', suffix='.png', delete=False) as tmp:\n                    temp_path = tmp.name\n                try:\n                    image.save(temp_path)\n                    caption = client(prompt, lazyllm_files=[temp_path])\n                finally:\n                    try:\n                        os.remove(temp_path)\n                    except OSError:\n                        pass\n            else:\n                # Use Gemini SDK format (default)\n                from google.genai import types\n                client = self._get_gemini_client()\n                if not client:\n                    logger.warning(\"Gemini client not initialized, skipping caption generation\")\n                    return \"\"\n\n                result = client.models.generate_content(\n                    model=self._image_caption_model,\n                    contents=[image, prompt],\n                    config=types.GenerateContentConfig(\n                        temperature=0.3,  # Lower temperature for more consistent captions\n                    )\n                )\n                caption = result.text.strip()\n\n            # Strip <think>...</think> tags from reasoning models\n            caption = strip_think_tags(caption)\n\n            return caption\n            \n        except Exception as e:\n            logger.warning(f\"Failed to generate caption for {image_url}: {str(e)}\")\n            return \"\"  # Return empty string on failure\n"
  },
  {
    "path": "backend/services/file_service.py",
    "content": "\"\"\"\nFile Service - handles all file operations\n\"\"\"\nimport os\nimport uuid\nfrom pathlib import Path\nfrom typing import Optional\nfrom werkzeug.utils import secure_filename\nfrom PIL import Image\nfrom models import Project\nfrom models import db\n\n\ndef convert_image_to_rgb(image: Image.Image) -> Image.Image:\n    \"\"\"\n    Convert image to RGB mode for JPEG compatibility.\n    Handles RGBA, LA, P (palette) and other modes by compositing onto white background.\n\n    Args:\n        image: PIL Image object\n\n    Returns:\n        PIL Image in RGB mode\n    \"\"\"\n    if image.mode in ('RGBA', 'LA', 'P'):\n        # Create white background for transparent images\n        background = Image.new('RGB', image.size, (255, 255, 255))\n\n        # Convert palette mode to RGBA to handle transparency\n        if image.mode == 'P':\n            image = image.convert('RGBA')\n\n        # Paste image onto white background using alpha channel as mask\n        # For RGBA and LA modes, the last channel is the alpha/transparency channel\n        if image.mode in ('RGBA', 'LA'):\n            background.paste(image, mask=image.split()[-1])\n        else:\n            # This shouldn't happen after P->RGBA conversion, but handle just in case\n            background.paste(image)\n\n        return background\n    elif image.mode != 'RGB':\n        return image.convert('RGB')\n    return image\n\n\ndef resize_image_for_thumbnail(image: Image.Image, max_width: int = 1920) -> Image.Image:\n    \"\"\"\n    Resize image for thumbnail if it exceeds max width.\n    Maintains aspect ratio.\n    \n    Args:\n        image: PIL Image object\n        max_width: Maximum width in pixels (default 1920)\n        \n    Returns:\n        Resized PIL Image (or original if already smaller)\n    \"\"\"\n    if image.width > max_width:\n        ratio = max_width / image.width\n        new_height = int(image.height * ratio)\n        return image.resize((max_width, new_height), Image.Resampling.LANCZOS)\n    return image\n\n\nclass FileService:\n    \"\"\"Service for file management\"\"\"\n    \n    def __init__(self, upload_folder: str):\n        \"\"\"Initialize file service\"\"\"\n        self.upload_folder = Path(upload_folder)\n        self.upload_folder.mkdir(exist_ok=True, parents=True)\n    \n    def _get_project_dir(self, project_id: str) -> Path:\n        \"\"\"Get project directory\"\"\"\n        project_dir = self.upload_folder / project_id\n        project_dir.mkdir(exist_ok=True, parents=True)\n        return project_dir\n    \n    def _get_template_dir(self, project_id: str) -> Path:\n        \"\"\"Get template directory for project\"\"\"\n        template_dir = self._get_project_dir(project_id) / \"template\"\n        template_dir.mkdir(exist_ok=True, parents=True)\n        return template_dir\n    \n    def _get_pages_dir(self, project_id: str) -> Path:\n        \"\"\"Get pages directory for project\"\"\"\n        pages_dir = self._get_project_dir(project_id) / \"pages\"\n        pages_dir.mkdir(exist_ok=True, parents=True)\n        return pages_dir\n\n    def _get_exports_dir(self, project_id: str) -> Path:\n        \"\"\"Get exports directory for project (for generated PPT/PDF files)\"\"\"\n        exports_dir = self._get_project_dir(project_id) / \"exports\"\n        exports_dir.mkdir(exist_ok=True, parents=True)\n        return exports_dir\n\n    def _get_materials_dir(self, project_id: str) -> Path:\n        \"\"\"Get materials directory for project (for standalone generated assets)\"\"\"\n        materials_dir = self._get_project_dir(project_id) / \"materials\"\n        materials_dir.mkdir(exist_ok=True, parents=True)\n        return materials_dir\n    \n    def save_template_image(self, file, project_id: str) -> str:\n        \"\"\"\n        Save template image file\n        \n        Args:\n            file: FileStorage object from Flask request\n            project_id: Project ID\n        \n        Returns:\n            Relative file path from upload folder\n        \"\"\"\n        template_dir = self._get_template_dir(project_id)\n        \n        # Secure filename and add unique suffix\n        original_filename = secure_filename(file.filename)\n        ext = original_filename.rsplit('.', 1)[1].lower() if '.' in original_filename else 'png'\n        filename = f\"template.{ext}\"\n        \n        filepath = template_dir / filename\n        file.save(str(filepath))\n        \n        # Return relative path\n        return filepath.relative_to(self.upload_folder).as_posix()\n    \n    def save_generated_image(self, image: Image.Image, project_id: str,\n                           page_id: str, image_format: str = 'PNG',\n                           version_number: int = None) -> str:\n        \"\"\"\n        Save generated image with version support\n\n        Args:\n            image: PIL Image object\n            project_id: Project ID\n            page_id: Page ID\n            image_format: Image format (PNG, JPEG, etc.)\n            version_number: Optional version number. If None, uses timestamp-based naming\n\n        Returns:\n            Relative file path from upload folder\n        \"\"\"\n        pages_dir = self._get_pages_dir(project_id)\n\n        # Use lowercase extension\n        ext = image_format.lower()\n\n        # Generate filename with version number or timestamp\n        if version_number is not None:\n            filename = f\"{page_id}_v{version_number}.{ext}\"\n        else:\n            # Use timestamp for unique filename\n            import time\n            timestamp = int(time.time() * 1000)  # milliseconds\n            filename = f\"{page_id}_{timestamp}.{ext}\"\n\n        filepath = pages_dir / filename\n\n        # Save image - format is determined by file extension or explicitly specified\n        # Some PIL Image objects may not support format parameter, so we use extension\n        image.save(str(filepath))\n\n        # Return relative path\n        return filepath.relative_to(self.upload_folder).as_posix()\n\n    def get_cached_image_path(self, project_id: str, page_id: str, version_number: int) -> str:\n        \"\"\"\n        Generate the relative path for a cached thumbnail image.\n\n        This method centralizes the path generation logic for cached images,\n        ensuring consistency across the codebase (DRY principle).\n\n        Args:\n            project_id: Project ID\n            page_id: Page ID\n            version_number: Version number\n\n        Returns:\n            Relative file path from upload folder (e.g., \"project_id/pages/page_id_v1_thumb.jpg\")\n        \"\"\"\n        filename = f\"{page_id}_v{version_number}_thumb.jpg\"\n        return f\"{project_id}/pages/{filename}\"\n\n    def save_cached_image(self, image: Image.Image, project_id: str,\n                         page_id: str, version_number: int,\n                         quality: int = 85, max_width: int = 1920) -> str:\n        \"\"\"\n        Save compressed JPG thumbnail for faster frontend loading\n\n        Args:\n            image: PIL Image object\n            project_id: Project ID\n            page_id: Page ID\n            version_number: Version number\n            quality: JPEG quality (1-100), default 85\n            max_width: Maximum thumbnail width in pixels (default 1920)\n\n        Returns:\n            Relative file path from upload folder\n        \"\"\"\n        pages_dir = self._get_pages_dir(project_id)\n\n        # Use centralized path generation\n        relative_path = self.get_cached_image_path(project_id, page_id, version_number)\n        filename = Path(relative_path).name\n        filepath = pages_dir / filename\n\n        # Resize image if too large (for faster loading)\n        image = resize_image_for_thumbnail(image, max_width)\n\n        # Convert to RGB using shared function\n        image = convert_image_to_rgb(image)\n\n        # Save as compressed JPEG\n        image.save(str(filepath), 'JPEG', quality=quality, optimize=True)\n\n        # Return relative path\n        return relative_path\n\n    def save_material_image(self, image: Image.Image, project_id: Optional[str],\n                            image_format: str = 'PNG') -> str:\n        \"\"\"\n        Save standalone generated material image (not bound to a specific page)\n\n        Args:\n            image: PIL Image object\n            project_id: Project ID (None for global materials)\n            image_format: Image format (PNG, JPEG, etc.)\n\n        Returns:\n            Relative file path from upload folder\n        \"\"\"\n        # Handle global materials (project_id is None)\n        if project_id is None:\n            materials_dir = self.upload_folder / \"materials\"\n            materials_dir.mkdir(exist_ok=True, parents=True)\n        else:\n            materials_dir = self._get_materials_dir(project_id)\n\n        # Use lowercase extension\n        ext = image_format.lower()\n\n        # Generate unique filename\n        import time\n        timestamp = int(time.time() * 1000)  # milliseconds\n        filename = f\"material_{timestamp}.{ext}\"\n\n        filepath = materials_dir / filename\n\n        # Save image\n        image.save(str(filepath))\n\n        # Return relative path\n        return filepath.relative_to(self.upload_folder).as_posix()\n    \n    def delete_page_image_version(self, image_path: str) -> bool:\n        \"\"\"\n        Delete a specific image version file and its cache\n\n        Args:\n            image_path: Relative path to the image file\n\n        Returns:\n            True if deleted successfully\n        \"\"\"\n        filepath = self.upload_folder / image_path.replace('\\\\', '/')\n        deleted = False\n\n        if filepath.exists() and filepath.is_file():\n            filepath.unlink()\n            deleted = True\n\n        # Also delete corresponding cache file (_thumb.jpg)\n        # e.g., xxx_v1.png -> xxx_v1_thumb.jpg\n        cache_filepath = filepath.parent / f\"{filepath.stem}_thumb.jpg\"\n        if cache_filepath.exists() and cache_filepath.is_file():\n            cache_filepath.unlink()\n\n        return deleted\n    \n    def get_file_url(self, project_id: Optional[str], file_type: str, filename: str) -> str:\n        \"\"\"\n        Generate file URL for frontend access\n        \n        Args:\n            project_id: Project ID (None for global materials)\n            file_type: 'template', 'pages', or 'materials'\n            filename: File name\n        \n        Returns:\n            URL path for file access\n        \"\"\"\n        if project_id is None:\n            # Global materials\n            return f\"/files/materials/{filename}\"\n        return f\"/files/{project_id}/{file_type}/{filename}\"\n    \n    def get_absolute_path(self, relative_path: str) -> str:\n        \"\"\"\n        Get absolute file path from relative path\n        \n        Args:\n            relative_path: Relative path from upload folder\n        \n        Returns:\n            Absolute file path\n        \"\"\"\n        result = (self.upload_folder / relative_path.replace('\\\\', '/')).resolve()\n        if not str(result).startswith(str(self.upload_folder.resolve())):\n            raise ValueError(f\"Path traversal detected: {relative_path}\")\n        return str(result)\n    \n    def delete_template(self, project_id: str) -> bool:\n        \"\"\"\n        Delete template for project\n        \n        Args:\n            project_id: Project ID\n        \n        Returns:\n            True if deleted successfully\n        \"\"\"\n        template_dir = self._get_template_dir(project_id)\n        \n        # Delete all files in template directory\n        for file in template_dir.iterdir():\n            if file.is_file():\n                file.unlink()\n        \n        return True\n    \n    def delete_page_image(self, project_id: str, page_id: str) -> bool:\n        \"\"\"\n        Delete all page images (all versions and their caches)\n\n        Args:\n            project_id: Project ID\n            page_id: Page ID\n\n        Returns:\n            True if deleted successfully\n        \"\"\"\n        pages_dir = self._get_pages_dir(project_id)\n\n        # Find and delete all page image files (all versions and caches)\n        # Pattern matches: {page_id}_v1.png, {page_id}_v1_thumb.jpg, etc.\n        for file in pages_dir.glob(f\"{page_id}_*\"):\n            if file.is_file():\n                file.unlink()\n\n        return True\n    \n    def delete_project_files(self, project_id: str) -> bool:\n        \"\"\"\n        Delete all files for a project\n        \n        Args:\n            project_id: Project ID\n        \n        Returns:\n            True if deleted successfully\n        \"\"\"\n        import shutil\n        project_dir = self._get_project_dir(project_id)\n        \n        if project_dir.exists():\n            shutil.rmtree(project_dir)\n        \n        return True\n    \n    def file_exists(self, relative_path: str) -> bool:\n        \"\"\"Check if file exists\"\"\"\n        filepath = self.upload_folder / relative_path.replace('\\\\', '/')\n        return filepath.exists() and filepath.is_file()\n    \n    def get_template_path(self, project_id: str) -> Optional[str]:\n        \"\"\"\n        Get template file path for project\n        \n        Args:\n            project_id: Project ID\n        \n        Returns:\n            Absolute path to template file or None\n        \"\"\"\n        \n        # 刷新数据库会话，确保获取最新数据\n        db.session.expire_all()\n        project = Project.query.get(project_id)\n        if project and project.template_image_path:\n            # template_image_path 是相对路径，需要转换为绝对路径\n            template_path = self.upload_folder / project.template_image_path\n            if template_path.exists() and template_path.is_file():\n                return str(template_path)\n        \n        # 如果数据库中没有，回退到目录查找（兼容旧数据）\n        template_dir = self._get_template_dir(project_id)\n        if template_dir.exists():\n            # 按修改时间排序，返回最新的模板文件\n            template_files = [\n                f for f in template_dir.iterdir() \n                if f.is_file() and f.stem == 'template'\n            ]\n            if template_files:\n                # 返回修改时间最新的文件\n                latest_file = max(template_files, key=lambda f: f.stat().st_mtime)\n                return str(latest_file)\n        \n        return None\n    \n    def _get_user_templates_dir(self) -> Path:\n        \"\"\"Get user templates directory\"\"\"\n        templates_dir = self.upload_folder / \"user-templates\"\n        templates_dir.mkdir(exist_ok=True, parents=True)\n        return templates_dir\n    \n    def save_user_template(self, file, template_id: str) -> str:\n        \"\"\"\n        Save user template image file\n        \n        Args:\n            file: FileStorage object from Flask request\n            template_id: Template ID\n        \n        Returns:\n            Relative file path from upload folder\n        \"\"\"\n        templates_dir = self._get_user_templates_dir()\n        template_dir = templates_dir / template_id\n        template_dir.mkdir(exist_ok=True, parents=True)\n        \n        # Secure filename and preserve extension\n        original_filename = secure_filename(file.filename)\n        ext = original_filename.rsplit('.', 1)[1].lower() if '.' in original_filename else 'png'\n        filename = f\"template.{ext}\"\n        \n        filepath = template_dir / filename\n        file.save(str(filepath))\n        \n        # Return relative path\n        return filepath.relative_to(self.upload_folder).as_posix()\n    \n    def delete_user_template(self, template_id: str) -> bool:\n        \"\"\"\n        Delete user template\n\n        Args:\n            template_id: Template ID\n\n        Returns:\n            True if deleted successfully\n        \"\"\"\n        import shutil\n        templates_dir = self._get_user_templates_dir()\n        template_dir = templates_dir / template_id\n\n        if template_dir.exists():\n            shutil.rmtree(template_dir)\n\n        return True\n\n    def save_user_template_thumbnail(self, template_id: str, original_path: str,\n                                      quality: int = 80, max_width: int = 600) -> Optional[str]:\n        \"\"\"\n        Generate and save thumbnail for user template\n\n        Args:\n            template_id: Template ID\n            original_path: Relative path to original template image\n            quality: JPEG quality (1-100), default 80\n            max_width: Maximum thumbnail width in pixels (default 600)\n\n        Returns:\n            Relative file path to thumbnail, or None if failed\n        \"\"\"\n        try:\n            # Get full path to original image\n            original_full_path = self.upload_folder / original_path.replace('\\\\', '/')\n\n            if not original_full_path.exists():\n                return None\n\n            # Open and process image\n            image = Image.open(str(original_full_path))\n\n            # Resize if needed\n            image = resize_image_for_thumbnail(image, max_width)\n\n            # Convert to RGB for JPEG\n            image = convert_image_to_rgb(image)\n\n            # Save thumbnail\n            templates_dir = self._get_user_templates_dir()\n            template_dir = templates_dir / template_id\n            template_dir.mkdir(exist_ok=True, parents=True)\n\n            thumb_filename = \"template-thumb.webp\"\n            thumb_filepath = template_dir / thumb_filename\n\n            image.save(str(thumb_filepath), 'WEBP', quality=quality)\n            image.close()\n\n            return thumb_filepath.relative_to(self.upload_folder).as_posix()\n        except Exception:\n            return None\n    \n"
  },
  {
    "path": "backend/services/image_editability/__init__.py",
    "content": "\"\"\"\n图片可编辑化服务模块\n\n核心设计：\n- 无状态服务 - 线程安全，可并行调用\n- 依赖注入 - 通过配置对象注入所有依赖\n- 单一职责 - 只负责单张图片的可编辑化，批量处理由调用者控制\n\n组件：\n- 数据模型（BBox, EditableElement, EditableImage）\n- 元素提取器（ElementExtractor及其实现）\n- Inpaint提供者（InpaintProvider及其实现）\n- 工厂和配置（ServiceConfig）\n- 主服务类（ImageEditabilityService）\n\nExample:\n    >>> from services.image_editability import ServiceConfig, ImageEditabilityService\n    >>> \n    >>> # 创建配置\n    >>> config = ServiceConfig.from_defaults(mineru_token=\"your_token\")\n    >>> \n    >>> # 创建服务\n    >>> service = ImageEditabilityService(config)\n    >>> \n    >>> # 串行处理\n    >>> result = service.make_image_editable(\"image.png\")\n    >>> \n    >>> # 并行处理（推荐）\n    >>> from concurrent.futures import ThreadPoolExecutor, as_completed\n    >>> \n    >>> images = [\"img1.png\", \"img2.png\", \"img3.png\"]\n    >>> with ThreadPoolExecutor(max_workers=4) as executor:\n    ...     futures = {executor.submit(service.make_image_editable, img): img \n    ...                for img in images}\n    ...     results = {images[i]: future.result() \n    ...                for i, future in enumerate(as_completed(futures))}\n\"\"\"\n\n# 数据模型\nfrom .data_models import BBox, EditableElement, EditableImage\n\n# 坐标映射\nfrom .coordinate_mapper import CoordinateMapper\n\n# 元素提取器\nfrom .extractors import (\n    ElementExtractor,\n    MinerUElementExtractor,\n    BaiduOCRElementExtractor,\n    BaiduAccurateOCRElementExtractor,\n    ExtractorRegistry\n)\n\n# 混合提取器\nfrom .hybrid_extractor import (\n    HybridElementExtractor,\n    BBoxUtils,\n    create_hybrid_extractor\n)\n\n# Inpaint提供者\nfrom .inpaint_providers import (\n    InpaintProvider,\n    DefaultInpaintProvider,\n    GenerativeEditInpaintProvider,\n    BaiduInpaintProvider,\n    HybridInpaintProvider,\n    InpaintProviderRegistry\n)\n\n# 文字属性提取器\nfrom .text_attribute_extractors import (\n    TextStyleResult,\n    TextAttributeExtractor,\n    CaptionModelTextAttributeExtractor,\n    TextAttributeExtractorRegistry\n)\n\n# 工厂和配置\nfrom .factories import (\n    ExtractorFactory,\n    InpaintProviderFactory,\n    TextAttributeExtractorFactory,\n    ServiceConfig\n)\n\n# 主服务\nfrom .service import ImageEditabilityService\n\n__all__ = [\n    # 数据模型\n    'BBox',\n    'EditableElement',\n    'EditableImage',\n    # 坐标映射\n    'CoordinateMapper',\n    # 元素提取器\n    'ElementExtractor',\n    'MinerUElementExtractor',\n    'BaiduOCRElementExtractor',\n    'BaiduAccurateOCRElementExtractor',\n    'ExtractorRegistry',\n    # 混合提取器\n    'HybridElementExtractor',\n    'BBoxUtils',\n    'create_hybrid_extractor',\n    # Inpaint提供者\n    'InpaintProvider',\n    'DefaultInpaintProvider',\n    'GenerativeEditInpaintProvider',\n    'BaiduInpaintProvider',\n    'HybridInpaintProvider',\n    'InpaintProviderRegistry',\n    # 文字属性提取器\n    'TextStyleResult',\n    'TextAttributeExtractor',\n    'CaptionModelTextAttributeExtractor',\n    'TextAttributeExtractorRegistry',\n    # 工厂和配置\n    'ExtractorFactory',\n    'InpaintProviderFactory',\n    'TextAttributeExtractorFactory',\n    'ServiceConfig',\n    # 主服务\n    'ImageEditabilityService',\n]\n\n"
  },
  {
    "path": "backend/services/image_editability/coordinate_mapper.py",
    "content": "\"\"\"\n坐标映射工具 - 处理父子图片间的坐标转换\n\"\"\"\nfrom typing import Tuple\nfrom .data_models import BBox\n\n\nclass CoordinateMapper:\n    \"\"\"坐标映射工具 - 处理父子图片间的坐标转换\"\"\"\n    \n    @staticmethod\n    def local_to_global(\n        local_bbox: BBox,\n        parent_bbox: BBox,\n        local_image_size: Tuple[int, int],\n        parent_image_size: Tuple[int, int]\n    ) -> BBox:\n        \"\"\"\n        将子图的局部坐标转换为父图（或根图）的全局坐标\n        \n        Args:\n            local_bbox: 子图坐标系中的bbox\n            parent_bbox: 子图在父图中的位置\n            local_image_size: 子图尺寸 (width, height)\n            parent_image_size: 父图尺寸 (width, height)\n        \n        Returns:\n            在父图坐标系中的bbox\n        \"\"\"\n        # 计算缩放比例（子图实际像素 vs 子图在父图中的bbox尺寸）\n        scale_x = parent_bbox.width / local_image_size[0]\n        scale_y = parent_bbox.height / local_image_size[1]\n        \n        # 先缩放到父图bbox的尺寸\n        scaled_bbox = local_bbox.scale(scale_x, scale_y)\n        \n        # 再平移到父图bbox的位置\n        global_bbox = scaled_bbox.translate(parent_bbox.x0, parent_bbox.y0)\n        \n        return global_bbox\n    \n    @staticmethod\n    def global_to_local(\n        global_bbox: BBox,\n        parent_bbox: BBox,\n        local_image_size: Tuple[int, int],\n        parent_image_size: Tuple[int, int]\n    ) -> BBox:\n        \"\"\"\n        将父图的全局坐标转换为子图的局部坐标（逆向映射）\n        \n        Args:\n            global_bbox: 父图坐标系中的bbox\n            parent_bbox: 子图在父图中的位置\n            local_image_size: 子图尺寸 (width, height)\n            parent_image_size: 父图尺寸 (width, height)\n        \n        Returns:\n            在子图坐标系中的bbox\n        \"\"\"\n        # 先平移（相对于parent_bbox的原点）\n        translated_bbox = global_bbox.translate(-parent_bbox.x0, -parent_bbox.y0)\n        \n        # 再缩放\n        scale_x = local_image_size[0] / parent_bbox.width\n        scale_y = local_image_size[1] / parent_bbox.height\n        \n        local_bbox = translated_bbox.scale(scale_x, scale_y)\n        \n        return local_bbox\n\n"
  },
  {
    "path": "backend/services/image_editability/data_models.py",
    "content": "\"\"\"\n数据模型 - 图片可编辑化服务的核心数据结构\n\"\"\"\nfrom typing import Dict, Any, List, Optional, Tuple\nfrom dataclasses import dataclass, field\n\n\n@dataclass\nclass BBox:\n    \"\"\"边界框坐标\"\"\"\n    x0: float\n    y0: float\n    x1: float\n    y1: float\n    \n    @property\n    def width(self) -> float:\n        return self.x1 - self.x0\n    \n    @property\n    def height(self) -> float:\n        return self.y1 - self.y0\n    \n    @property\n    def area(self) -> float:\n        return self.width * self.height\n    \n    def to_tuple(self) -> Tuple[float, float, float, float]:\n        \"\"\"转换为元组格式 (x0, y0, x1, y1)\"\"\"\n        return (self.x0, self.y0, self.x1, self.y1)\n    \n    def to_dict(self) -> Dict[str, float]:\n        \"\"\"转换为字典格式\"\"\"\n        return {\n            'x0': self.x0,\n            'y0': self.y0,\n            'x1': self.x1,\n            'y1': self.y1\n        }\n    \n    def scale(self, scale_x: float, scale_y: float) -> 'BBox':\n        \"\"\"缩放bbox\"\"\"\n        return BBox(\n            x0=self.x0 * scale_x,\n            y0=self.y0 * scale_y,\n            x1=self.x1 * scale_x,\n            y1=self.y1 * scale_y\n        )\n    \n    def translate(self, offset_x: float, offset_y: float) -> 'BBox':\n        \"\"\"平移bbox\"\"\"\n        return BBox(\n            x0=self.x0 + offset_x,\n            y0=self.y0 + offset_y,\n            x1=self.x1 + offset_x,\n            y1=self.y1 + offset_y\n        )\n\n\n@dataclass\nclass EditableElement:\n    \"\"\"可编辑元素\"\"\"\n    element_id: str  # 唯一标识\n    element_type: str  # text, image, table, figure, equation等\n    bbox: BBox  # 在父容器（EditableImage）坐标系中的位置\n    bbox_global: BBox  # 在根图片（最顶层EditableImage）坐标系中的位置（预计算存储，避免前端/后续使用时重新遍历计算）\n    content: Optional[str] = None  # 文字内容、HTML表格等\n    image_path: Optional[str] = None  # 图片路径（MinerU提取的）\n    \n    # 递归子元素（如果是图片或图表，可能有子元素）\n    children: List['EditableElement'] = field(default_factory=list)\n    \n    # 子图的inpaint背景（如果此元素是递归分析的图片/图表）\n    inpainted_background_path: Optional[str] = None\n    \n    # 元数据\n    metadata: Dict[str, Any] = field(default_factory=dict)\n    \n    def to_dict(self) -> Dict[str, Any]:\n        \"\"\"转换为字典（可序列化）\"\"\"\n        result = {\n            'element_id': self.element_id,\n            'element_type': self.element_type,\n            'bbox': self.bbox.to_dict(),\n            'bbox_global': self.bbox_global.to_dict(),\n            'content': self.content,\n            'image_path': self.image_path,\n            'inpainted_background_path': self.inpainted_background_path,\n            'metadata': self.metadata,\n            'children': [child.to_dict() for child in self.children]\n        }\n        return result\n\n\n@dataclass\nclass EditableImage:\n    \"\"\"可编辑化的图片结构\"\"\"\n    image_id: str  # 唯一标识\n    image_path: str  # 原始图片路径\n    width: int  # 图片宽度\n    height: int  # 图片高度\n    \n    # 所有提取的元素\n    elements: List[EditableElement] = field(default_factory=list)\n    \n    # Inpaint后的背景图（消除所有元素）\n    clean_background: Optional[str] = None\n    \n    # 递归层级\n    depth: int = 0\n    \n    # 父图片ID（如果是子图）\n    parent_id: Optional[str] = None\n    \n    # 元数据\n    metadata: Dict[str, Any] = field(default_factory=dict)\n    \n    def to_dict(self) -> Dict[str, Any]:\n        \"\"\"转换为字典（可序列化）\"\"\"\n        return {\n            'image_id': self.image_id,\n            'image_path': self.image_path,\n            'width': self.width,\n            'height': self.height,\n            'elements': [elem.to_dict() for elem in self.elements],\n            'clean_background': self.clean_background,\n            'depth': self.depth,\n            'parent_id': self.parent_id,\n            'metadata': self.metadata\n        }\n\n"
  },
  {
    "path": "backend/services/image_editability/extractors.py",
    "content": "\"\"\"\n元素提取器 - 抽象不同的元素识别方法\n\n包含：\n- ElementExtractor: 提取器抽象接口\n- MinerUElementExtractor: MinerU版面分析提取器\n- BaiduOCRElementExtractor: 百度表格OCR提取器\n- BaiduAccurateOCRElementExtractor: 百度高精度OCR提取器（文字识别）\n- ExtractorRegistry: 元素类型到提取器的映射注册表\n\"\"\"\nimport os\nimport json\nimport logging\nimport tempfile\nimport uuid\nfrom abc import ABC, abstractmethod\nfrom typing import Dict, Any, List, Optional, Tuple, Type\nfrom pathlib import Path\nfrom PIL import Image\n\nlogger = logging.getLogger(__name__)\n\n\nclass ExtractionContext:\n    \"\"\"提取上下文 - 提取器可能需要的额外信息\"\"\"\n    \n    def __init__(\n        self,\n        result_dir: Optional[str] = None,\n        metadata: Optional[Dict[str, Any]] = None\n    ):\n        \"\"\"\n        Args:\n            result_dir: 结果目录（如MinerU的输出目录）\n            metadata: 其他元数据\n        \"\"\"\n        self.result_dir = result_dir\n        self.metadata = metadata or {}\n\n\nclass ExtractionResult:\n    \"\"\"提取结果\"\"\"\n\n    def __init__(\n        self,\n        elements: List[Dict[str, Any]],\n        context: Optional[ExtractionContext] = None,\n        error: Optional[str] = None\n    ):\n        \"\"\"\n        Args:\n            elements: 提取的元素列表\n            context: 提取上下文（用于后续递归处理）\n            error: 提取过程中的错误信息（如果有）\n        \"\"\"\n        self.elements = elements\n        self.context = context or ExtractionContext()\n        self.error = error\n\n    @property\n    def has_error(self) -> bool:\n        \"\"\"是否有错误\"\"\"\n        return self.error is not None\n\n\nclass ElementExtractor(ABC):\n    \"\"\"\n    元素提取器抽象接口\n    \n    用于抽象不同的元素识别方法，支持接入多种实现：\n    - MinerU解析器（当前默认）\n    - 百度OCR（用于表格）\n    - PaddleOCR\n    - Tesseract OCR\n    - 其他自定义识别服务\n    \"\"\"\n    \n    @abstractmethod\n    def extract(\n        self,\n        image_path: str,\n        element_type: Optional[str] = None,\n        **kwargs\n    ) -> ExtractionResult:\n        \"\"\"\n        从图像中提取元素\n        \n        Args:\n            image_path: 图像文件路径\n            element_type: 元素类型提示（如 'table', 'text', 'image'等），可选\n            **kwargs: 其他由具体实现自定义的参数\n        \n        Returns:\n            ExtractionResult对象，包含：\n            - elements: 元素字典列表，每个字典包含：\n                - bbox: List[float] - 边界框 [x0, y0, x1, y1]\n                - type: str - 元素类型（'text', 'image', 'table', 'title'等）\n                - content: Optional[str] - 文本内容\n                - image_path: Optional[str] - 图片相对路径\n                - metadata: Dict[str, Any] - 其他元数据\n            - context: 提取上下文（用于后续递归处理）\n        \"\"\"\n        pass\n    \n    @abstractmethod\n    def supports_type(self, element_type: Optional[str]) -> bool:\n        \"\"\"\n        检查提取器是否支持指定的元素类型\n        \n        Args:\n            element_type: 元素类型（如 'table', 'image'等），None表示通用\n        \n        Returns:\n            是否支持该类型\n        \"\"\"\n        pass\n\n\nclass MinerUElementExtractor(ElementExtractor):\n    \"\"\"\n    基于MinerU的元素提取器（默认实现）\n    \n    从MinerU的解析结果中提取文本、图片、表格等元素\n    自包含：自己处理PDF转换、MinerU解析、结果提取\n    \"\"\"\n    \n    def __init__(self, parser_service, upload_folder: Path):\n        \"\"\"\n        初始化MinerU提取器\n        \n        Args:\n            parser_service: FileParserService实例\n            upload_folder: 上传文件夹路径\n        \"\"\"\n        self._parser_service = parser_service\n        self._upload_folder = upload_folder\n    \n    def supports_type(self, element_type: Optional[str]) -> bool:\n        \"\"\"MinerU支持所有通用类型（除了特殊的表格单元格）\"\"\"\n        return element_type != 'table_cell'\n    \n    def extract(\n        self,\n        image_path: str,\n        element_type: Optional[str] = None,\n        **kwargs\n    ) -> ExtractionResult:\n        \"\"\"\n        从图像中提取元素（自动处理PDF转换和MinerU解析）\n\n        支持的kwargs:\n        - depth: int, 递归深度（用于日志）\n        \"\"\"\n        depth = kwargs.get('depth', 0)\n\n        # 获取图片尺寸\n        img = Image.open(image_path)\n        image_size = img.size  # (width, height)\n\n        # 1. 检查缓存\n        cached_dir = self._find_cache(image_path)\n        if cached_dir:\n            logger.info(f\"{'  ' * depth}使用MinerU缓存\")\n            mineru_result_dir = cached_dir\n            parse_error = None\n        else:\n            # 2. 解析图片\n            mineru_result_dir, parse_error = self._parse_image(image_path, depth)\n            if not mineru_result_dir:\n                return ExtractionResult(elements=[], error=parse_error)\n\n        # 3. 提取元素\n        elements = self._extract_from_result(\n            mineru_result_dir=mineru_result_dir,\n            target_image_size=image_size,\n            depth=depth\n        )\n\n        # 4. 返回结果（带上下文）\n        context = ExtractionContext(\n            result_dir=mineru_result_dir,\n            metadata={'source': 'mineru', 'image_size': image_size}\n        )\n\n        return ExtractionResult(elements=elements, context=context)\n    \n    def _find_cache(self, image_path: str) -> Optional[str]:\n        \"\"\"查找缓存的MinerU结果\"\"\"\n        try:\n            import hashlib\n            import time\n            \n            img_path = Path(image_path)\n            if not img_path.exists():\n                return None\n            \n            mineru_files_dir = self._upload_folder / 'mineru_files'\n            if not mineru_files_dir.exists():\n                return None\n            \n            # 简单策略：不使用缓存（更安全）\n            return None\n            \n        except Exception as e:\n            logger.debug(f\"查找缓存失败: {e}\")\n            return None\n    \n    def _parse_image(self, image_path: str, depth: int) -> Tuple[Optional[str], Optional[str]]:\n        \"\"\"解析图片，返回MinerU结果目录和错误信息\n\n        Returns:\n            Tuple of (result_dir, error_message)\n        \"\"\"\n        from services.export_service import ExportService\n\n        # 转换为PDF\n        with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp_pdf:\n            pdf_path = tmp_pdf.name\n\n        try:\n            ExportService.create_pdf_from_images([image_path], output_file=pdf_path)\n\n            # 调用MinerU解析\n            image_id = str(uuid.uuid4())[:8]\n            batch_id, markdown_content, extract_id, error_message, failed_image_count = \\\n                self._parser_service.parse_file(pdf_path, f\"image_{image_id}.pdf\")\n\n            if error_message or not extract_id:\n                logger.error(f\"{'  ' * depth}MinerU解析失败: {error_message}\")\n                return None, error_message or \"MinerU解析失败，未返回extract_id\"\n\n            mineru_result_dir = (self._upload_folder / 'mineru_files' / extract_id).resolve()\n            if not mineru_result_dir.exists():\n                err = f\"MinerU结果目录不存在: {mineru_result_dir}\"\n                logger.error(f\"{'  ' * depth}{err}\")\n                return None, err\n\n            return str(mineru_result_dir), None\n\n        finally:\n            if os.path.exists(pdf_path):\n                os.remove(pdf_path)\n    \n    def _extract_from_result(\n        self,\n        mineru_result_dir: str,\n        target_image_size: Tuple[int, int],\n        depth: int\n    ) -> List[Dict[str, Any]]:\n        \"\"\"从MinerU结果目录中提取元素\"\"\"\n        elements = []\n        \n        try:\n            mineru_dir = Path(mineru_result_dir)\n            \n            # 加载layout.json和content_list.json\n            layout_file = mineru_dir / 'layout.json'\n            content_list_files = list(mineru_dir.glob(\"*_content_list.json\"))\n            \n            if not layout_file.exists() or not content_list_files:\n                logger.warning(f\"layout.json或content_list.json不存在\")\n                return []\n            \n            with open(layout_file, 'r', encoding='utf-8') as f:\n                layout_data = json.load(f)\n            \n            with open(content_list_files[0], 'r', encoding='utf-8') as f:\n                content_list = json.load(f)\n            \n            # 从layout.json提取元素\n            if 'pdf_info' not in layout_data or not layout_data['pdf_info']:\n                return []\n            \n            page_info = layout_data['pdf_info'][0]\n            source_page_size = page_info.get('page_size', target_image_size)\n            \n            # 计算缩放比例\n            scale_x = target_image_size[0] / source_page_size[0]\n            scale_y = target_image_size[1] / source_page_size[1]\n            \n            # 处理块的通用函数\n            def process_block(block):\n                bbox = block.get('bbox')\n                block_type = block.get('type', 'text')\n                \n                if not bbox or len(bbox) != 4:\n                    return None\n                \n                # 过滤掉 type 为 header/footer 且内容仅为 \"#\" 的特殊标记\n                if block_type in ['header', 'footer']:\n                    if block.get('lines'):\n                        # 提取所有文本内容\n                        all_text = []\n                        for line in block['lines']:\n                            for span in line.get('spans', []):\n                                if span.get('type') == 'text' and span.get('content'):\n                                    all_text.append(span['content'])\n                        # 如果所有文本合并后仅为\"#\"，则跳过此块\n                        combined_text = ''.join(all_text).strip()\n                        if combined_text == '#':\n                            return None\n                \n                # 缩放bbox到目标尺寸\n                scaled_bbox = [\n                    bbox[0] * scale_x,\n                    bbox[1] * scale_y,\n                    bbox[2] * scale_x,\n                    bbox[3] * scale_y\n                ]\n                \n                # 对于 header/footer，需要根据实际内容判断类型\n                actual_content_type = block_type\n                if block_type in ['header', 'footer']:\n                    # 检查是否包含图片\n                    has_image = False\n                    if block.get('blocks'):\n                        for sub_block in block['blocks']:\n                            if sub_block.get('type') == 'image_body':\n                                has_image = True\n                                break\n                    \n                    # 检查是否包含文本\n                    has_text = False\n                    if block.get('lines'):\n                        for line in block['lines']:\n                            for span in line.get('spans', []):\n                                if span.get('type') in ['text', 'inline_equation'] and span.get('content', '').strip():\n                                    has_text = True\n                                    break\n                            if has_text:\n                                break\n                    \n                    # 根据内容判断实际类型\n                    if has_image and not has_text:\n                        actual_content_type = 'image'\n                    elif has_text:\n                        actual_content_type = 'text'  # 将 header/footer 转换为 text\n                    else:\n                        # 默认当作文本处理\n                        actual_content_type = 'text'\n                \n                # 辅助函数：从 lines 提取文本\n                def extract_text_from_lines(lines):\n                    \"\"\"从 lines 数组提取所有文本内容\"\"\"\n                    line_texts = []\n                    for line in lines:\n                        span_texts = []\n                        for span in line.get('spans', []):\n                            span_type = span.get('type', '')\n                            span_content = span.get('content', '')\n                            \n                            if span_type == 'text' and span_content:\n                                span_texts.append(span_content)\n                            elif span_type == 'inline_equation' and span_content:\n                                from utils.latex_utils import latex_to_text\n                                converted = latex_to_text(span_content)\n                                span_texts.append(converted)\n                        \n                        if span_texts:\n                            line_text = ''.join(span_texts)\n                            line_texts.append(line_text)\n                    return line_texts\n                \n                # 提取content（文本）- 包括 caption 类型\n                content = None\n                if actual_content_type in ['text', 'title', 'table_caption', 'image_caption']:\n                    if block.get('lines'):\n                        line_texts = extract_text_from_lines(block['lines'])\n                        if line_texts:\n                            content = '\\n'.join(line_texts).strip()\n                \n                elif actual_content_type == 'list':\n                    # list 类型包含 blocks 子数组，每个 block 有 lines\n                    if block.get('blocks'):\n                        all_line_texts = []\n                        for sub_block in block['blocks']:\n                            if sub_block.get('lines'):\n                                sub_texts = extract_text_from_lines(sub_block['lines'])\n                                all_line_texts.extend(sub_texts)\n                        if all_line_texts:\n                            content = '\\n'.join(all_line_texts).strip()\n                \n                # 提取img_path（图片/表格）- 转换为绝对路径\n                img_path = None\n                if actual_content_type in ['image', 'table']:\n                    if block.get('blocks'):\n                        for sub_block in block['blocks']:\n                            for line in sub_block.get('lines', []):\n                                for span in line.get('spans', []):\n                                    if span.get('image_path'):\n                                        relative_path = span['image_path']\n                                        if not relative_path.startswith('images/'):\n                                            relative_path = 'images/' + relative_path\n                                        # 转换为绝对路径\n                                        abs_path = mineru_dir / relative_path\n                                        if abs_path.exists():\n                                            img_path = str(abs_path)\n                                        break\n                                if img_path:\n                                    break\n                            if img_path:\n                                break\n                \n                return {\n                    'bbox': scaled_bbox,\n                    'type': actual_content_type,  # 使用实际内容类型而不是原始类型\n                    'content': content,\n                    'image_path': img_path,  # 现在是绝对路径\n                    'metadata': {\n                        **block,\n                        'original_type': block_type  # 保留原始类型（header/footer）在metadata中\n                    }\n                }\n            \n            # 处理主要内容块（para_blocks）\n            for block in page_info.get('para_blocks', []):\n                element = process_block(block)\n                if element:\n                    elements.append(element)\n                # 递归处理子块（table_caption, image_caption 等）\n                # 注意：list 类型的子块已在 process_block 中处理，不需要再递归\n                block_type = block.get('type', '')\n                if block_type != 'list':\n                    for sub_block in block.get('blocks', []):\n                        sub_elem = process_block(sub_block)\n                        if sub_elem:\n                            elements.append(sub_elem)\n            \n            # 处理页眉页脚（discarded_blocks）\n            for block in page_info.get('discarded_blocks', []):\n                element = process_block(block)\n                if element:\n                    elements.append(element)\n                # 递归处理子块\n                # 注意：list 类型的子块已在 process_block 中处理，不需要再递归\n                block_type = block.get('type', '')\n                if block_type != 'list':\n                    for sub_block in block.get('blocks', []):\n                        sub_elem = process_block(sub_block)\n                        if sub_elem:\n                            elements.append(sub_elem)\n            \n            logger.info(f\"MinerU提取了 {len(elements)} 个元素\")\n        \n        except Exception as e:\n            logger.error(f\"MinerU提取元素失败: {e}\", exc_info=True)\n        \n        return elements\n\n\nclass BaiduOCRElementExtractor(ElementExtractor):\n    \"\"\"\n    基于百度OCR的元素提取器\n    \n    专门用于表格识别，提取表格单元格\n    自包含：自己处理OCR调用和单元格提取\n    \"\"\"\n    \n    def __init__(self, baidu_table_ocr_provider):\n        \"\"\"\n        初始化百度OCR提取器\n        \n        Args:\n            baidu_table_ocr_provider: 百度表格OCR Provider实例\n        \"\"\"\n        self._ocr_provider = baidu_table_ocr_provider\n    \n    def supports_type(self, element_type: Optional[str]) -> bool:\n        \"\"\"百度OCR主要支持表格类型\"\"\"\n        return element_type in ['table', 'table_cell', None]\n    \n    def extract(\n        self,\n        image_path: str,\n        element_type: Optional[str] = None,\n        **kwargs\n    ) -> ExtractionResult:\n        \"\"\"\n        从表格图片中提取单元格\n        \n        支持的kwargs:\n        - depth: int, 递归深度（用于日志）\n        - shrink_cells: bool, 是否收缩单元格以避免重叠，默认True\n        \"\"\"\n        depth = kwargs.get('depth', 0)\n        shrink_cells = kwargs.get('shrink_cells', True)\n        \n        elements = []\n        \n        try:\n            # 调用百度OCR识别表格\n            ocr_result = self._ocr_provider.recognize_table(\n                image_path,\n                cell_contents=True\n            )\n            \n            table_cells = ocr_result.get('cells', [])\n            # OCR结果通常会包含image_size，如果没有则自己获取\n            table_img_size = ocr_result.get('image_size')\n            if not table_img_size:\n                img = Image.open(image_path)\n                table_img_size = img.size\n            \n            logger.info(f\"{'  ' * depth}百度OCR识别到 {len(table_cells)} 个单元格\")\n            \n            # 只处理body单元格\n            body_cells = [cell for cell in table_cells if cell.get('section') == 'body']\n            valid_cells = [cell for cell in body_cells if cell.get('text', '').strip()]\n            \n            if not valid_cells:\n                logger.warning(f\"{'  ' * depth}没有有效的单元格\")\n                return ExtractionResult(elements=elements)\n            \n            # 处理单元格（可选择性收缩）\n            cell_bboxes = []\n            if shrink_cells:\n                cell_bboxes = self._shrink_cells_to_avoid_overlap(valid_cells, depth)\n            else:\n                cell_bboxes = [cell.get('bbox', [0, 0, 0, 0]) for cell in valid_cells]\n            \n            # 构建元素列表\n            for idx, (cell, bbox) in enumerate(zip(valid_cells, cell_bboxes)):\n                elements.append({\n                    'bbox': bbox,\n                    'type': 'table_cell',\n                    'content': cell.get('text', ''),\n                    'image_path': None,\n                    'metadata': {\n                        'row_start': cell.get('row_start'),\n                        'row_end': cell.get('row_end'),\n                        'col_start': cell.get('col_start'),\n                        'col_end': cell.get('col_end'),\n                        'table_idx': cell.get('table_idx', 0)\n                    }\n                })\n            \n            logger.info(f\"{'  ' * depth}百度OCR提取了 {len(elements)} 个单元格元素\")\n\n        except Exception as e:\n            error_msg = f\"百度OCR识别失败: {e}\"\n            logger.error(f\"{'  ' * depth}{error_msg}\", exc_info=True)\n            return ExtractionResult(elements=elements, error=error_msg)\n\n        # 百度OCR不需要result_dir（表格单元格不会有子元素）\n        return ExtractionResult(elements=elements)\n    \n    def _shrink_cells_to_avoid_overlap(\n        self,\n        valid_cells: List[Dict],\n        depth: int\n    ) -> List[List[float]]:\n        \"\"\"收缩单元格以避免重叠（算法同原实现）\"\"\"\n        TARGET_MIN_GAP = 6\n        SHRINK_STEP = 0.02\n        MIN_SIZE_RATIO = 0.4\n        MAX_ITERATIONS = 20\n        \n        cell_data = []\n        for cell in valid_cells:\n            bbox = cell.get('bbox', [0, 0, 0, 0])\n            x0, y0, x1, y1 = bbox\n            cell_data.append({\n                'cell': cell,\n                'original_bbox': bbox,\n                'current_bbox': [float(x0), float(y0), float(x1), float(y1)],\n                'original_width': x1 - x0,\n                'original_height': y1 - y0\n            })\n        \n        def calculate_min_gap(cell_data):\n            if len(cell_data) <= 1:\n                return float('inf')\n            \n            min_gap = float('inf')\n            for i, data1 in enumerate(cell_data):\n                x0_1, y0_1, x1_1, y1_1 = data1['current_bbox']\n                for j, data2 in enumerate(cell_data):\n                    if i >= j:\n                        continue\n                    x0_2, y0_2, x1_2, y1_2 = data2['current_bbox']\n                    \n                    x_overlap = not (x1_1 <= x0_2 or x1_2 <= x0_1)\n                    y_overlap = not (y1_1 <= y0_2 or y1_2 <= y0_1)\n                    \n                    if x_overlap and y_overlap:\n                        overlap_x = min(x1_1, x1_2) - max(x0_1, x0_2)\n                        overlap_y = min(y1_1, y1_2) - max(y0_1, y0_2)\n                        min_gap = min(min_gap, -min(overlap_x, overlap_y))\n                    elif x_overlap:\n                        gap = y0_2 - y1_1 if y1_1 <= y0_2 else y0_1 - y1_2\n                        min_gap = min(min_gap, gap)\n                    elif y_overlap:\n                        gap = x0_2 - x1_1 if x1_1 <= x0_2 else x0_1 - x1_2\n                        min_gap = min(min_gap, gap)\n            \n            return min_gap\n        \n        iteration = 0\n        total_shrink_ratio = 0\n        \n        while iteration < MAX_ITERATIONS:\n            current_min_gap = calculate_min_gap(cell_data)\n            \n            if current_min_gap >= TARGET_MIN_GAP:\n                if iteration == 0:\n                    logger.info(f\"{'  ' * depth}单元格间距已满足要求（最小={current_min_gap:.1f}px），无需收缩\")\n                else:\n                    logger.info(f\"{'  ' * depth}收缩完成：{iteration}次迭代，最小间距={current_min_gap:.1f}px\")\n                break\n            \n            all_cells_can_shrink = True\n            for data in cell_data:\n                x0, y0, x1, y1 = data['current_bbox']\n                current_width = x1 - x0\n                current_height = y1 - y0\n                \n                min_width = data['original_width'] * MIN_SIZE_RATIO\n                min_height = data['original_height'] * MIN_SIZE_RATIO\n                \n                if current_width <= min_width or current_height <= min_height:\n                    all_cells_can_shrink = False\n                    break\n                \n                shrink_x = max(0.5, current_width * SHRINK_STEP)\n                shrink_y = max(0.5, current_height * SHRINK_STEP)\n                \n                new_x0 = x0 + shrink_x\n                new_y0 = y0 + shrink_y\n                new_x1 = x1 - shrink_x\n                new_y1 = y1 - shrink_y\n                \n                if (new_x1 - new_x0) < min_width:\n                    new_x0 = x0 + (current_width - min_width) / 2\n                    new_x1 = x1 - (current_width - min_width) / 2\n                if (new_y1 - new_y0) < min_height:\n                    new_y0 = y0 + (current_height - min_height) / 2\n                    new_y1 = y1 - (current_height - min_height) / 2\n                \n                data['current_bbox'] = [new_x0, new_y0, new_x1, new_y1]\n            \n            if not all_cells_can_shrink:\n                logger.warning(f\"{'  ' * depth}达到最小尺寸限制，当前最小间距={current_min_gap:.1f}px\")\n                break\n            \n            total_shrink_ratio += SHRINK_STEP\n            iteration += 1\n        \n        if iteration >= MAX_ITERATIONS:\n            current_min_gap = calculate_min_gap(cell_data)\n            logger.warning(f\"{'  ' * depth}达到最大迭代次数，当前最小间距={current_min_gap:.1f}px\")\n        \n        return [data['current_bbox'] for data in cell_data]\n\n\nclass BaiduAccurateOCRElementExtractor(ElementExtractor):\n    \"\"\"\n    基于百度高精度OCR的元素提取器\n    \n    专门用于文字识别，提取文本行元素\n    支持多语种、高精度识别，返回文字位置信息\n    \"\"\"\n    \n    def __init__(self, baidu_accurate_ocr_provider):\n        \"\"\"\n        初始化百度高精度OCR提取器\n        \n        Args:\n            baidu_accurate_ocr_provider: 百度高精度OCR Provider实例\n        \"\"\"\n        self._ocr_provider = baidu_accurate_ocr_provider\n    \n    def supports_type(self, element_type: Optional[str]) -> bool:\n        \"\"\"百度高精度OCR主要支持文字类型\"\"\"\n        return element_type in ['text', 'title', 'paragraph', None]\n    \n    def extract(\n        self,\n        image_path: str,\n        element_type: Optional[str] = None,\n        **kwargs\n    ) -> ExtractionResult:\n        \"\"\"\n        从图片中提取文字元素\n        \n        支持的kwargs:\n        - depth: int, 递归深度（用于日志）\n        - language_type: str, 识别语言类型，默认'CHN_ENG'\n        - recognize_granularity: str, 是否定位单字符位置，'big'或'small'\n        - detect_direction: bool, 是否检测图像朝向\n        - paragraph: bool, 是否输出段落信息\n        \"\"\"\n        depth = kwargs.get('depth', 0)\n        language_type = kwargs.get('language_type', 'CHN_ENG')\n        recognize_granularity = kwargs.get('recognize_granularity', 'big')\n        detect_direction = kwargs.get('detect_direction', False)\n        paragraph = kwargs.get('paragraph', False)\n        \n        elements = []\n        \n        try:\n            # 调用百度高精度OCR识别\n            ocr_result = self._ocr_provider.recognize(\n                image_path,\n                language_type=language_type,\n                recognize_granularity=recognize_granularity,\n                detect_direction=detect_direction,\n                paragraph=paragraph,\n                probability=True,  # 获取置信度\n            )\n            \n            text_lines = ocr_result.get('text_lines', [])\n            image_size = ocr_result.get('image_size', (0, 0))\n            direction = ocr_result.get('direction', None)\n            \n            logger.info(f\"{'  ' * depth}百度高精度OCR识别到 {len(text_lines)} 行文字\")\n            \n            # 只处理有内容的文字行\n            valid_lines = [line for line in text_lines if line.get('text', '').strip()]\n            \n            if not valid_lines:\n                logger.warning(f\"{'  ' * depth}没有识别到有效的文字\")\n                return ExtractionResult(elements=elements)\n            \n            # 构建元素列表\n            for idx, line in enumerate(valid_lines):\n                bbox = line.get('bbox', [0, 0, 0, 0])\n                text = line.get('text', '')\n                \n                element = {\n                    'bbox': bbox,\n                    'type': 'text',\n                    'content': text,\n                    'image_path': None,\n                    'metadata': {\n                        'line_idx': idx,\n                        'source': 'baidu_accurate_ocr',\n                    }\n                }\n                \n                # 添加置信度信息\n                if 'probability' in line:\n                    element['metadata']['probability'] = line['probability']\n                \n                # 添加单字符信息\n                if 'chars' in line:\n                    element['metadata']['chars'] = line['chars']\n                \n                # 添加外接多边形顶点\n                if 'vertexes_location' in line:\n                    element['metadata']['vertexes_location'] = line['vertexes_location']\n                \n                elements.append(element)\n            \n            logger.info(f\"{'  ' * depth}百度高精度OCR提取了 {len(elements)} 个文字元素\")\n            \n            # 添加图片方向信息到上下文\n            context = ExtractionContext(\n                metadata={\n                    'source': 'baidu_accurate_ocr',\n                    'image_size': image_size,\n                    'direction': direction,\n                }\n            )\n            \n            return ExtractionResult(elements=elements, context=context)\n\n        except Exception as e:\n            error_msg = f\"百度高精度OCR识别失败: {e}\"\n            logger.error(f\"{'  ' * depth}{error_msg}\", exc_info=True)\n            return ExtractionResult(elements=elements, error=error_msg)\n\n\nclass ExtractorRegistry:\n    \"\"\"\n    元素类型到提取器的映射注册表\n    \n    用于管理不同元素类型应该使用哪个提取器进行子元素提取：\n    - 图片/图表元素 → MinerU 版面分析\n    - 表格元素 → 百度表格OCR\n    - 其他类型 → 默认提取器\n    \n    使用方式：\n        >>> registry = ExtractorRegistry()\n        >>> registry.register('table', baidu_ocr_extractor)\n        >>> registry.register('image', mineru_extractor)\n        >>> registry.register_default(mineru_extractor)\n        >>> \n        >>> extractor = registry.get_extractor('table')  # 返回 baidu_ocr_extractor\n        >>> extractor = registry.get_extractor('chart')  # 返回 mineru_extractor (默认)\n    \"\"\"\n    \n    # 预定义的元素类型分组\n    TABLE_TYPES = {'table', 'table_cell'}\n    IMAGE_TYPES = {'image', 'figure', 'chart', 'diagram'}\n    TEXT_TYPES = {'text', 'title', 'paragraph', 'header', 'footer', 'list'}\n    \n    def __init__(self):\n        \"\"\"初始化注册表\"\"\"\n        self._type_mapping: Dict[str, ElementExtractor] = {}\n        self._default_extractor: Optional[ElementExtractor] = None\n    \n    def register(self, element_type: str, extractor: ElementExtractor) -> 'ExtractorRegistry':\n        \"\"\"\n        注册元素类型到提取器的映射\n        \n        Args:\n            element_type: 元素类型（如 'table', 'image' 等）\n            extractor: 对应的提取器实例\n        \n        Returns:\n            self，支持链式调用\n        \"\"\"\n        self._type_mapping[element_type] = extractor\n        logger.debug(f\"注册提取器: {element_type} -> {extractor.__class__.__name__}\")\n        return self\n    \n    def register_types(self, element_types: List[str], extractor: ElementExtractor) -> 'ExtractorRegistry':\n        \"\"\"\n        批量注册多个元素类型到同一个提取器\n        \n        Args:\n            element_types: 元素类型列表\n            extractor: 对应的提取器实例\n        \n        Returns:\n            self，支持链式调用\n        \"\"\"\n        for t in element_types:\n            self.register(t, extractor)\n        return self\n    \n    def register_default(self, extractor: ElementExtractor) -> 'ExtractorRegistry':\n        \"\"\"\n        注册默认提取器（当没有特定类型映射时使用）\n        \n        Args:\n            extractor: 默认提取器实例\n        \n        Returns:\n            self，支持链式调用\n        \"\"\"\n        self._default_extractor = extractor\n        logger.debug(f\"注册默认提取器: {extractor.__class__.__name__}\")\n        return self\n    \n    def get_extractor(self, element_type: Optional[str]) -> Optional[ElementExtractor]:\n        \"\"\"\n        根据元素类型获取对应的提取器\n        \n        Args:\n            element_type: 元素类型，None表示使用默认提取器\n        \n        Returns:\n            对应的提取器，如果没有注册则返回默认提取器\n        \"\"\"\n        if element_type is None:\n            return self._default_extractor\n        \n        # 先查找精确匹配\n        if element_type in self._type_mapping:\n            return self._type_mapping[element_type]\n        \n        # 返回默认提取器\n        return self._default_extractor\n    \n    def get_all_extractors(self) -> List[ElementExtractor]:\n        \"\"\"\n        获取所有已注册的提取器（去重）\n        \n        Returns:\n            提取器列表\n        \"\"\"\n        extractors = list(set(self._type_mapping.values()))\n        if self._default_extractor and self._default_extractor not in extractors:\n            extractors.append(self._default_extractor)\n        return extractors\n    \n    @classmethod\n    def create_default(\n        cls,\n        mineru_extractor: ElementExtractor,\n        baidu_ocr_extractor: Optional[ElementExtractor] = None,\n        baidu_accurate_ocr_extractor: Optional[ElementExtractor] = None\n    ) -> 'ExtractorRegistry':\n        \"\"\"\n        创建默认配置的注册表\n        \n        默认配置：\n        - 表格类型 → 百度表格OCR（如果可用）\n        - 文字类型 → 百度高精度OCR（如果可用），否则MinerU\n        - 图片类型 → MinerU\n        - 其他类型 → MinerU（默认）\n        \n        Args:\n            mineru_extractor: MinerU提取器实例\n            baidu_ocr_extractor: 百度表格OCR提取器实例（可选）\n            baidu_accurate_ocr_extractor: 百度高精度OCR提取器实例（可选）\n        \n        Returns:\n            配置好的注册表实例\n        \"\"\"\n        registry = cls()\n        \n        # 设置默认提取器\n        registry.register_default(mineru_extractor)\n        \n        # 图片类型使用MinerU\n        registry.register_types(list(cls.IMAGE_TYPES), mineru_extractor)\n        \n        # 表格类型使用百度表格OCR（如果可用），否则使用MinerU\n        table_extractor = baidu_ocr_extractor if baidu_ocr_extractor else mineru_extractor\n        registry.register_types(list(cls.TABLE_TYPES), table_extractor)\n        \n        # 文字类型使用百度高精度OCR（如果可用），否则使用MinerU\n        text_extractor = baidu_accurate_ocr_extractor if baidu_accurate_ocr_extractor else mineru_extractor\n        registry.register_types(list(cls.TEXT_TYPES), text_extractor)\n        \n        logger.info(f\"创建默认ExtractorRegistry: \"\n                   f\"表格->{table_extractor.__class__.__name__}, \"\n                   f\"文字->{text_extractor.__class__.__name__}, \"\n                   f\"图片->{mineru_extractor.__class__.__name__}\")\n        \n        return registry\n\n"
  },
  {
    "path": "backend/services/image_editability/factories.py",
    "content": "\"\"\"\n工厂类 - 负责创建和配置具体的提取器和Inpaint提供者\n\"\"\"\nimport logging\nfrom typing import List, Optional, Any\nfrom pathlib import Path\n\nfrom .extractors import ElementExtractor, MinerUElementExtractor, BaiduOCRElementExtractor, BaiduAccurateOCRElementExtractor, ExtractorRegistry\nfrom .hybrid_extractor import HybridElementExtractor, create_hybrid_extractor\nfrom .inpaint_providers import (\n    InpaintProvider, \n    DefaultInpaintProvider, \n    GenerativeEditInpaintProvider, \n    BaiduInpaintProvider,\n    HybridInpaintProvider,\n    InpaintProviderRegistry\n)\nfrom .text_attribute_extractors import (\n    TextAttributeExtractor,\n    CaptionModelTextAttributeExtractor,\n    TextAttributeExtractorRegistry,\n    TextStyleResult\n)\n\nlogger = logging.getLogger(__name__)\n\n\nclass ExtractorFactory:\n    \"\"\"元素提取器工厂\"\"\"\n    \n    @staticmethod\n    def create_default_extractors(\n        parser_service: Any,\n        upload_folder: Path,\n        baidu_table_ocr_provider: Optional[Any] = None\n    ) -> List[ElementExtractor]:\n        \"\"\"\n        创建默认的元素提取器列表\n        \n        Args:\n            parser_service: MinerU解析服务实例\n            upload_folder: 上传文件夹路径\n            baidu_table_ocr_provider: 百度表格OCR Provider实例（可选）\n        \n        Returns:\n            提取器列表（按优先级排序）\n        \n        Note:\n            推荐使用 create_extractor_registry() 方法，它提供更清晰的类型到提取器映射\n        \"\"\"\n        extractors: List[ElementExtractor] = []\n        \n        # 1. 百度OCR提取器（用于表格）\n        if baidu_table_ocr_provider is None:\n            try:\n                from services.ai_providers.ocr import create_baidu_table_ocr_provider\n                baidu_provider = create_baidu_table_ocr_provider()\n                if baidu_provider:\n                    extractors.append(BaiduOCRElementExtractor(baidu_provider))\n                    logger.info(\"✅ 百度表格OCR提取器已启用\")\n            except Exception as e:\n                logger.warning(f\"无法初始化百度表格OCR: {e}\")\n        else:\n            extractors.append(BaiduOCRElementExtractor(baidu_table_ocr_provider))\n            logger.info(\"✅ 百度表格OCR提取器已启用\")\n        \n        # 2. MinerU提取器（默认通用提取器）\n        mineru_extractor = MinerUElementExtractor(parser_service, upload_folder)\n        extractors.append(mineru_extractor)\n        logger.info(\"✅ MinerU提取器已启用\")\n        \n        return extractors\n    \n    @staticmethod\n    def create_extractor_registry(\n        parser_service: Any,\n        upload_folder: Path,\n        baidu_table_ocr_provider: Optional[Any] = None\n    ) -> ExtractorRegistry:\n        \"\"\"\n        创建元素类型到提取器的注册表\n        \n        默认配置：\n        - 表格类型（table, table_cell）→ 百度OCR（如果可用），否则MinerU\n        - 图片类型（image, figure, chart）→ MinerU\n        - 其他类型 → MinerU（默认）\n        \n        Args:\n            parser_service: MinerU解析服务实例\n            upload_folder: 上传文件夹路径\n            baidu_table_ocr_provider: 百度表格OCR Provider实例（可选）\n        \n        Returns:\n            配置好的ExtractorRegistry实例\n        \"\"\"\n        # 创建MinerU提取器\n        mineru_extractor = MinerUElementExtractor(parser_service, upload_folder)\n        logger.info(\"✅ MinerU提取器已创建\")\n        \n        # 尝试创建百度OCR提取器\n        baidu_ocr_extractor = None\n        if baidu_table_ocr_provider is None:\n            try:\n                from services.ai_providers.ocr import create_baidu_table_ocr_provider\n                baidu_provider = create_baidu_table_ocr_provider()\n                if baidu_provider:\n                    baidu_ocr_extractor = BaiduOCRElementExtractor(baidu_provider)\n                    logger.info(\"✅ 百度表格OCR提取器已创建\")\n            except Exception as e:\n                logger.warning(f\"无法初始化百度表格OCR: {e}\")\n        else:\n            baidu_ocr_extractor = BaiduOCRElementExtractor(baidu_table_ocr_provider)\n            logger.info(\"✅ 百度表格OCR提取器已创建\")\n        \n        # 尝试创建百度高精度OCR提取器\n        baidu_accurate_ocr_extractor = None\n        try:\n            from services.ai_providers.ocr import create_baidu_accurate_ocr_provider\n            baidu_accurate_provider = create_baidu_accurate_ocr_provider()\n            if baidu_accurate_provider:\n                baidu_accurate_ocr_extractor = BaiduAccurateOCRElementExtractor(baidu_accurate_provider)\n                logger.info(\"✅ 百度高精度OCR提取器已创建\")\n        except Exception as e:\n            logger.warning(f\"无法初始化百度高精度OCR: {e}\")\n        \n        # 使用注册表的工厂方法创建默认配置\n        return ExtractorRegistry.create_default(\n            mineru_extractor=mineru_extractor,\n            baidu_ocr_extractor=baidu_ocr_extractor,\n            baidu_accurate_ocr_extractor=baidu_accurate_ocr_extractor\n        )\n    \n    @staticmethod\n    def create_baidu_accurate_ocr_extractor(\n        baidu_accurate_ocr_provider: Optional[Any] = None\n    ) -> Optional[BaiduAccurateOCRElementExtractor]:\n        \"\"\"\n        创建百度高精度OCR提取器\n        \n        Args:\n            baidu_accurate_ocr_provider: 百度高精度OCR Provider实例（可选，自动创建）\n        \n        Returns:\n            BaiduAccurateOCRElementExtractor实例，如果不可用则返回None\n        \"\"\"\n        if baidu_accurate_ocr_provider is None:\n            try:\n                from services.ai_providers.ocr import create_baidu_accurate_ocr_provider\n                baidu_accurate_ocr_provider = create_baidu_accurate_ocr_provider()\n            except Exception as e:\n                logger.warning(f\"无法初始化百度高精度OCR Provider: {e}\")\n                return None\n        \n        if baidu_accurate_ocr_provider is None:\n            return None\n        \n        return BaiduAccurateOCRElementExtractor(baidu_accurate_ocr_provider)\n    \n    @staticmethod\n    def create_hybrid_extractor(\n        parser_service: Any,\n        upload_folder: Path,\n        baidu_accurate_ocr_provider: Optional[Any] = None,\n        contain_threshold: float = 0.8,\n        intersection_threshold: float = 0.3\n    ) -> Optional[HybridElementExtractor]:\n        \"\"\"\n        创建混合元素提取器\n        \n        混合提取器结合MinerU版面分析和百度高精度OCR：\n        - MinerU负责识别元素类型和整体布局\n        - 百度OCR负责精确的文字识别和定位\n        \n        合并策略：\n        1. 图片类型bbox里包含的百度OCR bbox → 删除（图片内的文字不需要单独提取）\n        2. 表格类型bbox里包含的百度OCR bbox → 保留百度OCR结果，删除MinerU表格bbox\n        3. 其他类型（文字等）与百度OCR bbox有交集 → 使用百度OCR结果，删除MinerU bbox\n        \n        Args:\n            parser_service: MinerU解析服务实例\n            upload_folder: 上传文件夹路径\n            baidu_accurate_ocr_provider: 百度高精度OCR Provider实例（可选，自动创建）\n            contain_threshold: 包含判断阈值，默认0.8（80%面积在内部算包含）\n            intersection_threshold: 交集判断阈值，默认0.3（30%重叠算有交集）\n        \n        Returns:\n            HybridElementExtractor实例，如果无法创建则返回None\n        \"\"\"\n        # 创建MinerU提取器\n        mineru_extractor = MinerUElementExtractor(parser_service, upload_folder)\n        logger.info(\"✅ MinerU提取器已创建（用于混合提取）\")\n        \n        # 创建百度高精度OCR提取器\n        baidu_ocr_extractor = ExtractorFactory.create_baidu_accurate_ocr_extractor(\n            baidu_accurate_ocr_provider\n        )\n        \n        if baidu_ocr_extractor is None:\n            logger.warning(\"无法创建百度高精度OCR提取器，混合提取器创建失败\")\n            return None\n        \n        logger.info(\"✅ 百度高精度OCR提取器已创建（用于混合提取）\")\n        \n        return HybridElementExtractor(\n            mineru_extractor=mineru_extractor,\n            baidu_ocr_extractor=baidu_ocr_extractor,\n            contain_threshold=contain_threshold,\n            intersection_threshold=intersection_threshold\n        )\n    \n    @staticmethod\n    def create_hybrid_extractor_registry(\n        parser_service: Any,\n        upload_folder: Path,\n        baidu_table_ocr_provider: Optional[Any] = None,\n        baidu_accurate_ocr_provider: Optional[Any] = None,\n        contain_threshold: float = 0.8,\n        intersection_threshold: float = 0.3\n    ) -> ExtractorRegistry:\n        \"\"\"\n        创建使用混合提取器的注册表\n        \n        默认配置：\n        - 所有类型 → 混合提取器（如果可用）\n        - 回退到MinerU（如果混合提取器不可用）\n        \n        Args:\n            parser_service: MinerU解析服务实例\n            upload_folder: 上传文件夹路径\n            baidu_table_ocr_provider: 百度表格OCR Provider实例（可选）\n            baidu_accurate_ocr_provider: 百度高精度OCR Provider实例（可选）\n            contain_threshold: 包含判断阈值\n            intersection_threshold: 交集判断阈值\n        \n        Returns:\n            配置好的ExtractorRegistry实例\n        \"\"\"\n        # 创建MinerU提取器作为回退\n        mineru_extractor = MinerUElementExtractor(parser_service, upload_folder)\n        logger.info(\"✅ MinerU提取器已创建\")\n        \n        # 尝试创建混合提取器\n        hybrid_extractor = ExtractorFactory.create_hybrid_extractor(\n            parser_service=parser_service,\n            upload_folder=upload_folder,\n            baidu_accurate_ocr_provider=baidu_accurate_ocr_provider,\n            contain_threshold=contain_threshold,\n            intersection_threshold=intersection_threshold\n        )\n        \n        # 尝试创建百度表格OCR提取器\n        baidu_table_ocr_extractor = None\n        if baidu_table_ocr_provider is None:\n            try:\n                from services.ai_providers.ocr import create_baidu_table_ocr_provider\n                baidu_provider = create_baidu_table_ocr_provider()\n                if baidu_provider:\n                    from .extractors import BaiduOCRElementExtractor\n                    baidu_table_ocr_extractor = BaiduOCRElementExtractor(baidu_provider)\n                    logger.info(\"✅ 百度表格OCR提取器已创建\")\n            except Exception as e:\n                logger.warning(f\"无法初始化百度表格OCR: {e}\")\n        else:\n            from .extractors import BaiduOCRElementExtractor\n            baidu_table_ocr_extractor = BaiduOCRElementExtractor(baidu_table_ocr_provider)\n            logger.info(\"✅ 百度表格OCR提取器已创建\")\n        \n        # 创建注册表\n        registry = ExtractorRegistry()\n        \n        # 设置默认提取器\n        if hybrid_extractor:\n            registry.register_default(hybrid_extractor)\n            logger.info(\"✅ 使用混合提取器作为默认提取器\")\n        else:\n            registry.register_default(mineru_extractor)\n            logger.info(\"⚠️ 混合提取器不可用，回退到MinerU提取器\")\n        \n        # 表格类型使用百度表格OCR（如果可用）\n        if baidu_table_ocr_extractor:\n            registry.register_types(list(ExtractorRegistry.TABLE_TYPES), baidu_table_ocr_extractor)\n        \n        return registry\n\n\nclass InpaintProviderFactory:\n    \"\"\"Inpaint提供者工厂\"\"\"\n    \n    @staticmethod\n    def create_default_provider(inpainting_service: Optional[Any] = None) -> Optional[InpaintProvider]:\n        \"\"\"\n        创建默认的Inpaint提供者（使用Volcengine Inpainting服务）\n        \n        Args:\n            inpainting_service: InpaintingService实例（可选）\n        \n        Returns:\n            InpaintProvider实例，失败返回None\n        \"\"\"\n        if inpainting_service is None:\n            from services.inpainting_service import get_inpainting_service\n            inpainting_service = get_inpainting_service()\n        \n        logger.info(\"创建DefaultInpaintProvider\")\n        return DefaultInpaintProvider(inpainting_service)\n    \n    @staticmethod\n    def create_generative_edit_provider(\n        ai_service: Optional[Any] = None,\n        aspect_ratio: str = \"16:9\",\n        resolution: str = \"2K\"\n    ) -> InpaintProvider:\n        \"\"\"\n        创建基于生成式大模型的Inpaint提供者\n        \n        使用生成式大模型（如Gemini图片编辑）通过自然语言指令移除图片中的文字和图标。\n        适用于不需要精确bbox的场景，大模型自动理解并移除相关元素。\n        \n        Args:\n            ai_service: AIService实例（可选，如果不提供则自动获取）\n            aspect_ratio: 目标宽高比\n            resolution: 目标分辨率\n        \n        Returns:\n            GenerativeEditInpaintProvider实例\n        \n        Raises:\n            如果AI服务初始化失败，会抛出异常\n        \"\"\"\n        if ai_service is None:\n            from services.ai_service_manager import get_ai_service\n            ai_service = get_ai_service()\n        \n        logger.info(\"创建GenerativeEditInpaintProvider\")\n        return GenerativeEditInpaintProvider(ai_service, aspect_ratio, resolution)\n    \n    @staticmethod\n    def create_inpaint_registry(\n        mask_provider: Optional[InpaintProvider] = None,\n        generative_provider: Optional[InpaintProvider] = None,\n        default_provider_type: str = \"generative\"\n    ) -> InpaintProviderRegistry:\n        \"\"\"\n        创建重绘方法注册表\n        \n        支持动态注册新元素类型，不限于预定义类型。\n        \n        Args:\n            mask_provider: 基于mask的重绘提供者（可选，自动创建）\n            generative_provider: 生成式重绘提供者（可选，自动创建）\n            default_provider_type: 默认使用的提供者类型 (\"mask\" 或 \"generative\")\n        \n        Returns:\n            配置好的InpaintProviderRegistry实例\n        \"\"\"\n        # 自动创建提供者\n        if mask_provider is None:\n            mask_provider = InpaintProviderFactory.create_default_provider()\n        \n        if generative_provider is None:\n            generative_provider = InpaintProviderFactory.create_generative_edit_provider()\n        \n        # 创建注册表\n        registry = InpaintProviderRegistry()\n        \n        # 设置默认提供者\n        if default_provider_type == \"generative\" and generative_provider:\n            registry.register_default(generative_provider)\n        elif mask_provider:\n            registry.register_default(mask_provider)\n        elif generative_provider:\n            registry.register_default(generative_provider)\n        \n        # 注册类型映射（可通过registry.register()动态扩展）\n        if mask_provider:\n            # 文本和表格使用mask-based精确移除\n            registry.register_types(['text', 'title', 'paragraph'], mask_provider)\n            registry.register_types(['table', 'table_cell'], mask_provider)\n        \n        if generative_provider:\n            # 图片和图表使用生成式重绘\n            registry.register_types(['image', 'figure', 'chart', 'diagram'], generative_provider)\n        \n        logger.info(f\"创建InpaintProviderRegistry: 默认={default_provider_type}, \"\n                   f\"mask={mask_provider is not None}, generative={generative_provider is not None}\")\n        \n        return registry\n    \n    @staticmethod\n    def create_baidu_inpaint_provider() -> Optional[BaiduInpaintProvider]:\n        \"\"\"\n        创建百度图像修复提供者\n        \n        使用百度AI在指定矩形区域去除遮挡物并用背景内容填充。\n        \n        Returns:\n            BaiduInpaintProvider实例，如果不可用则返回None\n        \"\"\"\n        try:\n            from services.ai_providers.image.baidu_inpainting_provider import create_baidu_inpainting_provider\n            baidu_provider = create_baidu_inpainting_provider()\n            if baidu_provider:\n                logger.info(\"✅ 创建BaiduInpaintProvider\")\n                return BaiduInpaintProvider(baidu_provider)\n            else:\n                logger.warning(\"⚠️ 无法创建百度图像修复Provider（API Key未配置）\")\n                return None\n        except Exception as e:\n            logger.warning(f\"⚠️ 创建BaiduInpaintProvider失败: {e}\")\n            return None\n    \n    @staticmethod\n    def create_hybrid_inpaint_provider(\n        baidu_provider: Optional[BaiduInpaintProvider] = None,\n        generative_provider: Optional[GenerativeEditInpaintProvider] = None,\n        ai_service: Optional[Any] = None,\n        enhance_quality: bool = True\n    ) -> Optional[HybridInpaintProvider]:\n        \"\"\"\n        创建混合Inpaint提供者（百度修复 + 生成式画质提升）\n        \n        工作流程：\n        1. 先使用百度图像修复API精确去除文字\n        2. 再使用生成式大模型提升整体画质\n        \n        Args:\n            baidu_provider: 百度图像修复提供者（可选，自动创建）\n            generative_provider: 生成式编辑提供者（可选，自动创建）\n            ai_service: AI服务实例（用于创建生成式提供者）\n            enhance_quality: 是否启用画质提升，默认True\n        \n        Returns:\n            HybridInpaintProvider实例，如果无法创建则返回None\n        \"\"\"\n        # 创建百度修复提供者\n        if baidu_provider is None:\n            baidu_provider = InpaintProviderFactory.create_baidu_inpaint_provider()\n        \n        if baidu_provider is None:\n            logger.warning(\"⚠️ 无法创建百度图像修复Provider，混合Provider创建失败\")\n            return None\n        \n        # 创建生成式提供者（用于画质提升）\n        if generative_provider is None:\n            generative_provider = InpaintProviderFactory.create_generative_edit_provider(\n                ai_service=ai_service\n            )\n        \n        logger.info(\"✅ 创建HybridInpaintProvider（百度修复 + 生成式画质提升）\")\n        return HybridInpaintProvider(\n            baidu_provider=baidu_provider,\n            generative_provider=generative_provider,\n            enhance_quality=enhance_quality\n        )\n\n\nclass ServiceConfig:\n    \"\"\"服务配置类 - 纯配置，不持有具体服务引用\"\"\"\n    \n    def __init__(\n        self,\n        upload_folder: Path,\n        extractor_registry: ExtractorRegistry,\n        inpaint_registry: InpaintProviderRegistry,\n        max_depth: int = 1,\n        min_image_size: int = 200,\n        min_image_area: int = 40000\n    ):\n        \"\"\"\n        初始化服务配置\n        \n        Args:\n            upload_folder: 上传文件夹路径\n            extractor_registry: 元素类型到提取器的注册表\n            inpaint_registry: 元素类型到重绘方法的注册表\n            max_depth: 最大递归深度（默认1）\n            min_image_size: 最小图片尺寸\n            min_image_area: 最小图片面积\n        \"\"\"\n        self.upload_folder = upload_folder\n        self.extractor_registry = extractor_registry\n        self.inpaint_registry = inpaint_registry\n        self.max_depth = max_depth\n        self.min_image_size = min_image_size\n        self.min_image_area = min_image_area\n    \n    @classmethod\n    def from_defaults(\n        cls,\n        mineru_token: Optional[str] = None,\n        mineru_api_base: Optional[str] = None,\n        upload_folder: Optional[str] = None,\n        ai_service: Optional[Any] = None,\n        use_hybrid_extractor: bool = True,\n        use_hybrid_inpaint: bool = True,\n        extractor_method: Optional[str] = None,  # 'mineru' 或 'hybrid'，优先于 use_hybrid_extractor\n        inpaint_method: Optional[str] = None,    # 'generative', 'baidu', 'hybrid'，优先于 use_hybrid_inpaint\n        **kwargs\n    ) -> 'ServiceConfig':\n        \"\"\"\n        从默认参数创建配置\n        \n        默认配置（推荐用于导出PPTX）：\n        - 元素提取：混合提取器（MinerU版面分析 + 百度高精度OCR）\n        - 背景生成：混合Inpaint（百度图像修复 + 生成式画质提升）\n        - 递归深度：1\n        \n        混合提取器合并策略：\n        1. 图片类型bbox里包含的百度OCR bbox → 删除\n        2. 表格类型bbox里包含的百度OCR bbox → 保留百度OCR结果，删除MinerU表格bbox\n        3. 其他类型与百度OCR bbox有交集 → 使用百度OCR结果\n        \n        混合Inpaint策略：\n        1. 先用百度图像修复精确去除指定区域的文字\n        2. 再用生成式模型提升整体画质\n        \n        支持动态注册新的元素类型到不同的提取器/重绘方法。\n        \n        如果不提供参数，会自动从 Flask app.config 获取配置。\n        \n        Args:\n            mineru_token: MinerU API token（可选，默认从 Flask config 获取）\n            mineru_api_base: MinerU API base URL（可选，默认从 Flask config 获取）\n            upload_folder: 上传文件夹路径（可选，默认从 Flask config 获取）\n            ai_service: AI服务实例（可选，用于生成式重绘）\n            use_hybrid_extractor: 是否使用混合提取器（默认True，会被 extractor_method 覆盖）\n            use_hybrid_inpaint: 是否使用混合Inpaint（默认True，会被 inpaint_method 覆盖）\n            extractor_method: 组件提取方法，'mineru' 或 'hybrid'（优先于 use_hybrid_extractor）\n            inpaint_method: 背景修复方法，'generative', 'baidu', 'hybrid'（优先于 use_hybrid_inpaint）\n            **kwargs: 其他配置参数\n                - max_depth: 最大递归深度（默认1）\n                - min_image_size: 最小图片尺寸（默认200）\n                - min_image_area: 最小图片面积（默认40000）\n                - contain_threshold: 混合提取器包含判断阈值（默认0.8）\n                - intersection_threshold: 混合提取器交集判断阈值（默认0.3）\n                - enhance_quality: 混合Inpaint是否启用画质提升（默认True）\n        \n        Returns:\n            ServiceConfig实例\n        \n        Raises:\n            ValueError: 如果 mineru_token 未配置\n        \"\"\"\n        # 处理新参数：extractor_method 优先于 use_hybrid_extractor\n        if extractor_method is not None:\n            use_hybrid_extractor = (extractor_method == 'hybrid')\n            logger.info(f\"extractor_method={extractor_method} -> use_hybrid_extractor={use_hybrid_extractor}\")\n        # 自动从 Flask config 获取配置\n        from flask import current_app, has_app_context\n        \n        if has_app_context() and current_app:\n            if mineru_token is None:\n                mineru_token = current_app.config.get('MINERU_TOKEN')\n            if mineru_api_base is None:\n                mineru_api_base = current_app.config.get('MINERU_API_BASE', 'https://mineru.net')\n            if upload_folder is None:\n                upload_folder = current_app.config.get('UPLOAD_FOLDER', './uploads')\n        else:\n            # 回退到默认值\n            if mineru_api_base is None:\n                mineru_api_base = 'https://mineru.net'\n            if upload_folder is None:\n                upload_folder = './uploads'\n        \n        # 验证必需配置\n        if not mineru_token:\n            raise ValueError(\"MinerU token is required. Please configure MINERU_TOKEN.\")\n        \n        from services.file_parser_service import FileParserService\n        \n        # 解析upload_folder路径\n        upload_path = Path(upload_folder)\n        if not upload_path.is_absolute():\n            current_file = Path(__file__).resolve()\n            backend_dir = current_file.parent.parent\n            project_root = backend_dir.parent\n            upload_path = project_root / upload_folder.lstrip('./')\n        \n        logger.info(f\"Upload folder resolved to: {upload_path}\")\n        \n        # 创建MinerU解析服务\n        parser_service = FileParserService(\n            mineru_token=mineru_token,\n            mineru_api_base=mineru_api_base\n        )\n        \n        # 创建提取器注册表\n        extractor_registry = ExtractorRegistry()\n        \n        if use_hybrid_extractor:\n            # 尝试创建混合提取器（MinerU + 百度高精度OCR）\n            hybrid_extractor = ExtractorFactory.create_hybrid_extractor(\n                parser_service=parser_service,\n                upload_folder=upload_path,\n                contain_threshold=kwargs.get('contain_threshold', 0.8),\n                intersection_threshold=kwargs.get('intersection_threshold', 0.3)\n            )\n            \n            if hybrid_extractor:\n                extractor_registry.register_default(hybrid_extractor)\n                logger.info(\"✅ 混合提取器已创建（MinerU + 百度高精度OCR）\")\n            else:\n                # 回退到MinerU\n                mineru_extractor = MinerUElementExtractor(parser_service, upload_path)\n                extractor_registry.register_default(mineru_extractor)\n                logger.warning(\"⚠️ 混合提取器创建失败，回退到MinerU提取器\")\n        else:\n            # 使用纯MinerU提取器\n            mineru_extractor = MinerUElementExtractor(parser_service, upload_path)\n            extractor_registry.register_default(mineru_extractor)\n            logger.info(\"✅ MinerU提取器已创建（通用分割）\")\n        \n        # 创建Inpaint提供者\n        inpaint_registry = InpaintProviderRegistry()\n        \n        # 处理 inpaint_method 参数（优先于 use_hybrid_inpaint）\n        effective_inpaint_method = inpaint_method\n        if effective_inpaint_method is None:\n            # 向后兼容：根据 use_hybrid_inpaint 转换\n            effective_inpaint_method = 'hybrid' if use_hybrid_inpaint else 'generative'\n        \n        logger.info(f\"inpaint_method={effective_inpaint_method}\")\n        \n        if effective_inpaint_method == 'hybrid':\n            # 混合Inpaint提供者（百度修复 + 生成式画质提升）\n            hybrid_inpaint = InpaintProviderFactory.create_hybrid_inpaint_provider(\n                ai_service=ai_service,\n                enhance_quality=kwargs.get('enhance_quality', True)\n            )\n            \n            if hybrid_inpaint:\n                inpaint_registry.register_default(hybrid_inpaint)\n                logger.info(\"✅ 混合Inpaint提供者已创建（百度修复 + 生成式画质提升）\")\n            else:\n                # 回退到纯生成式重绘\n                generative_provider = InpaintProviderFactory.create_generative_edit_provider(\n                    ai_service=ai_service\n                )\n                inpaint_registry.register_default(generative_provider)\n                logger.warning(\"⚠️ 混合Inpaint创建失败，回退到GenerativeEdit\")\n        \n        elif effective_inpaint_method == 'baidu':\n            # 只用百度图像修复（不使用生成式模型，低成本）\n            baidu_inpaint = InpaintProviderFactory.create_baidu_inpaint_provider()\n            \n            if baidu_inpaint:\n                inpaint_registry.register_default(baidu_inpaint)\n                logger.info(\"✅ 百度Inpaint提供者已创建（纯百度修复）\")\n            else:\n                # 回退到生成式\n                generative_provider = InpaintProviderFactory.create_generative_edit_provider(\n                    ai_service=ai_service\n                )\n                inpaint_registry.register_default(generative_provider)\n                logger.warning(\"⚠️ 百度Inpaint创建失败，回退到GenerativeEdit\")\n        \n        else:  # 'generative' 或其他\n            # 使用纯生成式重绘\n            generative_provider = InpaintProviderFactory.create_generative_edit_provider(\n                ai_service=ai_service\n            )\n            inpaint_registry.register_default(generative_provider)\n            logger.info(\"✅ 重绘注册表已创建（GenerativeEdit通用）\")\n        \n        return cls(\n            upload_folder=upload_path,\n            extractor_registry=extractor_registry,\n            inpaint_registry=inpaint_registry,\n            max_depth=kwargs.get('max_depth', 1),\n            min_image_size=kwargs.get('min_image_size', 200),\n            min_image_area=kwargs.get('min_image_area', 40000)\n        )\n\n\nclass TextAttributeExtractorFactory:\n    \"\"\"文字属性提取器工厂\"\"\"\n    \n    @staticmethod\n    def create_caption_model_extractor(\n        ai_service: Optional[Any] = None,\n        prompt_template: Optional[str] = None\n    ) -> TextAttributeExtractor:\n        \"\"\"\n        创建基于Caption Model的文字属性提取器\n        \n        使用视觉语言模型（如Gemini）分析文字区域图像，\n        通过生成JSON的方式获取字体颜色、是否粗体、是否斜体等属性。\n        \n        Args:\n            ai_service: AIService实例（可选，如果不提供则自动获取）\n            prompt_template: 自定义的prompt模板（可选），必须使用 {content_hint} 作为占位符\n        \n        Returns:\n            CaptionModelTextAttributeExtractor实例\n        \n        Raises:\n            如果AI服务初始化失败，会抛出异常\n        \"\"\"\n        if ai_service is None:\n            from services.ai_service_manager import get_ai_service\n            ai_service = get_ai_service()\n        \n        logger.info(\"创建CaptionModelTextAttributeExtractor\")\n        return CaptionModelTextAttributeExtractor(ai_service, prompt_template)\n    \n    @staticmethod\n    def create_text_attribute_registry(\n        caption_extractor: Optional[TextAttributeExtractor] = None,\n        ai_service: Optional[Any] = None\n    ) -> TextAttributeExtractorRegistry:\n        \"\"\"\n        创建文字属性提取器注册表\n        \n        支持动态注册新元素类型，不限于预定义类型。\n        \n        Args:\n            caption_extractor: Caption Model提取器（可选，自动创建）\n            ai_service: AIService实例（可选，用于自动创建提取器）\n        \n        Returns:\n            配置好的TextAttributeExtractorRegistry实例\n        \n        Raises:\n            如果提取器创建失败，会抛出异常\n        \"\"\"\n        # 自动创建提取器\n        if caption_extractor is None:\n            caption_extractor = TextAttributeExtractorFactory.create_caption_model_extractor(\n                ai_service=ai_service\n            )\n        \n        # 创建注册表\n        registry = TextAttributeExtractorRegistry()\n        \n        # 设置默认提取器\n        registry.register_default(caption_extractor)\n        \n        # 注册文本类型\n        registry.register_types(\n            ['text', 'title', 'paragraph', 'heading', 'table_cell'],\n            caption_extractor\n        )\n        \n        logger.info(\"创建TextAttributeExtractorRegistry\")\n        \n        return registry\n\n"
  },
  {
    "path": "backend/services/image_editability/helpers.py",
    "content": "\"\"\"\n辅助函数和工具方法\n\n纯函数，不依赖任何具体实现\n\"\"\"\nimport logging\nimport tempfile\nfrom typing import List\nfrom PIL import Image\n\nfrom .data_models import EditableElement, BBox\n\nlogger = logging.getLogger(__name__)\n\n\ndef collect_bboxes_from_elements(elements: List[EditableElement]) -> List[tuple]:\n    \"\"\"\n    收集当前层级元素的bbox列表（不递归到子元素）\n    \n    Args:\n        elements: 元素列表\n        \n    Returns:\n        bbox元组列表 [(x0, y0, x1, y1), ...]\n    \"\"\"\n    bboxes = []\n    for elem in elements:\n        bbox_tuple = elem.bbox.to_tuple()\n        bboxes.append(bbox_tuple)\n        logger.debug(f\"元素 {elem.element_id} ({elem.element_type}): bbox={bbox_tuple}\")\n    return bboxes\n\n\ndef crop_element_from_image(\n    source_image_path: str,\n    bbox: BBox\n) -> str:\n    \"\"\"\n    从源图片中裁剪出元素区域\n    \n    Args:\n        source_image_path: 源图片路径\n        bbox: 裁剪区域\n        \n    Returns:\n        裁剪后图片的临时文件路径\n    \"\"\"\n    img = Image.open(source_image_path)\n    \n    # 裁剪\n    crop_box = (int(bbox.x0), int(bbox.y0), int(bbox.x1), int(bbox.y1))\n    cropped = img.crop(crop_box)\n    \n    # 保存到临时文件\n    with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:\n        cropped.save(tmp.name)\n        return tmp.name\n\n\ndef should_recurse_into_element(\n    element: EditableElement,\n    parent_image_size: tuple,\n    min_image_size: int,\n    min_image_area: int,\n    max_child_coverage_ratio: float\n) -> bool:\n    \"\"\"\n    判断是否应该对元素进行递归分析\n    \n    Args:\n        element: 待判断的元素\n        parent_image_size: 父图尺寸 (width, height)\n        min_image_size: 最小图片尺寸\n        min_image_area: 最小图片面积\n        max_child_coverage_ratio: 最大子图覆盖比例\n    \"\"\"\n    # 如果已经有子元素（例如表格单元格），不再递归\n    if element.children:\n        logger.debug(f\"  元素 {element.element_id} 已有 {len(element.children)} 个子元素，不递归\")\n        return False\n    \n    # 只对图片和图表类型递归\n    if element.element_type not in ['image', 'figure', 'chart', 'table']:\n        return False\n    \n    # 检查尺寸是否足够大\n    bbox = element.bbox\n    if bbox.width < min_image_size or bbox.height < min_image_size:\n        logger.debug(f\"  元素 {element.element_id} 尺寸过小 ({bbox.width}x{bbox.height})，不递归\")\n        return False\n    \n    if bbox.area < min_image_area:\n        logger.debug(f\"  元素 {element.element_id} 面积过小 ({bbox.area})，不递归\")\n        return False\n    \n    # 检查子图是否占据父图绝大部分面积\n    parent_width, parent_height = parent_image_size\n    parent_area = parent_width * parent_height\n    coverage_ratio = bbox.area / parent_area if parent_area > 0 else 0\n    \n    if coverage_ratio > max_child_coverage_ratio:\n        logger.info(f\"  元素 {element.element_id} 占父图面积 {coverage_ratio*100:.1f}% (>{max_child_coverage_ratio*100:.0f}%)，不递归\")\n        return False\n    \n    return True\n"
  },
  {
    "path": "backend/services/image_editability/hybrid_extractor.py",
    "content": "\"\"\"\n混合元素提取器 - 结合MinerU版面分析和百度高精度OCR的提取策略\n\n工作流程：\n1. MinerU和百度OCR并行识别（提升速度）\n2. 结果合并：\n   - 图片类型bbox里包含的百度OCR bbox → 删除百度OCR bbox\n   - 表格类型bbox里包含的百度OCR bbox → 保留百度OCR bbox，删除MinerU表格bbox\n   - 其他类型bbox与百度OCR bbox有交集 → 使用百度OCR结果，删除MinerU bbox\n\"\"\"\nimport logging\nfrom typing import Dict, Any, List, Optional, Tuple\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom PIL import Image\n\nfrom .extractors import (\n    ElementExtractor, \n    ExtractionResult, \n    ExtractionContext,\n    MinerUElementExtractor,\n    BaiduAccurateOCRElementExtractor\n)\n\nlogger = logging.getLogger(__name__)\n\n\nclass BBoxUtils:\n    \"\"\"边界框工具类\"\"\"\n    \n    @staticmethod\n    def is_contained(inner_bbox: List[float], outer_bbox: List[float], threshold: float = 0.8) -> bool:\n        \"\"\"\n        判断inner_bbox是否被outer_bbox包含\n        \n        Args:\n            inner_bbox: 内部bbox [x0, y0, x1, y1]\n            outer_bbox: 外部bbox [x0, y0, x1, y1]\n            threshold: 包含阈值，inner_bbox有多少比例在outer_bbox内算作包含，默认0.8\n        \n        Returns:\n            是否被包含\n        \"\"\"\n        if not inner_bbox or not outer_bbox:\n            return False\n        \n        ix0, iy0, ix1, iy1 = inner_bbox\n        ox0, oy0, ox1, oy1 = outer_bbox\n        \n        # 计算交集\n        inter_x0 = max(ix0, ox0)\n        inter_y0 = max(iy0, oy0)\n        inter_x1 = min(ix1, ox1)\n        inter_y1 = min(iy1, oy1)\n        \n        if inter_x1 <= inter_x0 or inter_y1 <= inter_y0:\n            return False\n        \n        # 计算交集面积\n        inter_area = (inter_x1 - inter_x0) * (inter_y1 - inter_y0)\n        \n        # 计算inner_bbox面积\n        inner_area = (ix1 - ix0) * (iy1 - iy0)\n        \n        if inner_area <= 0:\n            return False\n        \n        # 判断包含比例\n        return (inter_area / inner_area) >= threshold\n    \n    @staticmethod\n    def has_intersection(bbox1: List[float], bbox2: List[float], min_overlap_ratio: float = 0.1) -> bool:\n        \"\"\"\n        判断两个bbox是否有交集\n        \n        Args:\n            bbox1: 第一个bbox [x0, y0, x1, y1]\n            bbox2: 第二个bbox [x0, y0, x1, y1]\n            min_overlap_ratio: 最小重叠比例（相对于较小bbox的面积），默认0.1\n        \n        Returns:\n            是否有交集\n        \"\"\"\n        if not bbox1 or not bbox2:\n            return False\n        \n        x0_1, y0_1, x1_1, y1_1 = bbox1\n        x0_2, y0_2, x1_2, y1_2 = bbox2\n        \n        # 计算交集\n        inter_x0 = max(x0_1, x0_2)\n        inter_y0 = max(y0_1, y0_2)\n        inter_x1 = min(x1_1, x1_2)\n        inter_y1 = min(y1_1, y1_2)\n        \n        if inter_x1 <= inter_x0 or inter_y1 <= inter_y0:\n            return False\n        \n        # 计算交集面积\n        inter_area = (inter_x1 - inter_x0) * (inter_y1 - inter_y0)\n        \n        # 计算两个bbox的面积\n        area1 = (x1_1 - x0_1) * (y1_1 - y0_1)\n        area2 = (x1_2 - x0_2) * (y1_2 - y0_2)\n        \n        # 取较小面积作为基准\n        min_area = min(area1, area2)\n        \n        if min_area <= 0:\n            return False\n        \n        # 判断重叠比例\n        return (inter_area / min_area) >= min_overlap_ratio\n    \n    @staticmethod\n    def get_intersection_ratio(bbox1: List[float], bbox2: List[float]) -> Tuple[float, float]:\n        \"\"\"\n        计算两个bbox的交集比例\n        \n        Args:\n            bbox1: 第一个bbox\n            bbox2: 第二个bbox\n        \n        Returns:\n            (交集占bbox1的比例, 交集占bbox2的比例)\n        \"\"\"\n        if not bbox1 or not bbox2:\n            return (0.0, 0.0)\n        \n        x0_1, y0_1, x1_1, y1_1 = bbox1\n        x0_2, y0_2, x1_2, y1_2 = bbox2\n        \n        # 计算交集\n        inter_x0 = max(x0_1, x0_2)\n        inter_y0 = max(y0_1, y0_2)\n        inter_x1 = min(x1_1, x1_2)\n        inter_y1 = min(y1_1, y1_2)\n        \n        if inter_x1 <= inter_x0 or inter_y1 <= inter_y0:\n            return (0.0, 0.0)\n        \n        inter_area = (inter_x1 - inter_x0) * (inter_y1 - inter_y0)\n        area1 = (x1_1 - x0_1) * (y1_1 - y0_1)\n        area2 = (x1_2 - x0_2) * (y1_2 - y0_2)\n        \n        ratio1 = inter_area / area1 if area1 > 0 else 0.0\n        ratio2 = inter_area / area2 if area2 > 0 else 0.0\n        \n        return (ratio1, ratio2)\n\n\nclass HybridElementExtractor(ElementExtractor):\n    \"\"\"\n    混合元素提取器\n    \n    结合MinerU版面分析和百度高精度OCR，实现更精确的元素识别：\n    - MinerU负责识别元素类型和整体布局\n    - 百度OCR负责精确的文字识别和定位\n    \n    合并策略：\n    1. 图片类型bbox里包含的百度OCR bbox → 删除（图片内的文字不需要单独提取）\n    2. 表格类型bbox里包含的百度OCR bbox → 保留百度OCR结果，删除MinerU表格bbox\n    3. 其他类型（文字等）与百度OCR bbox有交集 → 使用百度OCR结果，删除MinerU bbox\n    \"\"\"\n    \n    # 元素类型分类\n    IMAGE_TYPES = {'image', 'figure', 'chart', 'diagram'}\n    TABLE_TYPES = {'table', 'table_cell'}\n    TEXT_TYPES = {'text', 'title', 'paragraph', 'header', 'footer', 'list'}\n    \n    def __init__(\n        self,\n        mineru_extractor: MinerUElementExtractor,\n        baidu_ocr_extractor: BaiduAccurateOCRElementExtractor,\n        contain_threshold: float = 0.8,\n        intersection_threshold: float = 0.3\n    ):\n        \"\"\"\n        初始化混合提取器\n        \n        Args:\n            mineru_extractor: MinerU元素提取器\n            baidu_ocr_extractor: 百度高精度OCR提取器\n            contain_threshold: 包含判断阈值，默认0.8（80%面积在内部算包含）\n            intersection_threshold: 交集判断阈值，默认0.3（30%重叠算有交集）\n        \"\"\"\n        self._mineru_extractor = mineru_extractor\n        self._baidu_ocr_extractor = baidu_ocr_extractor\n        self._contain_threshold = contain_threshold\n        self._intersection_threshold = intersection_threshold\n    \n    def supports_type(self, element_type: Optional[str]) -> bool:\n        \"\"\"混合提取器支持所有类型\"\"\"\n        return True\n    \n    def extract(\n        self,\n        image_path: str,\n        element_type: Optional[str] = None,\n        **kwargs\n    ) -> ExtractionResult:\n        \"\"\"\n        从图像中提取元素（混合策略）\n        \n        工作流程：\n        1. 调用MinerU提取器获取版面分析结果\n        2. 调用百度OCR提取器获取文字识别结果\n        3. 合并结果\n        \n        Args:\n            image_path: 图像文件路径\n            element_type: 元素类型提示（可选）\n            **kwargs: 其他参数\n                - depth: 递归深度\n                - language_type: 百度OCR语言类型\n        \n        Returns:\n            合并后的ExtractionResult\n        \"\"\"\n        depth = kwargs.get('depth', 0)\n        indent = '  ' * depth\n        \n        logger.info(f\"{indent}🔀 开始混合提取: {image_path}\")\n        \n        # 1. MinerU版面分析 和 百度高精度OCR 并行执行\n        logger.info(f\"{indent}📄🔤 Step 1: MinerU + 百度OCR 并行识别...\")\n        \n        mineru_result = None\n        baidu_result = None\n        mineru_error = None\n        baidu_error = None\n\n        def run_mineru():\n            return self._mineru_extractor.extract(image_path, element_type, **kwargs)\n\n        def run_baidu_ocr():\n            return self._baidu_ocr_extractor.extract(image_path, element_type, **kwargs)\n\n        with ThreadPoolExecutor(max_workers=2) as executor:\n            future_mineru = executor.submit(run_mineru)\n            future_baidu = executor.submit(run_baidu_ocr)\n\n            # 等待两个任务完成\n            for future in as_completed([future_mineru, future_baidu]):\n                try:\n                    if future == future_mineru:\n                        mineru_result = future.result()\n                        # 检查结果是否带有错误\n                        if mineru_result.has_error:\n                            mineru_error = mineru_result.error\n                            logger.error(f\"{indent}  ❌ MinerU提取错误: {mineru_error}\")\n                        else:\n                            logger.info(f\"{indent}  ✅ MinerU识别到 {len(mineru_result.elements)} 个元素\")\n                    else:\n                        baidu_result = future.result()\n                        if baidu_result.has_error:\n                            baidu_error = baidu_result.error\n                            logger.error(f\"{indent}  ❌ 百度OCR提取错误: {baidu_error}\")\n                        else:\n                            logger.info(f\"{indent}  ✅ 百度OCR识别到 {len(baidu_result.elements)} 个元素\")\n                except Exception as e:\n                    if future == future_mineru:\n                        mineru_error = str(e)\n                        logger.error(f\"{indent}  ❌ MinerU提取失败: {e}\")\n                    else:\n                        baidu_error = str(e)\n                        logger.error(f\"{indent}  ❌ 百度OCR提取失败: {e}\")\n\n        # 确保两个结果都存在（即使有错误也创建空结果以便继续合并）\n        if mineru_result is None:\n            mineru_result = ExtractionResult(elements=[], error=mineru_error)\n        if baidu_result is None:\n            baidu_result = ExtractionResult(elements=[], error=baidu_error)\n        \n        mineru_elements = mineru_result.elements\n        baidu_elements = baidu_result.elements\n\n        # 2. 合并结果\n        logger.info(f\"{indent}🔧 Step 2: 合并结果...\")\n        merged_elements = self._merge_results(mineru_elements, baidu_elements, depth)\n        logger.info(f\"{indent}  合并后共 {len(merged_elements)} 个元素\")\n\n        # 合并错误信息\n        errors = []\n        if mineru_result.has_error:\n            errors.append(f\"MinerU: {mineru_result.error}\")\n        if baidu_result.has_error:\n            errors.append(f\"百度OCR: {baidu_result.error}\")\n        combined_error = \"; \".join(errors) if errors else None\n\n        # 合并上下文\n        context = ExtractionContext(\n            result_dir=mineru_result.context.result_dir,\n            metadata={\n                'source': 'hybrid',\n                'mineru_count': len(mineru_elements),\n                'baidu_count': len(baidu_elements),\n                'merged_count': len(merged_elements),\n                'mineru_error': mineru_result.error,\n                'baidu_error': baidu_result.error,\n                **mineru_result.context.metadata\n            }\n        )\n\n        return ExtractionResult(elements=merged_elements, context=context, error=combined_error)\n    \n    def _merge_results(\n        self,\n        mineru_elements: List[Dict[str, Any]],\n        baidu_elements: List[Dict[str, Any]],\n        depth: int = 0\n    ) -> List[Dict[str, Any]]:\n        \"\"\"\n        合并MinerU和百度OCR的结果\n        \n        合并规则：\n        1. 图片类型bbox里包含的百度OCR bbox → 删除百度OCR bbox\n        2. 表格类型bbox里包含的百度OCR bbox → 保留百度OCR bbox，删除MinerU表格bbox\n        3. 其他类型与百度OCR bbox有交集 → 使用百度OCR结果，删除MinerU bbox\n        \n        Args:\n            mineru_elements: MinerU识别的元素列表\n            baidu_elements: 百度OCR识别的元素列表\n            depth: 递归深度（用于日志）\n        \n        Returns:\n            合并后的元素列表\n        \"\"\"\n        indent = '  ' * depth\n        \n        # 分类MinerU元素\n        image_elements = []\n        table_elements = []\n        other_elements = []\n        \n        for elem in mineru_elements:\n            elem_type = elem.get('type', '')\n            if elem_type in self.IMAGE_TYPES:\n                image_elements.append(elem)\n            elif elem_type in self.TABLE_TYPES:\n                table_elements.append(elem)\n            else:\n                other_elements.append(elem)\n        \n        logger.info(f\"{indent}  MinerU分类: 图片={len(image_elements)}, 表格={len(table_elements)}, 其他={len(other_elements)}\")\n        \n        # 标记需要保留/删除的百度OCR元素\n        baidu_to_keep = set(range(len(baidu_elements)))  # 初始全部保留\n        baidu_in_table = set()  # 在表格内的百度OCR元素\n        \n        # 规则1: 图片类型bbox里包含的百度OCR bbox → 删除\n        for img_elem in image_elements:\n            img_bbox = img_elem.get('bbox', [])\n            for idx, baidu_elem in enumerate(baidu_elements):\n                baidu_bbox = baidu_elem.get('bbox', [])\n                if BBoxUtils.is_contained(baidu_bbox, img_bbox, self._contain_threshold):\n                    baidu_to_keep.discard(idx)\n                    logger.debug(f\"{indent}    百度OCR[{idx}]被图片包含，删除\")\n        \n        # 规则2: 表格类型bbox里包含的百度OCR bbox → 保留，并标记\n        tables_to_remove = set()\n        for table_idx, table_elem in enumerate(table_elements):\n            table_bbox = table_elem.get('bbox', [])\n            has_contained_text = False\n            for idx, baidu_elem in enumerate(baidu_elements):\n                baidu_bbox = baidu_elem.get('bbox', [])\n                if BBoxUtils.is_contained(baidu_bbox, table_bbox, self._contain_threshold):\n                    baidu_in_table.add(idx)\n                    has_contained_text = True\n                    logger.debug(f\"{indent}    百度OCR[{idx}]在表格内，保留\")\n            \n            if has_contained_text:\n                tables_to_remove.add(table_idx)\n                logger.debug(f\"{indent}    表格[{table_idx}]有文字，删除表格bbox\")\n        \n        # 规则3: 其他类型与百度OCR bbox有交集 → 使用百度OCR结果\n        other_to_remove = set()\n        for other_idx, other_elem in enumerate(other_elements):\n            other_bbox = other_elem.get('bbox', [])\n            for idx, baidu_elem in enumerate(baidu_elements):\n                if idx not in baidu_to_keep:\n                    continue\n                baidu_bbox = baidu_elem.get('bbox', [])\n                if BBoxUtils.has_intersection(other_bbox, baidu_bbox, self._intersection_threshold):\n                    other_to_remove.add(other_idx)\n                    logger.debug(f\"{indent}    MinerU其他[{other_idx}]与百度OCR[{idx}]有交集，使用百度OCR\")\n                    break\n        \n        # 构建最终结果\n        merged = []\n        \n        # 添加图片元素（全部保留）\n        for elem in image_elements:\n            elem_copy = elem.copy()\n            elem_copy['metadata'] = elem_copy.get('metadata', {}).copy()\n            elem_copy['metadata']['source'] = 'mineru'\n            merged.append(elem_copy)\n        \n        # 添加表格元素（删除有文字的表格bbox）\n        for idx, elem in enumerate(table_elements):\n            if idx not in tables_to_remove:\n                elem_copy = elem.copy()\n                elem_copy['metadata'] = elem_copy.get('metadata', {}).copy()\n                elem_copy['metadata']['source'] = 'mineru'\n                merged.append(elem_copy)\n        \n        # 添加其他MinerU元素（删除与百度OCR有交集的）\n        for idx, elem in enumerate(other_elements):\n            if idx not in other_to_remove:\n                elem_copy = elem.copy()\n                elem_copy['metadata'] = elem_copy.get('metadata', {}).copy()\n                elem_copy['metadata']['source'] = 'mineru'\n                merged.append(elem_copy)\n        \n        # 添加保留的百度OCR元素\n        for idx in baidu_to_keep:\n            elem = baidu_elements[idx]\n            elem_copy = elem.copy()\n            elem_copy['metadata'] = elem_copy.get('metadata', {}).copy()\n            elem_copy['metadata']['source'] = 'baidu_ocr'\n            if idx in baidu_in_table:\n                elem_copy['metadata']['in_table'] = True\n            merged.append(elem_copy)\n        \n        logger.info(f\"{indent}  合并结果: 保留图片={len(image_elements)}, \"\n                   f\"保留表格={len(table_elements) - len(tables_to_remove)}, \"\n                   f\"保留MinerU其他={len(other_elements) - len(other_to_remove)}, \"\n                   f\"保留百度OCR={len(baidu_to_keep)}\")\n        \n        return merged\n\n\ndef create_hybrid_extractor(\n    mineru_extractor: Optional[MinerUElementExtractor] = None,\n    baidu_ocr_extractor: Optional[BaiduAccurateOCRElementExtractor] = None,\n    parser_service: Optional[Any] = None,\n    upload_folder: Optional[Any] = None,\n    contain_threshold: float = 0.8,\n    intersection_threshold: float = 0.3\n) -> Optional[HybridElementExtractor]:\n    \"\"\"\n    创建混合元素提取器\n    \n    Args:\n        mineru_extractor: MinerU提取器（可选，自动创建）\n        baidu_ocr_extractor: 百度OCR提取器（可选，自动创建）\n        parser_service: FileParserService实例（用于创建MinerU提取器）\n        upload_folder: 上传文件夹路径（用于创建MinerU提取器）\n        contain_threshold: 包含判断阈值\n        intersection_threshold: 交集判断阈值\n    \n    Returns:\n        HybridElementExtractor实例，如果无法创建则返回None\n    \"\"\"\n    from pathlib import Path\n    \n    # 创建MinerU提取器\n    if mineru_extractor is None:\n        if parser_service is None or upload_folder is None:\n            logger.error(\"创建混合提取器需要提供 parser_service 和 upload_folder，或者直接提供 mineru_extractor\")\n            return None\n        \n        if isinstance(upload_folder, str):\n            upload_folder = Path(upload_folder)\n        \n        mineru_extractor = MinerUElementExtractor(parser_service, upload_folder)\n        logger.info(\"✅ MinerU提取器已创建\")\n    \n    # 创建百度OCR提取器\n    if baidu_ocr_extractor is None:\n        try:\n            from services.ai_providers.ocr import create_baidu_accurate_ocr_provider\n            baidu_provider = create_baidu_accurate_ocr_provider()\n            if baidu_provider is None:\n                logger.warning(\"无法创建百度高精度OCR Provider\")\n                return None\n            baidu_ocr_extractor = BaiduAccurateOCRElementExtractor(baidu_provider)\n            logger.info(\"✅ 百度高精度OCR提取器已创建\")\n        except Exception as e:\n            logger.error(f\"创建百度高精度OCR提取器失败: {e}\")\n            return None\n    \n    return HybridElementExtractor(\n        mineru_extractor=mineru_extractor,\n        baidu_ocr_extractor=baidu_ocr_extractor,\n        contain_threshold=contain_threshold,\n        intersection_threshold=intersection_threshold\n    )\n\n"
  },
  {
    "path": "backend/services/image_editability/inpaint_providers.py",
    "content": "\"\"\"\nInpaint提供者 - 抽象不同的inpaint实现\n\n提供多种重绘方法：\n1. DefaultInpaintProvider - 基于mask的精确区域重绘（使用Volcengine Inpainting服务）\n2. GenerativeEditInpaintProvider - 基于生成式大模型的整图编辑重绘（如Gemini图片编辑）\n3. BaiduInpaintProvider - 基于百度图像修复API的区域重绘\n4. HybridInpaintProvider - 混合方法：先百度修复去除文字，再生成式提升画质\n\n以及注册表：\n- InpaintProviderRegistry - 元素类型到重绘方法的映射注册表\n\"\"\"\nimport logging\nimport tempfile\nfrom abc import ABC, abstractmethod\nfrom typing import List, Optional, Dict\nfrom PIL import Image\n\nfrom utils.mask_utils import create_mask_from_bboxes\n\nlogger = logging.getLogger(__name__)\n\n\nclass InpaintProvider(ABC):\n    \"\"\"\n    Inpaint提供者抽象接口\n    \n    用于抽象不同的inpaint方法，支持接入多种实现：\n    - 基于InpaintingService的实现（当前默认）\n    - Gemini API实现\n    - SD/SDXL等其他模型实现\n    - 第三方API实现\n    \"\"\"\n    \n    @abstractmethod\n    def inpaint_regions(\n        self,\n        image: Image.Image,\n        bboxes: List[tuple],\n        types: Optional[List[str]] = None,\n        **kwargs\n    ) -> Optional[Image.Image]:\n        \"\"\"\n        对图像中指定区域进行inpaint处理\n        \n        Args:\n            image: 原始PIL图像对象\n            bboxes: 边界框列表，每个bbox格式为 (x0, y0, x1, y1)\n            types: 可选的元素类型列表，与bboxes一一对应（如 'text', 'image', 'table'等）\n            **kwargs: 其他由具体实现自定义的参数\n        \n        Returns:\n            处理后的PIL图像对象，失败返回None\n        \"\"\"\n        pass\n\n\nclass DefaultInpaintProvider(InpaintProvider):\n    \"\"\"\n    基于InpaintingService的默认Inpaint提供者\n    \n    这是当前系统使用的实现，调用已有的InpaintingService\n    \"\"\"\n    \n    def __init__(self, inpainting_service):\n        \"\"\"\n        初始化默认Inpaint提供者\n        \n        Args:\n            inpainting_service: InpaintingService实例\n        \"\"\"\n        self.inpainting_service = inpainting_service\n    \n    def inpaint_regions(\n        self,\n        image: Image.Image,\n        bboxes: List[tuple],\n        types: Optional[List[str]] = None,\n        **kwargs\n    ) -> Optional[Image.Image]:\n        \"\"\"\n        使用InpaintingService处理inpaint\n        \n        支持的kwargs参数：\n        - expand_pixels: int, 扩展像素数，默认10\n        - merge_bboxes: bool, 是否合并bbox，默认False\n        - merge_threshold: int, 合并阈值，默认20\n        - save_mask_path: str, mask保存路径，可选\n        - full_page_image: Image.Image, 完整页面图像（用于Gemini），可选\n        - crop_box: tuple, 裁剪框 (x0, y0, x1, y1)，可选\n        \"\"\"\n        expand_pixels = kwargs.get('expand_pixels', 10)\n        merge_bboxes = kwargs.get('merge_bboxes', False)\n        merge_threshold = kwargs.get('merge_threshold', 20)\n        save_mask_path = kwargs.get('save_mask_path')\n        full_page_image = kwargs.get('full_page_image')\n        crop_box = kwargs.get('crop_box')\n        \n        try:\n            result_img = self.inpainting_service.remove_regions_by_bboxes(\n                image=image,\n                bboxes=bboxes,\n                expand_pixels=expand_pixels,\n                merge_bboxes=merge_bboxes,\n                merge_threshold=merge_threshold,\n                save_mask_path=save_mask_path,\n                full_page_image=full_page_image,\n                crop_box=crop_box\n            )\n            return result_img\n        except Exception as e:\n            logger.error(f\"DefaultInpaintProvider处理失败: {e}\", exc_info=True)\n            return None\n\n\nclass GenerativeEditInpaintProvider(InpaintProvider):\n    \"\"\"\n    基于生成式大模型图片编辑的Inpaint提供者\n    \n    使用生成式大模型（如Gemini的图片编辑功能）通过自然语言指令移除图片中的文字、图标等元素。\n    \n    与DefaultInpaintProvider的区别：\n    - DefaultInpaintProvider: 基于mask的精确区域重绘（需要准确的bbox）\n    - GenerativeEditInpaintProvider: 整图生成式编辑（通过prompt描述要移除的内容）\n    \n    优点：不需要精确的bbox，大模型自动理解并移除相关元素\n    缺点：可能改变背景细节，生成速度较慢，消耗更多token\n    \n    适用场景：\n    - bbox不够精确时\n    - 需要移除复杂或分散的元素时\n    - 作为mask-based方法的备选方案\n    \"\"\"\n    \n    def __init__(self, ai_service, aspect_ratio: str = \"16:9\", resolution: str = \"2K\"):\n        \"\"\"\n        初始化生成式编辑Inpaint提供者\n        \n        Args:\n            ai_service: AIService实例（需要支持edit_image方法）\n            aspect_ratio: 目标宽高比\n            resolution: 目标分辨率\n        \"\"\"\n        self.ai_service = ai_service\n        self.aspect_ratio = aspect_ratio\n        self.resolution = resolution\n    \n    def inpaint_regions(\n        self,\n        image: Image.Image,\n        bboxes: List[tuple],\n        types: Optional[List[str]] = None,\n        **kwargs\n    ) -> Optional[Image.Image]:\n        \"\"\"\n        使用生成式大模型编辑生成干净背景\n        \n        注意：此方法忽略bboxes参数，通过大模型自动识别并移除所有文字和图标\n        \n        支持的kwargs参数：\n        - aspect_ratio: str, 宽高比，默认使用初始化时的值\n        - resolution: str, 分辨率，默认使用初始化时的值\n        \"\"\"\n        aspect_ratio = kwargs.get('aspect_ratio', self.aspect_ratio)\n        resolution = kwargs.get('resolution', self.resolution)\n        \n        try:\n            from services.prompts import get_clean_background_prompt\n            \n            # 获取清理背景的prompt\n            edit_instruction = get_clean_background_prompt()\n            \n            # 保存临时图片文件（AI服务需要文件路径）\n            with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp_file:\n                tmp_path = tmp_file.name\n                image.save(tmp_path)\n            \n            logger.info(\"GenerativeEditInpaintProvider: 开始生成式编辑重绘...\")\n            \n            # 调用AI服务编辑图片\n            clean_bg_image = self.ai_service.edit_image(\n                prompt=edit_instruction,\n                current_image_path=tmp_path,\n                aspect_ratio=aspect_ratio,\n                resolution=resolution,\n                original_description=None,\n                additional_ref_images=None\n            )\n            \n            if not clean_bg_image:\n                logger.error(\"GenerativeEditInpaintProvider: 生成式编辑返回空结果\")\n                return None\n            \n            # 转换为PIL Image\n            if not isinstance(clean_bg_image, Image.Image):\n                # Google GenAI返回自己的Image类型，需要提取_pil_image\n                if hasattr(clean_bg_image, '_pil_image'):\n                    clean_bg_image = clean_bg_image._pil_image\n                else:\n                    logger.error(f\"GenerativeEditInpaintProvider: 未知的图片类型: {type(clean_bg_image)}\")\n                    return None\n            \n            logger.info(\"GenerativeEditInpaintProvider: 重绘完成\")\n            return clean_bg_image\n        \n        except Exception as e:\n            logger.error(f\"GenerativeEditInpaintProvider处理失败: {e}\", exc_info=True)\n            return None\n\n\nclass BaiduInpaintProvider(InpaintProvider):\n    \"\"\"\n    基于百度图像修复API的Inpaint提供者\n    \n    使用百度AI在指定矩形区域去除遮挡物并用背景内容填充。\n    \n    特点：\n    - 基于bbox的精确区域修复\n    - 快速响应，使用背景内容智能填充\n    - 适合去除文字、水印等规则区域\n    \n    注意：修复质量可能不如生成式模型，但速度快且稳定\n    \"\"\"\n    \n    def __init__(self, baidu_inpainting_provider):\n        \"\"\"\n        初始化百度图像修复提供者\n        \n        Args:\n            baidu_inpainting_provider: BaiduInpaintingProvider实例（来自ai_providers.image）\n        \"\"\"\n        self._provider = baidu_inpainting_provider\n    \n    def inpaint_regions(\n        self,\n        image: Image.Image,\n        bboxes: List[tuple],\n        types: Optional[List[str]] = None,\n        **kwargs\n    ) -> Optional[Image.Image]:\n        \"\"\"\n        使用百度图像修复API处理指定区域\n        \n        支持的kwargs参数：\n        - expand_pixels: int, 扩展像素数，默认2\n        \"\"\"\n        expand_pixels = kwargs.get('expand_pixels', 2)\n        \n        try:\n            logger.info(f\"BaiduInpaintProvider: 开始修复 {len(bboxes)} 个区域...\")\n            \n            result_image = self._provider.inpaint_bboxes(\n                image=image,\n                bboxes=bboxes,\n                expand_pixels=expand_pixels\n            )\n            \n            if result_image:\n                logger.info(\"BaiduInpaintProvider: 修复完成\")\n            else:\n                logger.warning(\"BaiduInpaintProvider: 修复返回空结果\")\n                return None\n            \n            # 合并原图和修复后的图片，只取bboxes区域的修复结果（不扩展，避免影响bbox外的区域）\n            mask = create_mask_from_bboxes(image.size, bboxes, expand_pixels=0)\n            return Image.composite(result_image, image, mask.convert('L'))\n        \n        except Exception as e:\n            logger.error(f\"BaiduInpaintProvider处理失败: {e}\", exc_info=True)\n            return None\n\n\nclass HybridInpaintProvider(InpaintProvider):\n    \"\"\"\n    混合Inpaint提供者 - 百度修复 + 生成式画质提升\n    \n    工作流程：\n    1. 先使用百度图像修复API去除指定区域的内容（如文字、水印）\n    2. 再使用生成式大模型（如Gemini）提升整体画质，保持内容不变\n    \n    优点：\n    - 百度修复快速精确地去除文字，不会遗漏\n    - 生成式模型提升画质，使修复痕迹更自然\n    \n    适用场景：\n    - 需要精确去除文字且保证高画质的场景\n    - 单独使用生成式模型容易遗漏文字的情况\n    \"\"\"\n    \n    def __init__(\n        self,\n        baidu_provider: BaiduInpaintProvider,\n        generative_provider: 'GenerativeEditInpaintProvider',\n        enhance_quality: bool = True\n    ):\n        \"\"\"\n        初始化混合Inpaint提供者\n        \n        Args:\n            baidu_provider: 百度图像修复提供者\n            generative_provider: 生成式编辑提供者（用于画质提升）\n            enhance_quality: 是否在百度修复后使用生成式模型提升画质，默认True\n        \"\"\"\n        self._baidu_provider = baidu_provider\n        self._generative_provider = generative_provider\n        self._enhance_quality = enhance_quality\n    \n    def inpaint_regions(\n        self,\n        image: Image.Image,\n        bboxes: List[tuple],\n        types: Optional[List[str]] = None,\n        **kwargs\n    ) -> Optional[Image.Image]:\n        \"\"\"\n        混合处理：先百度修复，再生成式画质提升\n        \n        支持的kwargs参数：\n        - expand_pixels: int, 百度修复的扩展像素数，默认2\n        - enhance_quality: bool, 是否提升画质，默认使用初始化时的值\n        - aspect_ratio: str, 画质提升的宽高比\n        - resolution: str, 画质提升的分辨率\n        \"\"\"\n        expand_pixels = kwargs.get('expand_pixels', 2)\n        enhance_quality = kwargs.get('enhance_quality', self._enhance_quality)\n        \n        try:\n            # Step 1: 百度图像修复 - 精确去除文字\n            logger.info(f\"HybridInpaintProvider Step 1: 百度修复 {len(bboxes)} 个区域...\")\n            \n            repaired_image = self._baidu_provider.inpaint_regions(\n                image=image,\n                bboxes=bboxes,\n                types=types,\n                expand_pixels=expand_pixels\n            )\n            \n            if repaired_image is None:\n                logger.error(\"HybridInpaintProvider: 百度修复失败\")\n                return None\n            \n            logger.info(\"HybridInpaintProvider: 百度修复完成\")\n            \n            # Step 2: 生成式画质提升（可选）\n            if enhance_quality and self._generative_provider:\n                logger.info(\"HybridInpaintProvider Step 2: 生成式画质提升...\")\n                \n                # 使用专门的画质提升prompt，传入被修复的区域信息\n                enhanced_image = self._enhance_image_quality(\n                    repaired_image,\n                    inpainted_bboxes=bboxes,  # 传入被修复的区域\n                    aspect_ratio=kwargs.get('aspect_ratio'),\n                    resolution=kwargs.get('resolution')\n                )\n                \n                if enhanced_image:\n                    logger.info(\"HybridInpaintProvider: 画质提升完成\")\n                    return enhanced_image\n                else:\n                    logger.warning(\"HybridInpaintProvider: 画质提升失败，返回百度修复结果\")\n                    return repaired_image\n            else:\n                logger.info(\"HybridInpaintProvider: 跳过画质提升\")\n                return repaired_image\n        \n        except Exception as e:\n            logger.error(f\"HybridInpaintProvider处理失败: {e}\", exc_info=True)\n            return None\n    \n    def _enhance_image_quality(\n        self,\n        image: Image.Image,\n        inpainted_bboxes: Optional[List[tuple]] = None,\n        aspect_ratio: Optional[str] = None,\n        resolution: Optional[str] = None\n    ) -> Optional[Image.Image]:\n        \"\"\"\n        使用生成式模型提升图像画质\n        \n        Args:\n            image: 需要提升画质的图像\n            inpainted_bboxes: 被修复区域的bbox列表，格式为 [(x0, y0, x1, y1), ...]\n            aspect_ratio: 宽高比（可选）\n            resolution: 分辨率（可选）\n        \n        Returns:\n            提升画质后的图像\n        \"\"\"\n        try:\n            # 保存临时图片\n            with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp_file:\n                tmp_path = tmp_file.name\n                image.save(tmp_path)\n            \n            # 将bboxes转换为百分比形式（相对于图片宽高）\n            regions = None\n            if inpainted_bboxes:\n                # 先合并上下间距很小的bbox（减少传递给生成式模型的区域数量）\n                from utils.mask_utils import merge_vertical_nearby_bboxes\n                original_count = len(inpainted_bboxes)\n                merged_bboxes = merge_vertical_nearby_bboxes(inpainted_bboxes)\n                if len(merged_bboxes) < original_count:\n                    logger.info(f\"合并相邻文字行后：{original_count} -> {len(merged_bboxes)} 个区域\")\n                \n                img_width, img_height = image.size\n                regions = []\n                for bbox in merged_bboxes:\n                    x0, y0, x1, y1 = bbox\n                    # 转换为百分比（0-100）\n                    regions.append({\n                        'left': round(x0 / img_width * 100, 1),\n                        'top': round(y0 / img_height * 100, 1),\n                        'right': round(x1 / img_width * 100, 1),\n                        'bottom': round(y1 / img_height * 100, 1),\n                        'width_percent': round((x1 - x0) / img_width * 100, 1),\n                        'height_percent': round((y1 - y0) / img_height * 100, 1)\n                    })\n                logger.info(f\"传递 {len(regions)} 个被修复区域给生成式模型（百分比坐标）\")\n            \n            # 获取画质提升的prompt（包含被修复区域信息）\n            from services.prompts import get_quality_enhancement_prompt\n            enhance_prompt = get_quality_enhancement_prompt(inpainted_regions=regions)\n            \n            # 使用AI服务的aspect_ratio和resolution（如果提供）\n            ar = aspect_ratio or self._generative_provider.aspect_ratio\n            res = resolution or self._generative_provider.resolution\n            \n            # 调用AI服务\n            enhanced_image = self._generative_provider.ai_service.edit_image(\n                prompt=enhance_prompt,\n                current_image_path=tmp_path,\n                aspect_ratio=ar,\n                resolution=res,\n                original_description=None,\n                additional_ref_images=None\n            )\n            \n            if not enhanced_image:\n                return None\n            \n            # 转换为PIL Image\n            if not isinstance(enhanced_image, Image.Image):\n                if hasattr(enhanced_image, '_pil_image'):\n                    enhanced_image = enhanced_image._pil_image\n                else:\n                    logger.error(f\"未知的图片类型: {type(enhanced_image)}\")\n                    return None\n            \n            return enhanced_image\n        \n        except Exception as e:\n            logger.error(f\"画质提升失败: {e}\", exc_info=True)\n            return None\n\n\nclass InpaintProviderRegistry:\n    \"\"\"\n    元素类型到重绘方法的映射注册表\n    \n    根据元素类型选择合适的重绘方法：\n    - 文本元素 → DefaultInpaintProvider（mask-based精确移除）\n    - 表格元素 → DefaultInpaintProvider（保持表格框架）\n    - 图片/图表元素 → GenerativeEditInpaintProvider（整图重绘）\n    - 其他类型 → 默认提供者\n    \n    使用方式：\n        >>> registry = InpaintProviderRegistry()\n        >>> registry.register('text', mask_provider)\n        >>> registry.register('image', generative_provider)\n        >>> registry.register_default(mask_provider)\n        >>> \n        >>> provider = registry.get_provider('text')  # 返回 mask_provider\n        >>> provider = registry.get_provider('chart')  # 返回 generative_provider\n    \"\"\"\n    \n    # 预定义的元素类型分组\n    TEXT_TYPES = {'text', 'title', 'paragraph', 'header', 'footer', 'list'}\n    TABLE_TYPES = {'table', 'table_cell'}\n    IMAGE_TYPES = {'image', 'figure', 'chart', 'diagram'}\n    \n    def __init__(self):\n        \"\"\"初始化注册表\"\"\"\n        self._type_mapping: Dict[str, InpaintProvider] = {}\n        self._default_provider: Optional[InpaintProvider] = None\n    \n    def register(self, element_type: str, provider: InpaintProvider) -> 'InpaintProviderRegistry':\n        \"\"\"\n        注册元素类型到重绘方法的映射\n        \n        Args:\n            element_type: 元素类型（如 'text', 'image' 等）\n            provider: 对应的重绘提供者实例\n        \n        Returns:\n            self，支持链式调用\n        \"\"\"\n        self._type_mapping[element_type] = provider\n        logger.debug(f\"注册重绘提供者: {element_type} -> {provider.__class__.__name__}\")\n        return self\n    \n    def register_types(self, element_types: List[str], provider: InpaintProvider) -> 'InpaintProviderRegistry':\n        \"\"\"\n        批量注册多个元素类型到同一个重绘方法\n        \n        Args:\n            element_types: 元素类型列表\n            provider: 对应的重绘提供者实例\n        \n        Returns:\n            self，支持链式调用\n        \"\"\"\n        for t in element_types:\n            self.register(t, provider)\n        return self\n    \n    def register_default(self, provider: InpaintProvider) -> 'InpaintProviderRegistry':\n        \"\"\"\n        注册默认重绘方法（当没有特定类型映射时使用）\n        \n        Args:\n            provider: 默认重绘提供者实例\n        \n        Returns:\n            self，支持链式调用\n        \"\"\"\n        self._default_provider = provider\n        logger.debug(f\"注册默认重绘提供者: {provider.__class__.__name__}\")\n        return self\n    \n    def get_provider(self, element_type: Optional[str]) -> Optional[InpaintProvider]:\n        \"\"\"\n        根据元素类型获取对应的重绘方法\n        \n        Args:\n            element_type: 元素类型，None表示使用默认提供者\n        \n        Returns:\n            对应的重绘提供者，如果没有注册则返回默认提供者\n        \"\"\"\n        if element_type is None:\n            return self._default_provider\n        \n        # 先查找精确匹配\n        if element_type in self._type_mapping:\n            return self._type_mapping[element_type]\n        \n        # 返回默认提供者\n        return self._default_provider\n    \n    def get_all_providers(self) -> List[InpaintProvider]:\n        \"\"\"\n        获取所有已注册的重绘提供者（去重）\n        \n        Returns:\n            重绘提供者列表\n        \"\"\"\n        providers = list(set(self._type_mapping.values()))\n        if self._default_provider and self._default_provider not in providers:\n            providers.append(self._default_provider)\n        return providers\n    \n    @classmethod\n    def create_default(\n        cls,\n        mask_provider: Optional[InpaintProvider] = None,\n        generative_provider: Optional[InpaintProvider] = None\n    ) -> 'InpaintProviderRegistry':\n        \"\"\"\n        创建默认配置的注册表\n        \n        默认配置：\n        - 文本类型 → mask-based（精确移除文字区域）\n        - 表格类型 → mask-based（保持表格框架，只移除单元格内容）\n        - 图片/图表类型 → generative（整图重绘，处理复杂图形）\n        - 其他类型 → mask-based（默认）\n        \n        Args:\n            mask_provider: 基于mask的重绘提供者（DefaultInpaintProvider）\n            generative_provider: 生成式重绘提供者（GenerativeEditInpaintProvider）\n        \n        Returns:\n            配置好的注册表实例\n        \"\"\"\n        registry = cls()\n        \n        # 如果没有提供任何provider，返回空注册表\n        if not mask_provider and not generative_provider:\n            logger.warning(\"创建InpaintProviderRegistry时未提供任何provider\")\n            return registry\n        \n        # 设置默认提供者（优先使用mask_provider）\n        default_provider = mask_provider or generative_provider\n        registry.register_default(default_provider)\n        \n        # 文本类型使用mask-based\n        if mask_provider:\n            registry.register_types(list(cls.TEXT_TYPES), mask_provider)\n            registry.register_types(list(cls.TABLE_TYPES), mask_provider)\n        \n        # 图片类型使用generative（如果可用），否则使用mask-based\n        image_provider = generative_provider or mask_provider\n        if image_provider:\n            registry.register_types(list(cls.IMAGE_TYPES), image_provider)\n        \n        logger.info(f\"创建默认InpaintProviderRegistry: \"\n                   f\"文本/表格->{mask_provider.__class__.__name__ if mask_provider else 'None'}, \"\n                   f\"图片->{image_provider.__class__.__name__ if image_provider else 'None'}\")\n        \n        return registry\n\n"
  },
  {
    "path": "backend/services/image_editability/service.py",
    "content": "\"\"\"\n图片可编辑化服务 - 核心服务类\n\n设计原则：\n1. 无状态设计 - 线程安全，可并行调用\n2. 单一职责 - 只负责单张图片的可编辑化\n3. 依赖注入 - 通过配置对象注入所有依赖\n4. 零具体实现依赖 - 完全依赖抽象接口\n\"\"\"\nimport logging\nimport uuid\nfrom typing import List, Optional, Tuple\nfrom PIL import Image\n\nfrom .data_models import BBox, EditableElement, EditableImage\nfrom .coordinate_mapper import CoordinateMapper\nfrom .extractors import ElementExtractor, ExtractionResult\nfrom .inpaint_providers import InpaintProvider\nfrom .factories import ServiceConfig\nfrom .helpers import collect_bboxes_from_elements, should_recurse_into_element, crop_element_from_image\n\nlogger = logging.getLogger(__name__)\n\n\nclass ImageEditabilityService:\n    \"\"\"\n    图片可编辑化服务\n    \n    线程安全的无状态服务，可并行调用 make_image_editable()\n    完全依赖抽象接口，不知道任何具体实现细节\n    \n    Example:\n        >>> config = ServiceConfig.from_defaults(mineru_token=\"xxx\")\n        >>> service = ImageEditabilityService(config)\n        >>> \n        >>> # 串行处理\n        >>> result = service.make_image_editable(\"image.png\")\n        >>> \n        >>> # 并行处理（由调用者控制）\n        >>> from concurrent.futures import ThreadPoolExecutor\n        >>> with ThreadPoolExecutor() as executor:\n        ...     futures = [executor.submit(service.make_image_editable, img) \n        ...                for img in image_paths]\n        ...     results = [f.result() for f in futures]\n    \"\"\"\n    \n    def __init__(self, config: ServiceConfig):\n        \"\"\"\n        初始化服务\n        \n        Args:\n            config: ServiceConfig配置对象，包含所有依赖\n        \"\"\"\n        # 只读配置，线程安全\n        self._upload_folder = config.upload_folder\n        self._extractor_registry = config.extractor_registry\n        self._inpaint_registry = config.inpaint_registry\n        self._max_depth = config.max_depth\n        self._min_image_size = config.min_image_size\n        self._min_image_area = config.min_image_area\n        self._max_child_coverage_ratio = 0.85\n        \n        extractors = self._extractor_registry.get_all_extractors()\n        inpaint_providers = self._inpaint_registry.get_all_providers()\n        logger.info(\n            f\"ImageEditabilityService: {len(extractors)} extractors, \"\n            f\"{len(inpaint_providers)} inpaint providers, \"\n            f\"max_depth={self._max_depth}\"\n        )\n    \n    def make_image_editable(\n        self,\n        image_path: str,\n        depth: int = 0,\n        parent_id: Optional[str] = None,\n        parent_bbox: Optional[BBox] = None,\n        root_image_size: Optional[Tuple[int, int]] = None,\n        element_type: Optional[str] = None,\n        root_image_path: Optional[str] = None\n    ) -> EditableImage:\n        \"\"\"\n        将图片转换为可编辑结构（递归）\n        \n        线程安全：此方法可以被多个线程并行调用\n        \n        Args:\n            image_path: 图片路径\n            depth: 当前递归深度（内部使用）\n            parent_id: 父图片ID（内部使用）\n            parent_bbox: 当前图片在父图中的bbox位置（内部使用）\n            root_image_size: 根图片尺寸（内部使用）\n            element_type: 元素类型，用于选择提取器（内部使用）\n            root_image_path: 根图片路径（内部使用）\n        \n        Returns:\n            EditableImage对象\n        \n        Raises:\n            FileNotFoundError: 图片文件不存在\n            ValueError: 图片格式不支持\n        \"\"\"\n        image_id = str(uuid.uuid4())[:8]\n        logger.info(f\"{'  ' * depth}[{image_id}] 开始处理\")\n        \n        # 1. 加载图片\n        try:\n            img = Image.open(image_path)\n            width, height = img.size\n        except Exception as e:\n            logger.error(f\"无法加载图片 {image_path}: {e}\")\n            raise\n        \n        # 记录根图片信息\n        if root_image_size is None:\n            root_image_size = (width, height)\n        if root_image_path is None:\n            root_image_path = image_path\n        \n        # 2. 提取元素\n        extraction_result = self._extract_elements(\n            image_path=image_path,\n            element_type=element_type,\n            depth=depth\n        )\n\n        # 检查提取是否有错误（根层级必须成功，否则报错）\n        if extraction_result.has_error and depth == 0:\n            raise RuntimeError(f\"版面分析失败: {extraction_result.error}\")\n\n        # 从context获取image_size（提取器自己获取）\n        extracted_image_size = extraction_result.context.metadata.get('image_size', (width, height))\n        \n        elements = self._convert_to_editable_elements(\n            element_dicts=extraction_result.elements,\n            image_id=image_id,\n            parent_bbox=parent_bbox,\n            image_size=extracted_image_size,\n            root_image_size=root_image_size,\n            source_image_path=image_path  # 传入源图片路径用于裁剪\n        )\n        \n        logger.info(f\"{'  ' * depth}提取到 {len(elements)} 个元素\")\n        \n        # 3. 生成clean background（根据元素类型选择重绘方法）\n        clean_background = None\n        if self._inpaint_registry and elements:\n            clean_background = self._generate_clean_background(\n                image_path=image_path,\n                elements=elements,\n                image_id=image_id,\n                depth=depth,\n                parent_bbox=parent_bbox,\n                root_image_path=root_image_path,\n                image_size=(width, height),\n                element_type=element_type  # 传递元素类型以选择对应的重绘方法\n            )\n        \n        # 4. 递归处理子元素\n        # max_depth 语义：max_depth=1 表示只处理1层不递归，max_depth=2 递归一次\n        if depth + 1 < self._max_depth:\n            self._process_children(\n                elements=elements,\n                current_image_path=image_path,\n                depth=depth,\n                image_id=image_id,\n                root_image_size=root_image_size,\n                current_image_size=(width, height),\n                root_image_path=root_image_path\n            )\n        \n        # 5. 构建结果\n        editable_image = EditableImage(\n            image_id=image_id,\n            image_path=image_path,\n            width=width,\n            height=height,\n            elements=elements,\n            clean_background=clean_background,\n            depth=depth,\n            parent_id=parent_id\n        )\n        \n        logger.info(f\"{'  ' * depth}[{image_id}] 处理完成\")\n        return editable_image\n    \n    def _extract_elements(\n        self,\n        image_path: str,\n        element_type: Optional[str],\n        depth: int\n    ) -> ExtractionResult:\n        \"\"\"提取元素（完全依赖提取器接口）\"\"\"\n        logger.info(f\"{'  ' * depth}提取元素...\")\n        \n        # 选择提取器\n        extractor = self._select_extractor(element_type)\n        \n        # 调用提取器（提取器自己处理所有细节，包括获取image_size）\n        return extractor.extract(\n            image_path=image_path,\n            element_type=element_type,\n            depth=depth\n        )\n    \n    def _select_extractor(self, element_type: Optional[str]) -> ElementExtractor:\n        \"\"\"根据元素类型从注册表选择对应的提取器\"\"\"\n        extractor = self._extractor_registry.get_extractor(element_type)\n        if extractor is None:\n            raise ValueError(f\"未找到元素类型 '{element_type}' 对应的提取器\")\n        return extractor\n    \n    def _convert_to_editable_elements(\n        self,\n        element_dicts: List[dict],\n        image_id: str,\n        parent_bbox: Optional[BBox],\n        image_size: Tuple[int, int],\n        root_image_size: Tuple[int, int],\n        source_image_path: Optional[str] = None\n    ) -> List[EditableElement]:\n        \"\"\"\n        将提取器返回的字典转换为EditableElement对象\n        \n        对每个元素根据 bbox 从原图裁剪并保存图片，不依赖 MinerU 提取的图片。\n        这样所有元素（包括文字）都有 image_path，可用于样式提取。\n        \"\"\"\n        elements = []\n        \n        # 准备输出目录\n        output_dir = None\n        source_img = None\n        if source_image_path:\n            output_dir = self._upload_folder / 'editable_images' / image_id / 'elements'\n            output_dir.mkdir(parents=True, exist_ok=True)\n            try:\n                source_img = Image.open(source_image_path)\n            except Exception as e:\n                logger.warning(f\"无法加载源图片进行裁剪: {e}\")\n        \n        for idx, elem_dict in enumerate(element_dicts):\n            bbox_list = elem_dict['bbox']\n            local_bbox = BBox(\n                x0=bbox_list[0],\n                y0=bbox_list[1],\n                x1=bbox_list[2],\n                y1=bbox_list[3]\n            )\n            \n            # 计算全局坐标\n            if parent_bbox is None:\n                global_bbox = local_bbox\n            else:\n                global_bbox = CoordinateMapper.local_to_global(\n                    local_bbox=local_bbox,\n                    parent_bbox=parent_bbox,\n                    local_image_size=image_size,\n                    parent_image_size=root_image_size\n                )\n            \n            # 为每个元素裁剪并保存图片（统一使用自己裁剪的图片）\n            element_image_path = None\n            if source_img and output_dir:\n                try:\n                    # 裁剪元素区域\n                    crop_box = (\n                        max(0, int(local_bbox.x0)),\n                        max(0, int(local_bbox.y0)),\n                        min(source_img.width, int(local_bbox.x1)),\n                        min(source_img.height, int(local_bbox.y1))\n                    )\n                    \n                    # 检查裁剪区域有效性\n                    if crop_box[2] > crop_box[0] and crop_box[3] > crop_box[1]:\n                        cropped = source_img.crop(crop_box)\n                        element_image_path = str(output_dir / f\"{idx}_{elem_dict['type']}.png\")\n                        cropped.save(element_image_path)\n                except Exception as e:\n                    logger.warning(f\"裁剪元素 {idx} 失败: {e}\")\n            \n            element = EditableElement(\n                element_id=f\"{image_id}_{idx}\",\n                element_type=elem_dict['type'],\n                bbox=local_bbox,\n                bbox_global=global_bbox,\n                content=elem_dict.get('content'),\n                image_path=element_image_path,  # 使用自己裁剪的图片路径\n                metadata=elem_dict.get('metadata', {})\n            )\n            \n            elements.append(element)\n        \n        # 关闭源图片\n        if source_img:\n            source_img.close()\n        \n        return elements\n    \n    def _generate_clean_background(\n        self,\n        image_path: str,\n        elements: List[EditableElement],\n        image_id: str,\n        depth: int,\n        parent_bbox: Optional[BBox],\n        root_image_path: str,\n        image_size: Tuple[int, int],\n        element_type: Optional[str] = None\n    ) -> Optional[str]:\n        \"\"\"\n        生成clean background\n        \n        根据元素类型从注册表选择对应的重绘方法：\n        - 如果指定了element_type，使用该类型对应的重绘方法\n        - 否则使用默认的重绘方法\n        \"\"\"\n        logger.info(f\"{'  ' * depth}生成clean background (element_type={element_type})...\")\n        \n        # 从注册表获取重绘方法\n        inpaint_provider = self._inpaint_registry.get_provider(element_type)\n        if inpaint_provider is None:\n            logger.warning(f\"{'  ' * depth}未找到重绘方法，跳过\")\n            return None\n        \n        try:\n            bboxes = collect_bboxes_from_elements(elements)\n            img = Image.open(image_path)\n            img_width, img_height = img.size\n            element_types = [elem.element_type for elem in elements]\n            \n            # 计算crop_box\n            if depth == 0:\n                crop_box = (0, 0, img_width, img_height)\n            elif parent_bbox:\n                crop_box = (\n                    int(parent_bbox.x0),\n                    int(parent_bbox.y0),\n                    int(parent_bbox.x1),\n                    int(parent_bbox.y1)\n                )\n            else:\n                crop_box = None\n            \n            # 加载完整页面图像\n            full_page_img = None\n            if root_image_path != image_path:\n                full_page_img = Image.open(root_image_path)\n            \n            # 过滤覆盖过大的bbox\n            filtered_bboxes = []\n            filtered_types = []\n            for bbox, elem_type in zip(bboxes, element_types):\n                if isinstance(bbox, (tuple, list)) and len(bbox) == 4:\n                    x0, y0, x1, y1 = bbox\n                    coverage = ((x1 - x0) * (y1 - y0)) / (img_width * img_height)\n                    if coverage > 0.95:\n                        continue\n                filtered_bboxes.append(bbox)\n                filtered_types.append(elem_type)\n            \n            if not filtered_bboxes:\n                return None\n            \n            # 准备输出\n            output_dir = self._upload_folder / 'editable_images' / image_id\n            output_dir.mkdir(parents=True, exist_ok=True)\n            \n            # 调用注册表中选择的重绘方法\n            logger.info(f\"{'  ' * depth}使用 {inpaint_provider.__class__.__name__} 进行重绘\")\n            result_img = inpaint_provider.inpaint_regions(\n                image=img,\n                bboxes=filtered_bboxes,\n                types=filtered_types,\n                expand_pixels=10,\n                save_mask_path=str(output_dir / 'mask.png'),\n                full_page_image=full_page_img,\n                crop_box=crop_box\n            )\n            \n            if result_img is None:\n                return None\n            \n            # 保存结果\n            output_path = output_dir / 'clean_background.png'\n            result_img.save(str(output_path))\n            return str(output_path)\n        \n        except Exception as e:\n            logger.error(f\"生成clean background失败: {e}\", exc_info=True)\n            return None\n    \n    def _process_children(\n        self,\n        elements: List[EditableElement],\n        current_image_path: str,\n        depth: int,\n        image_id: str,\n        root_image_size: Tuple[int, int],\n        current_image_size: Tuple[int, int],\n        root_image_path: str\n    ):\n        \"\"\"递归处理子元素（通过裁剪原图获取子图，并行处理多个子元素）\"\"\"\n        logger.info(f\"{'  ' * depth}递归处理子元素...\")\n        \n        # 筛选需要递归的元素\n        elements_to_process = []\n        for element in elements:\n            if should_recurse_into_element(\n                element=element,\n                parent_image_size=current_image_size,\n                min_image_size=self._min_image_size,\n                min_image_area=self._min_image_area,\n                max_child_coverage_ratio=self._max_child_coverage_ratio\n            ):\n                elements_to_process.append(element)\n        \n        if not elements_to_process:\n            return\n        \n        # 并行处理多个子元素\n        from concurrent.futures import ThreadPoolExecutor, as_completed\n        \n        def process_single_element(element):\n            \"\"\"处理单个子元素\"\"\"\n            try:\n                # 从当前图片裁剪出子区域\n                child_image_path = crop_element_from_image(\n                    source_image_path=current_image_path,\n                    bbox=element.bbox\n                )\n                \n                child_editable = self.make_image_editable(\n                    image_path=child_image_path,\n                    depth=depth + 1,\n                    parent_id=image_id,\n                    parent_bbox=element.bbox_global,\n                    root_image_size=root_image_size,\n                    element_type=element.element_type,\n                    root_image_path=root_image_path\n                )\n                \n                return element, child_editable, None\n            \n            except Exception as e:\n                return element, None, e\n        \n        logger.info(f\"{'  ' * depth}  并行处理 {len(elements_to_process)} 个子元素...\")\n        \n        # 使用线程池并行处理\n        max_workers = min(8, len(elements_to_process))  # 限制并发数\n        with ThreadPoolExecutor(max_workers=max_workers) as executor:\n            futures = {executor.submit(process_single_element, elem): elem for elem in elements_to_process}\n            \n            for future in as_completed(futures):\n                element, child_editable, error = future.result()\n                \n                if error:\n                    logger.error(f\"{'  ' * depth}  ✗ {element.element_id} 失败: {error}\")\n                else:\n                    element.children = child_editable.elements\n                    element.inpainted_background_path = child_editable.clean_background\n                    logger.info(f\"{'  ' * depth}  ✓ {element.element_id} 完成: {len(child_editable.elements)} 个子元素\")\n"
  },
  {
    "path": "backend/services/image_editability/text_attribute_extractors.py",
    "content": "\"\"\"\n文字属性提取器 - 从文字区域图像中提取文字的视觉属性\n\n包含：\n- TextStyleResult: 文字样式数据结构\n- TextAttributeExtractor: 提取器抽象接口\n- CaptionModelTextAttributeExtractor: 基于Caption Model的默认实现\n- TextAttributeExtractorRegistry: 提取器注册表\n\"\"\"\nimport logging\nfrom abc import ABC, abstractmethod\nfrom dataclasses import dataclass, field, asdict\nfrom typing import Dict, Any, List, Optional, Tuple, Union\nfrom PIL import Image\nfrom services.prompts import get_text_attribute_extraction_prompt\n\nlogger = logging.getLogger(__name__)\n\n\n@dataclass\nclass ColoredSegment:\n    \"\"\"\n    带颜色的文字片段\n    \n    用于表示一段文字及其颜色，支持 LaTeX 公式\n    \"\"\"\n    text: str  # 文字内容（如果是公式则为 LaTeX 格式）\n    color_rgb: Tuple[int, int, int] = (0, 0, 0)  # RGB颜色 (0-255)\n    is_latex: bool = False  # 是否为 LaTeX 公式\n    \n    def to_dict(self) -> Dict[str, Any]:\n        \"\"\"转换为字典\"\"\"\n        result = {\n            'text': self.text,\n            'color': f\"#{self.color_rgb[0]:02x}{self.color_rgb[1]:02x}{self.color_rgb[2]:02x}\"\n        }\n        if self.is_latex:\n            result['is_latex'] = True\n        return result\n    \n    @classmethod\n    def from_dict(cls, data: Dict[str, Any]) -> 'ColoredSegment':\n        \"\"\"从字典创建实例\"\"\"\n        text = data.get('text', '')\n        color = data.get('color', '#000000')\n        is_latex = bool(data.get('is_latex', False))\n        \n        # 解析颜色\n        if isinstance(color, str):\n            color = color.lstrip('#')\n            if len(color) == 3:\n                color = ''.join(c * 2 for c in color)\n            try:\n                r = int(color[0:2], 16)\n                g = int(color[2:4], 16)\n                b = int(color[4:6], 16)\n                color_rgb = (r, g, b)\n            except (ValueError, IndexError):\n                color_rgb = (0, 0, 0)\n        else:\n            color_rgb = (0, 0, 0)\n        return cls(text=text, color_rgb=color_rgb, is_latex=is_latex)\n\n\n@dataclass\nclass TextStyleResult:\n    \"\"\"\n    文字样式数据结构\n    \n    包含从文字区域图像中提取的视觉属性\n    \n    Note:\n        字体大小不在此处提取，因为传入的是裁剪后的子图，无法准确估算。\n        字体大小应由 PPTXBuilder.calculate_font_size 根据bbox计算。\n    \"\"\"\n    # 字体颜色 RGB (0-255) - 默认颜色，用于整体颜色或兜底\n    font_color_rgb: Tuple[int, int, int] = (0, 0, 0)\n    \n    # 带颜色的文字片段列表 - 支持一行文字多种颜色\n    # 如果有值，渲染时优先使用这个，文字内容也以这里的为准\n    colored_segments: List[ColoredSegment] = field(default_factory=list)\n    \n    # 是否粗体\n    is_bold: bool = False\n    \n    # 是否斜体\n    is_italic: bool = False\n    \n    # 是否有下划线\n    is_underline: bool = False\n    \n    # 文字对齐方式 - 可选 ('left', 'center', 'right', 'justify')\n    text_alignment: Optional[str] = None\n    \n    # 置信度 (0.0-1.0)\n    confidence: float = 1.0\n    \n    # 额外的元数据\n    metadata: Dict[str, Any] = field(default_factory=dict)\n    \n    def to_dict(self) -> Dict[str, Any]:\n        \"\"\"转换为字典\"\"\"\n        result = asdict(self)\n        # 将 tuple 转换为 list 以便 JSON 序列化\n        result['font_color_rgb'] = list(self.font_color_rgb)\n        # 转换 colored_segments\n        result['colored_segments'] = [seg.to_dict() if isinstance(seg, ColoredSegment) else seg for seg in self.colored_segments]\n        return result\n    \n    @classmethod\n    def from_dict(cls, data: Dict[str, Any]) -> 'TextStyleResult':\n        \"\"\"从字典创建实例\"\"\"\n        if 'font_color_rgb' in data and isinstance(data['font_color_rgb'], list):\n            data['font_color_rgb'] = tuple(data['font_color_rgb'])\n        # 转换 colored_segments\n        if 'colored_segments' in data:\n            data['colored_segments'] = [\n                ColoredSegment.from_dict(seg) if isinstance(seg, dict) else seg \n                for seg in data['colored_segments']\n            ]\n        return cls(**data)\n    \n    def get_hex_color(self) -> str:\n        \"\"\"获取十六进制颜色值（默认颜色）\"\"\"\n        r, g, b = self.font_color_rgb\n        return f\"#{r:02x}{g:02x}{b:02x}\"\n    \n    def get_full_text(self) -> str:\n        \"\"\"获取完整的文字内容（从 colored_segments 拼接）\"\"\"\n        if self.colored_segments:\n            return ''.join(seg.text for seg in self.colored_segments)\n        return \"\"\n    \n    def has_multi_color(self) -> bool:\n        \"\"\"是否有多种颜色\"\"\"\n        if not self.colored_segments or len(self.colored_segments) <= 1:\n            return False\n        colors = set(seg.color_rgb for seg in self.colored_segments)\n        return len(colors) > 1\n\n\nclass TextAttributeExtractor(ABC):\n    \"\"\"\n    文字属性提取器抽象接口\n    \n    用于从文字区域图像中提取文字的视觉属性，支持接入多种实现：\n    - CaptionModelTextAttributeExtractor: 使用视觉语言模型（如Gemini）分析图像\n    - 未来可扩展：基于传统CV的方法、专用OCR模型等\n    \"\"\"\n    \n    @abstractmethod\n    def extract(\n        self,\n        image: Union[str, Image.Image],\n        text_content: Optional[str] = None,\n        **kwargs\n    ) -> TextStyleResult:\n        \"\"\"\n        从文字区域图像中提取文字样式属性\n        \n        Args:\n            image: 文字区域的图像，可以是文件路径或PIL Image对象\n            text_content: 文字内容（可选，某些实现可能用于辅助识别）\n            **kwargs: 其他由具体实现自定义的参数\n        \n        Returns:\n            TextStyleResult对象，包含提取的文字样式属性\n        \"\"\"\n        pass\n    \n    @abstractmethod\n    def supports_batch(self) -> bool:\n        \"\"\"\n        是否支持批量处理\n        \n        Returns:\n            如果支持批量处理返回True\n        \"\"\"\n        pass\n    \n    def extract_batch(\n        self,\n        items: List[Tuple[Union[str, Image.Image], Optional[str]]],\n        **kwargs\n    ) -> List[TextStyleResult]:\n        \"\"\"\n        批量提取文字样式属性\n        \n        默认实现：逐个调用extract方法\n        子类可以覆盖此方法以实现更高效的批量处理\n        \n        Args:\n            items: 列表，每个元素是 (image, text_content) 元组\n            **kwargs: 其他参数\n        \n        Returns:\n            TextStyleResult列表\n        \"\"\"\n        results = []\n        for image, text_content in items:\n            try:\n                result = self.extract(image, text_content, **kwargs)\n                results.append(result)\n            except Exception as e:\n                logger.error(f\"批量提取文字属性失败: {e}\")\n                # 返回默认结果\n                results.append(TextStyleResult(confidence=0.0))\n        return results\n\n\nclass CaptionModelTextAttributeExtractor(TextAttributeExtractor):\n    \"\"\"\n    基于Caption Model（视觉语言模型）的文字属性提取器\n    \n    使用视觉语言模型（如Gemini）分析文字区域图像，\n    通过生成JSON的方式获取字体颜色、是否粗体、是否斜体等属性。\n    \"\"\"\n    @staticmethod\n    def build_prompt(text_content: Optional[str] = None) -> str:\n        \"\"\"\n        构建合并后的prompt\n        如果text_content存在则插入提示，否则省略\n        \"\"\"\n        if text_content:\n            content_hint = f'图片中的文字内容是: \"{text_content}\"'\n        else:\n            content_hint = \"\"\n        return get_text_attribute_extraction_prompt(content_hint=content_hint)\n    \n    def __init__(self, ai_service, prompt_template: Optional[str] = None):\n        \"\"\"\n        初始化Caption Model文字属性提取器\n        \n        Args:\n            ai_service: AIService实例（需要支持generate_json方法和图片输入）\n            prompt_template: 自定义的prompt模板（可选），必须使用 {content_hint} 作为占位符\n        \"\"\"\n        self.ai_service = ai_service\n        self.prompt_template = prompt_template\n    \n    def supports_batch(self) -> bool:\n        \"\"\"当前实现不支持批量处理\"\"\"\n        return False\n    \n    def extract(\n        self,\n        image: Union[str, Image.Image],\n        text_content: Optional[str] = None,\n        **kwargs\n    ) -> TextStyleResult:\n        \"\"\"\n        使用Caption Model提取文字样式属性\n        \n        Args:\n            image: 文字区域的图像\n            text_content: 文字内容（可选，用于辅助识别）\n            **kwargs: \n                - thinking_budget: int, 思考预算，默认500\n        \n        Returns:\n            TextStyleResult对象\n        \"\"\"\n        thinking_budget = kwargs.get('thinking_budget', 500)\n        \n        try:\n            # 准备图片\n            if isinstance(image, str):\n                pil_image = Image.open(image)\n            else:\n                pil_image = image\n            \n            # 构建prompt\n            # 统一使用 content_hint 格式\n            if text_content:\n                content_hint = f'图片中的文字内容是: \"{text_content}\"'\n            else:\n                content_hint = \"\"\n            \n            if self.prompt_template:\n                # 自定义模板必须使用 {content_hint} 占位符\n                prompt = self.prompt_template.format(content_hint=content_hint)\n            else:\n                prompt = get_text_attribute_extraction_prompt(content_hint=content_hint)\n            \n            # 调用AI服务（需要支持图片输入的generate_json）\n            # 这里假设text_provider支持带图片的generate方法\n            result_json = self._call_vision_model(pil_image, prompt, thinking_budget)\n            \n            # 解析结果\n            return self._parse_result(result_json)\n        \n        except Exception as e:\n            logger.error(f\"CaptionModelTextAttributeExtractor提取失败: {e}\", exc_info=True)\n            return TextStyleResult(confidence=0.0, metadata={'error': str(e)})\n    \n    def _call_vision_model(self, image: Image.Image, prompt: str, thinking_budget: int) -> Dict[str, Any]:\n        \"\"\"\n        调用视觉语言模型，使用 ai_service.generate_json_with_image（带重试机制）\n        \n        Args:\n            image: PIL Image对象\n            prompt: 提示词\n            thinking_budget: 思考预算\n        \n        Returns:\n            解析后的JSON结果\n        \"\"\"\n        import tempfile\n        import os\n        \n        # 保存临时图片文件\n        with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp_file:\n            tmp_path = tmp_file.name\n            image.save(tmp_path)\n        \n        try:\n            # 使用 ai_service.generate_json_with_image（带重试机制）\n            result = self.ai_service.generate_json_with_image(\n                prompt=prompt,\n                image_path=tmp_path,\n                thinking_budget=thinking_budget\n            )\n            return result if isinstance(result, dict) else {}\n        \n        except ValueError as e:\n            raise RuntimeError(f\"当前图片样式提取模型不支持图片输入: {e}\") from e\n        \n        except Exception as e:\n            raise RuntimeError(f\"调用视觉模型提取文本样式失败: {e}\") from e\n        \n        finally:\n            if os.path.exists(tmp_path):\n                os.remove(tmp_path)\n    \n    @staticmethod\n    def _hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:\n        \"\"\"\n        将十六进制颜色转换为RGB元组\n        \n        Args:\n            hex_color: 十六进制颜色，如 \"#FF6B6B\" 或 \"FF6B6B\"\n        \n        Returns:\n            RGB元组 (R, G, B)\n        \"\"\"\n        # 移除 # 前缀\n        hex_color = hex_color.lstrip('#')\n        \n        # 处理简写格式 (如 #FFF -> #FFFFFF)\n        if len(hex_color) == 3:\n            hex_color = ''.join(c * 2 for c in hex_color)\n        \n        if len(hex_color) != 6:\n            return (0, 0, 0)  # 无效格式，返回黑色\n        \n        try:\n            r = int(hex_color[0:2], 16)\n            g = int(hex_color[2:4], 16)\n            b = int(hex_color[4:6], 16)\n            return (r, g, b)\n        except ValueError:\n            return (0, 0, 0)\n    \n    def _parse_result(self, result_json: Dict[str, Any]) -> TextStyleResult:\n        \"\"\"\n        解析AI返回的JSON结果\n        \n        Args:\n            result_json: AI返回的JSON字典，支持两种格式：\n                - 新格式：包含 colored_segments 数组（文字-颜色对）\n                - 旧格式：包含 font_color 单一颜色\n        \n        Returns:\n            TextStyleResult对象\n        \"\"\"\n        if not result_json:\n            return TextStyleResult(\n                confidence=0.0,\n                metadata={'error': '视觉模型未返回可解析的样式结果'}\n            )\n        \n        try:\n            # 解析 colored_segments（新格式：支持一行多颜色）\n            colored_segments = []\n            segments_data = result_json.get('colored_segments', [])\n            \n            if segments_data and isinstance(segments_data, list):\n                for seg in segments_data:\n                    if isinstance(seg, dict):\n                        colored_segments.append(ColoredSegment.from_dict(seg))\n            \n            # 计算默认颜色（从 segments 取第一个，或用旧格式的 font_color）\n            if colored_segments:\n                font_color_rgb = colored_segments[0].color_rgb\n            else:\n                # 兼容旧格式\n                font_color_hex = result_json.get('font_color', '#000000')\n                if isinstance(font_color_hex, str):\n                    font_color_rgb = self._hex_to_rgb(font_color_hex)\n                else:\n                    font_color_rgb = (0, 0, 0)\n            \n            # 解析布尔值\n            is_bold = bool(result_json.get('is_bold', False))\n            is_italic = bool(result_json.get('is_italic', False))\n            is_underline = bool(result_json.get('is_underline', False))\n            \n            # 解析文字对齐方式\n            text_alignment = result_json.get('text_alignment')\n            if text_alignment not in ('left', 'center', 'right', 'justify', None):\n                text_alignment = None\n            \n            return TextStyleResult(\n                font_color_rgb=font_color_rgb,\n                colored_segments=colored_segments,\n                is_bold=is_bold,\n                is_italic=is_italic,\n                is_underline=is_underline,\n                text_alignment=text_alignment,\n                confidence=0.9,  # 模型返回的结果给予较高置信度\n                metadata={'source': 'caption_model', 'raw_response': result_json}\n            )\n        \n        except Exception as e:\n            logger.error(f\"解析结果失败: {e}\")\n            return TextStyleResult(confidence=0.0, metadata={'error': str(e)})\n    \n    def extract_batch_with_full_image(\n        self,\n        full_image: Union[str, Image.Image],\n        text_elements: List[Dict[str, Any]],\n        **kwargs\n    ) -> Dict[str, TextStyleResult]:\n        \"\"\"\n        【新逻辑】使用全图一次性提取所有文本元素的样式属性\n        \n        优势：模型可以看到全局上下文，提高分析准确性\n        \n        Args:\n            full_image: 完整的页面图片，可以是文件路径或PIL Image对象\n            text_elements: 文本元素列表，每个元素包含：\n                - element_id: 元素唯一标识\n                - bbox: 边界框 [x0, y0, x1, y1]\n                - content: 文字内容\n            **kwargs:\n                - thinking_budget: int, 思考预算，默认1000\n        \n        Returns:\n            字典，key为element_id，value为TextStyleResult\n        \"\"\"\n        import json\n        import tempfile\n        from services.prompts import get_batch_text_attribute_extraction_prompt\n        \n        thinking_budget = kwargs.get('thinking_budget', 1000)\n        \n        if not text_elements:\n            return {}\n        \n        try:\n            # 准备图片\n            if isinstance(full_image, str):\n                pil_image = Image.open(full_image)\n                tmp_path = full_image  # 如果已经是路径，直接使用\n                need_cleanup = False\n            else:\n                pil_image = full_image\n                # 保存临时图片文件\n                with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp_file:\n                    tmp_path = tmp_file.name\n                    pil_image.save(tmp_path)\n                need_cleanup = True\n            \n            # 构建文本元素的 JSON 描述\n            elements_for_prompt = []\n            for elem in text_elements:\n                elements_for_prompt.append({\n                    'element_id': elem['element_id'],\n                    'bbox': elem['bbox'],\n                    'content': elem['content']\n                })\n            \n            text_elements_json = json.dumps(elements_for_prompt, ensure_ascii=False, indent=2)\n            \n            # 构建 prompt\n            prompt = get_batch_text_attribute_extraction_prompt(text_elements_json)\n            \n            # 调用 ai_service.generate_json_with_image（带重试机制）\n            try:\n                result = self.ai_service.generate_json_with_image(\n                    prompt=prompt,\n                    image_path=tmp_path,\n                    thinking_budget=thinking_budget\n                )\n                \n                # 确保结果是列表\n                if isinstance(result, list):\n                    result_list = result\n                elif isinstance(result, dict):\n                    # 如果返回的是字典，尝试获取列表\n                    result_list = result.get('results', [result])\n                else:\n                    result_list = []\n                \n                # 解析结果\n                return self._parse_batch_result(result_list, text_elements)\n            \n            except ValueError as e:\n                raise RuntimeError(f\"当前图片样式提取模型不支持图片输入: {e}\") from e\n            \n            except Exception as e:\n                raise RuntimeError(f\"批量调用视觉模型提取文本样式失败: {e}\") from e\n                \n            finally:\n                if need_cleanup:\n                    import os\n                    if os.path.exists(tmp_path):\n                        os.remove(tmp_path)\n        \n        except Exception as e:\n            logger.error(f\"批量提取文字属性失败: {e}\", exc_info=True)\n            raise\n    \n    def _parse_batch_result(\n        self,\n        result_list: List[Dict[str, Any]],\n        original_elements: List[Dict[str, Any]]\n    ) -> Dict[str, TextStyleResult]:\n        \"\"\"\n        解析批量提取的 AI 返回结果\n        \n        Args:\n            result_list: AI 返回的 JSON 列表，每个元素包含样式属性\n            original_elements: 原始输入的元素列表，用于匹配 element_id\n        \n        Returns:\n            字典，key 为 element_id，value 为 TextStyleResult\n        \"\"\"\n        results = {}\n        \n        # 创建 element_id 到原始元素的映射，用于回退\n        original_map = {elem['element_id']: elem for elem in original_elements}\n        \n        for item in result_list:\n            try:\n                element_id = item.get('element_id')\n                if not element_id:\n                    continue\n                \n                # 解析颜色（十六进制格式）\n                font_color_hex = item.get('font_color', '#000000')\n                if isinstance(font_color_hex, str):\n                    font_color_rgb = self._hex_to_rgb(font_color_hex)\n                else:\n                    font_color_rgb = (0, 0, 0)\n                \n                # 解析布尔值\n                is_bold = bool(item.get('is_bold', False))\n                is_italic = bool(item.get('is_italic', False))\n                is_underline = bool(item.get('is_underline', False))\n                \n                # 解析文字对齐方式\n                text_alignment = item.get('text_alignment')\n                if text_alignment not in ('left', 'center', 'right', 'justify', None):\n                    text_alignment = None\n                \n                results[element_id] = TextStyleResult(\n                    font_color_rgb=font_color_rgb,\n                    is_bold=is_bold,\n                    is_italic=is_italic,\n                    is_underline=is_underline,\n                    text_alignment=text_alignment,\n                    confidence=0.9,\n                    metadata={'source': 'batch_caption_model', 'raw_response': item}\n                )\n                \n            except Exception as e:\n                logger.warning(f\"解析元素 {item.get('element_id', 'unknown')} 的样式失败: {e}\")\n                continue\n        \n        logger.info(f\"批量解析完成: 成功 {len(results)}/{len(original_elements)} 个元素\")\n        return results\n\n\nclass TextAttributeExtractorRegistry:\n    \"\"\"\n    文字属性提取器注册表\n    \n    管理不同元素类型应该使用哪个文字属性提取器：\n    - 普通文本 → CaptionModelTextAttributeExtractor\n    - 标题文本 → 可使用不同配置的提取器\n    - 其他类型 → 默认提取器\n    \n    使用方式：\n        >>> registry = TextAttributeExtractorRegistry()\n        >>> registry.register('text', caption_extractor)\n        >>> registry.register('title', title_extractor)\n        >>> registry.register_default(caption_extractor)\n        >>> \n        >>> extractor = registry.get_extractor('text')\n        >>> extractor = registry.get_extractor('unknown_type')  # 返回默认提取器\n    \"\"\"\n    \n    # 预定义的元素类型分组\n    TEXT_TYPES = {'text', 'title', 'paragraph', 'heading', 'header', 'footer', 'list'}\n    TABLE_TEXT_TYPES = {'table_cell'}\n    \n    def __init__(self):\n        \"\"\"初始化注册表\"\"\"\n        self._type_mapping: Dict[str, TextAttributeExtractor] = {}\n        self._default_extractor: Optional[TextAttributeExtractor] = None\n    \n    def register(self, element_type: str, extractor: TextAttributeExtractor) -> 'TextAttributeExtractorRegistry':\n        \"\"\"\n        注册元素类型到提取器的映射\n        \n        Args:\n            element_type: 元素类型（如 'text', 'title' 等）\n            extractor: 对应的提取器实例\n        \n        Returns:\n            self，支持链式调用\n        \"\"\"\n        self._type_mapping[element_type] = extractor\n        logger.debug(f\"注册文字属性提取器: {element_type} -> {extractor.__class__.__name__}\")\n        return self\n    \n    def register_types(self, element_types: List[str], extractor: TextAttributeExtractor) -> 'TextAttributeExtractorRegistry':\n        \"\"\"\n        批量注册多个元素类型到同一个提取器\n        \n        Args:\n            element_types: 元素类型列表\n            extractor: 对应的提取器实例\n        \n        Returns:\n            self，支持链式调用\n        \"\"\"\n        for t in element_types:\n            self.register(t, extractor)\n        return self\n    \n    def register_default(self, extractor: TextAttributeExtractor) -> 'TextAttributeExtractorRegistry':\n        \"\"\"\n        注册默认提取器（当没有特定类型映射时使用）\n        \n        Args:\n            extractor: 默认提取器实例\n        \n        Returns:\n            self，支持链式调用\n        \"\"\"\n        self._default_extractor = extractor\n        logger.debug(f\"注册默认文字属性提取器: {extractor.__class__.__name__}\")\n        return self\n    \n    def get_extractor(self, element_type: Optional[str]) -> Optional[TextAttributeExtractor]:\n        \"\"\"\n        根据元素类型获取对应的提取器\n        \n        Args:\n            element_type: 元素类型，None表示使用默认提取器\n        \n        Returns:\n            对应的提取器，如果没有注册则返回默认提取器\n        \"\"\"\n        if element_type is None:\n            return self._default_extractor\n        \n        # 先查找精确匹配\n        if element_type in self._type_mapping:\n            return self._type_mapping[element_type]\n        \n        # 返回默认提取器\n        return self._default_extractor\n    \n    def get_all_extractors(self) -> List[TextAttributeExtractor]:\n        \"\"\"\n        获取所有已注册的提取器（去重）\n        \n        Returns:\n            提取器列表\n        \"\"\"\n        extractors = list(set(self._type_mapping.values()))\n        if self._default_extractor and self._default_extractor not in extractors:\n            extractors.append(self._default_extractor)\n        return extractors\n    \n    @classmethod\n    def create_default(\n        cls,\n        caption_extractor: Optional[TextAttributeExtractor] = None\n    ) -> 'TextAttributeExtractorRegistry':\n        \"\"\"\n        创建默认配置的注册表\n        \n        默认配置：\n        - 所有文本类型 → CaptionModelTextAttributeExtractor\n        - 其他类型 → 默认提取器\n        \n        Args:\n            caption_extractor: Caption Model提取器实例\n        \n        Returns:\n            配置好的注册表实例\n        \"\"\"\n        registry = cls()\n        \n        if not caption_extractor:\n            logger.warning(\"创建TextAttributeExtractorRegistry时未提供任何extractor\")\n            return registry\n        \n        # 设置默认提取器\n        registry.register_default(caption_extractor)\n        \n        # 所有文本类型使用相同的提取器\n        registry.register_types(list(cls.TEXT_TYPES), caption_extractor)\n        registry.register_types(list(cls.TABLE_TEXT_TYPES), caption_extractor)\n        \n        logger.info(f\"创建默认TextAttributeExtractorRegistry: \"\n                   f\"默认提取器->{caption_extractor.__class__.__name__}\")\n        \n        return registry\n"
  },
  {
    "path": "backend/services/inpainting_service.py",
    "content": "\"\"\"\nInpainting 服务\n提供基于多种 provider 的图像区域消除和背景重新生成功能\n支持的 provider:\n- volcengine: 火山引擎 Inpainting\n- gemini: Google Gemini 2.5 Flash Image Preview\n\"\"\"\nimport logging\nfrom typing import List, Tuple, Union, Optional\nfrom PIL import Image\n\nfrom services.ai_providers.image.volcengine_inpainting_provider import VolcengineInpaintingProvider\nfrom services.ai_providers.image.gemini_inpainting_provider import GeminiInpaintingProvider\nfrom utils.mask_utils import (\n    create_mask_from_bboxes,\n    create_inverse_mask_from_bboxes,\n    create_mask_from_image_and_bboxes,\n    merge_overlapping_bboxes,\n    visualize_mask_overlay\n)\nfrom config import get_config\n\nlogger = logging.getLogger(__name__)\n\n\nclass InpaintingService:\n    \"\"\"\n    Inpainting 服务类\n    \n    主要功能：\n    1. 从 bbox 生成掩码图像\n    2. 调用 inpainting provider 消除指定区域\n    3. 提供便捷的背景重生成接口\n    \n    支持的 provider:\n    - volcengine: 火山引擎 Inpainting\n    - gemini: Google Gemini 2.5 Flash Image Preview\n    \"\"\"\n    \n    def __init__(self, provider=None, provider_type: str = \"volcengine\"):\n        \"\"\"\n        初始化 Inpainting 服务\n        \n        Args:\n            provider: Inpainting 提供者实例，如果为 None 则从配置创建\n            provider_type: Provider 类型 ('volcengine' 或 'gemini')\n        \"\"\"\n        if provider is None:\n            config = get_config()\n            \n            if provider_type == \"gemini\":\n                # 使用 Gemini Inpainting Provider\n                api_key = config.GOOGLE_API_KEY\n                api_base = config.GOOGLE_API_BASE\n                timeout = config.GENAI_TIMEOUT\n                \n                if not api_key:\n                    raise ValueError(\"Google API Key 未配置\")\n                \n                self.provider = GeminiInpaintingProvider(\n                    api_key=api_key,\n                    api_base=api_base,\n                    timeout=timeout\n                )\n                self.provider_type = \"gemini\"\n            else:\n                # 使用火山引擎 Inpainting Provider（默认）\n                access_key = config.VOLCENGINE_ACCESS_KEY\n                secret_key = config.VOLCENGINE_SECRET_KEY\n                timeout = config.VOLCENGINE_INPAINTING_TIMEOUT\n                \n                if not access_key or not secret_key:\n                    raise ValueError(\"火山引擎 Access Key 和 Secret Key 未配置\")\n                \n                self.provider = VolcengineInpaintingProvider(\n                    access_key=access_key,\n                    secret_key=secret_key,\n                    timeout=timeout\n                )\n                self.provider_type = \"volcengine\"\n        else:\n            self.provider = provider\n            self.provider_type = provider_type\n        \n        self.config = get_config()\n    \n    def remove_regions_by_bboxes(\n        self,\n        image: Image.Image,\n        bboxes: List[Union[Tuple[int, int, int, int], dict]],\n        expand_pixels: int = 5,\n        merge_bboxes: bool = False,\n        merge_threshold: int = 10,\n        save_mask_path: Optional[str] = None,\n        full_page_image: Optional[Image.Image] = None,\n        crop_box: Optional[tuple] = None\n    ) -> Optional[Image.Image]:\n        \"\"\"\n        根据边界框列表消除图像中的指定区域\n        \n        Args:\n            image: 原始图像（PIL Image）\n            bboxes: 边界框列表，支持以下格式：\n                    - (x1, y1, x2, y2) 元组\n                    - {\"x1\": x1, \"y1\": y1, \"x2\": x2, \"y2\": y2} 字典\n                    - {\"x\": x, \"y\": y, \"width\": w, \"height\": h} 字典\n            expand_pixels: 扩展像素数，让掩码区域略微扩大（默认5像素）\n            merge_bboxes: 是否合并重叠或相邻的边界框（默认False）\n            merge_threshold: 合并阈值，边界框距离小于此值时会合并（默认10像素）\n            save_mask_path: Mask 保存路径（可选）\n            full_page_image: 完整的 PPT 页面图像（仅用于 Gemini provider）\n            crop_box: 裁剪框 (x0, y0, x1, y1)，从完整页面结果中裁剪的区域（仅用于 Gemini provider）\n            \n        Returns:\n            处理后的图像，失败返回 None\n        \"\"\"\n        try:\n            logger.info(f\"开始处理图像消除，原始 bbox 数量: {len(bboxes)}\")\n            \n            # 合并重叠的边界框（如果启用）\n            if merge_bboxes and len(bboxes) > 1:\n                # 先标准化所有 bbox 格式\n                normalized_bboxes = []\n                for bbox in bboxes:\n                    if isinstance(bbox, dict):\n                        if 'x1' in bbox:\n                            normalized_bboxes.append((bbox['x1'], bbox['y1'], bbox['x2'], bbox['y2']))\n                        elif 'x' in bbox:\n                            normalized_bboxes.append((bbox['x'], bbox['y'], \n                                                    bbox['x'] + bbox['width'], \n                                                    bbox['y'] + bbox['height']))\n                    else:\n                        normalized_bboxes.append(tuple(bbox))\n                \n                bboxes = merge_overlapping_bboxes(normalized_bboxes, merge_threshold)\n                logger.info(f\"合并后 bbox 数量: {len(bboxes)}\")\n            \n            # 生成掩码图像\n            mask = create_mask_from_image_and_bboxes(\n                image,\n                bboxes,\n                expand_pixels=expand_pixels\n            )\n            \n            logger.info(f\"掩码图像已生成，尺寸: {mask.size}\")\n            \n            # 保存mask图像（如果指定了路径）\n            if save_mask_path:\n                try:\n                    mask.save(save_mask_path)\n                    logger.info(f\"📷 Mask图像已保存: {save_mask_path}\")\n                except Exception as e:\n                    logger.warning(f\"⚠️ 保存mask图像失败: {e}\")\n            \n            # 调用 inpainting 服务（已内置重试逻辑）\n            result = self.provider.inpaint_image(\n                original_image=image,\n                mask_image=mask,\n                full_page_image=full_page_image,\n                crop_box=crop_box\n            )\n            \n            if result is not None:\n                logger.info(f\"图像消除成功，结果尺寸: {result.size}\")\n            else:\n                logger.error(\"图像消除失败\")\n            \n            return result\n            \n        except Exception as e:\n            logger.error(f\"消除区域失败: {str(e)}\", exc_info=True)\n            return None\n    \n    def regenerate_background(\n        self,\n        image: Image.Image,\n        foreground_bboxes: List[Union[Tuple[int, int, int, int], dict]],\n        expand_pixels: int = 5\n    ) -> Optional[Image.Image]:\n        \"\"\"\n        重新生成背景（保留前景对象，消除其他区域）\n        \n        这个方法使用反向掩码：保留 bbox 区域，消除其他所有区域\n        \n        Args:\n            image: 原始图像\n            foreground_bboxes: 前景对象的边界框列表（这些区域会被保留）\n            expand_pixels: 收缩像素数（负数表示扩展），让前景边缘更自然\n            \n        Returns:\n            处理后的图像，失败返回 None\n        \"\"\"\n        try:\n            logger.info(f\"开始重新生成背景，前景对象数量: {len(foreground_bboxes)}\")\n            \n            # 生成反向掩码（保留前景，消除背景）\n            mask = create_inverse_mask_from_bboxes(\n                image.size,\n                foreground_bboxes,\n                expand_pixels=expand_pixels\n            )\n            \n            logger.info(f\"反向掩码已生成，尺寸: {mask.size}\")\n            \n            # 调用 inpainting 服务（已内置重试逻辑）\n            result = self.provider.inpaint_image(\n                original_image=image,\n                mask_image=mask\n            )\n            \n            if result is not None:\n                logger.info(f\"背景重生成成功，结果尺寸: {result.size}\")\n            else:\n                logger.error(\"背景重生成失败\")\n            \n            return result\n            \n        except Exception as e:\n            logger.error(f\"重新生成背景失败: {str(e)}\", exc_info=True)\n            return None\n    \n    def create_mask_preview(\n        self,\n        image: Image.Image,\n        bboxes: List[Union[Tuple[int, int, int, int], dict]],\n        expand_pixels: int = 0,\n        alpha: float = 0.5\n    ) -> Image.Image:\n        \"\"\"\n        创建掩码预览图（用于调试和可视化）\n        \n        Args:\n            image: 原始图像\n            bboxes: 边界框列表\n            expand_pixels: 扩展像素数\n            alpha: 掩码透明度\n            \n        Returns:\n            叠加了黑色半透明掩码的预览图\n        \"\"\"\n        mask = create_mask_from_image_and_bboxes(image, bboxes, expand_pixels)\n        return visualize_mask_overlay(image, mask, alpha)\n    \n    @staticmethod\n    def create_mask_image(\n        image_size: Tuple[int, int],\n        bboxes: List[Union[Tuple[int, int, int, int], dict]],\n        expand_pixels: int = 0\n    ) -> Image.Image:\n        \"\"\"\n        静态方法：创建掩码图像（不需要实例化服务）\n        \n        Args:\n            image_size: 图像尺寸 (width, height)\n            bboxes: 边界框列表\n            expand_pixels: 扩展像素数\n            \n        Returns:\n            掩码图像\n        \"\"\"\n        return create_mask_from_bboxes(image_size, bboxes, expand_pixels)\n\n\n# 便捷函数\n\n_inpainting_service_instances = {}\n\n\ndef get_inpainting_service(provider_type: str = None) -> InpaintingService:\n    \"\"\"\n    获取 InpaintingService 实例（单例模式，每种 provider 一个实例）\n    \n    Args:\n        provider_type: Provider 类型 ('volcengine', 'gemini')，\n                      如果为 None 则从配置读取\n    \n    Returns:\n        InpaintingService 实例\n    \"\"\"\n    global _inpainting_service_instances\n    \n    # 从配置读取默认 provider\n    if provider_type is None:\n        config = get_config()\n        provider_type = getattr(config, 'INPAINTING_PROVIDER', 'gemini')  # 默认使用 gemini\n    \n    # 获取或创建对应的实例\n    if provider_type not in _inpainting_service_instances:\n        _inpainting_service_instances[provider_type] = InpaintingService(\n            provider_type=provider_type\n        )\n    \n    return _inpainting_service_instances[provider_type]\n\n\ndef remove_regions(\n    image: Image.Image,\n    bboxes: List[Union[Tuple[int, int, int, int], dict]],\n    **kwargs\n) -> Optional[Image.Image]:\n    \"\"\"\n    便捷函数：消除图像中的指定区域\n    \n    Args:\n        image: 原始图像\n        bboxes: 边界框列表\n        **kwargs: 其他参数传递给 InpaintingService.remove_regions_by_bboxes\n        \n    Returns:\n        处理后的图像\n    \"\"\"\n    service = get_inpainting_service()\n    return service.remove_regions_by_bboxes(image, bboxes, **kwargs)\n\n\ndef regenerate_background(\n    image: Image.Image,\n    foreground_bboxes: List[Union[Tuple[int, int, int, int], dict]],\n    **kwargs\n) -> Optional[Image.Image]:\n    \"\"\"\n    便捷函数：重新生成背景\n    \n    Args:\n        image: 原始图像\n        foreground_bboxes: 前景对象的边界框列表\n        **kwargs: 其他参数传递给 InpaintingService.regenerate_background\n        \n    Returns:\n        处理后的图像\n    \"\"\"\n    service = get_inpainting_service()\n    return service.regenerate_background(image, foreground_bboxes, **kwargs)\n\n"
  },
  {
    "path": "backend/services/pdf_service.py",
    "content": "\"\"\"\nPDF Service - PDF splitting utilities using PyPDF2\n\"\"\"\nimport logging\nimport os\nfrom typing import List\nfrom PyPDF2 import PdfReader, PdfWriter\n\nlogger = logging.getLogger(__name__)\n\n\ndef split_pdf_to_pages(pdf_path: str, output_dir: str) -> List[str]:\n    \"\"\"\n    Split a multi-page PDF into individual single-page PDF files.\n\n    Args:\n        pdf_path: Path to the source PDF file\n        output_dir: Directory to write individual page PDFs\n\n    Returns:\n        List of file paths for each single-page PDF, ordered by page number\n    \"\"\"\n    os.makedirs(output_dir, exist_ok=True)\n\n    reader = PdfReader(pdf_path)\n    page_paths = []\n\n    for i, page in enumerate(reader.pages):\n        writer = PdfWriter()\n        writer.add_page(page)\n\n        page_path = os.path.join(output_dir, f\"page_{i + 1}.pdf\")\n        with open(page_path, \"wb\") as f:\n            writer.write(f)\n\n        page_paths.append(page_path)\n\n    logger.info(f\"Split PDF into {len(page_paths)} pages: {pdf_path}\")\n    return page_paths\n"
  },
  {
    "path": "backend/services/prompts.py",
    "content": "\"\"\"\nAI Service Prompts - 集中管理所有 AI 服务的 prompt 模板\n\n分区:\n  1. 共享工具 & 常量    — 语言配置、格式化辅助、DRY 常量\n  2. 大纲 Prompts       — 生成、解析、细化大纲\n  3. 描述 Prompts       — 单页、流式、拆分、细化描述\n  4. 图片生成 Prompts   — 文生图、图片编辑\n  5. 图片处理 Prompts   — 背景提取、画质修复\n  6. 内容提取 Prompts   — 文字属性、页面内容、排版分析、风格提取\n\"\"\"\nimport json\nimport logging\nfrom typing import List, Dict, Optional, TYPE_CHECKING\n\nif TYPE_CHECKING:\n    from services.ai_service import ProjectContext\n\nlogger = logging.getLogger(__name__)\n\n\n# ═══════════════════════════════════════════════════════════════════════════════\n# 1. 共享工具 & 常量\n# ═══════════════════════════════════════════════════════════════════════════════\n\n\n# --- 常量 ---\n\nLANGUAGE_CONFIG = {\n    'zh': {\n        'name': '中文',\n        'instruction': '请使用全中文输出。',\n        'ppt_text': 'PPT文字请使用全中文。'\n    },\n    'ja': {\n        'name': '日本語',\n        'instruction': 'すべて日本語で出力してください。',\n        'ppt_text': 'PPTのテキストは全て日本語で出力してください。'\n    },\n    'en': {\n        'name': 'English',\n        'instruction': 'Please output all in English.',\n        'ppt_text': 'Use English for PPT text.'\n    },\n    'auto': {\n        'name': '自动',\n        'instruction': '',\n        'ppt_text': ''\n    }\n}\n\nDETAIL_LEVEL_SPECS = {\n    'concise': '文字极致地压缩和精简，每条要点用一个核心词语或数据代替，例如效率↑80%',\n    'default': '清晰明了，每条要点控制在15-20字以内, 避免冗长的句子和复杂的表述',\n    'detailed': '忠于原文的基础上做到内容详实，逻辑清晰。',\n}\n\n_OUTLINE_JSON_FORMAT = \"\"\"\\\n1. Simple format (for short PPTs without major sections):\n[{\"title\": \"title1\", \"points\": [\"point1\", \"point2\"]}, {\"title\": \"title2\", \"points\": [\"point1\", \"point2\"]}]\n\n2. Part-based format (for longer PPTs with major sections):\n[\n    {\n    \"part\": \"Part 1: Introduction\",\n    \"pages\": [\n        {\"title\": \"Welcome\", \"points\": [\"point1\", \"point2\"]},\n        {\"title\": \"Overview\", \"points\": [\"point1\", \"point2\"]}\n    ]\n    },\n    {\n    \"part\": \"Part 2: Main Content\",\n    \"pages\": [\n        {\"title\": \"Topic 1\", \"points\": [\"point1\", \"point2\"]},\n        {\"title\": \"Topic 2\", \"points\": [\"point1\", \"point2\"]}\n    ]\n    }\n]\"\"\"\n\n\n# --- 辅助函数 ---\n\ndef _build_prompt(prompt_text: str, reference_files_content=None, *, tag: str = '') -> str:\n    \"\"\"Prepend reference files XML and log the final prompt.\"\"\"\n    files_xml = _format_reference_files_xml(reference_files_content)\n    final = files_xml + prompt_text\n    if tag:\n        logger.debug(f\"[{tag}] Final prompt:\\n{final}\")\n    return final\n\n\ndef _get_original_input(project_context: 'ProjectContext') -> str:\n    \"\"\"Extract original user input from project context (shared across prompt builders).\"\"\"\n    if project_context.creation_type == 'idea' and project_context.idea_prompt:\n        return project_context.idea_prompt\n    if project_context.creation_type == 'outline' and project_context.outline_text:\n        return f\"用户提供的大纲：\\n{project_context.outline_text}\"\n    if project_context.creation_type == 'descriptions' and project_context.description_text:\n        return f\"用户提供的描述：\\n{project_context.description_text}\"\n    return project_context.idea_prompt or \"\"\n\n\ndef _get_original_input_labeled(project_context: 'ProjectContext') -> str:\n    \"\"\"Build labeled original input section for refinement prompts.\"\"\"\n    text = \"\\n原始输入信息：\\n\"\n    if project_context.creation_type == 'idea' and project_context.idea_prompt:\n        text += f\"- PPT构想：{project_context.idea_prompt}\\n\"\n    elif project_context.creation_type == 'outline' and project_context.outline_text:\n        text += f\"- 用户提供的大纲文本：\\n{project_context.outline_text}\\n\"\n    elif project_context.creation_type == 'descriptions' and project_context.description_text:\n        text += f\"- 用户提供的页面描述文本：\\n{project_context.description_text}\\n\"\n    elif project_context.idea_prompt:\n        text += f\"- 用户输入：{project_context.idea_prompt}\\n\"\n    return text\n\n\ndef _get_previous_requirements_text(previous_requirements: Optional[List[str]]) -> str:\n    \"\"\"Format previous modification history.\"\"\"\n    if not previous_requirements:\n        return \"\"\n    prev_list = \"\\n\".join([f\"- {req}\" for req in previous_requirements])\n    return f\"\\n\\n之前用户提出的修改要求：\\n{prev_list}\\n\"\n\n\ndef _format_extra_field_instructions(extra_fields: list | None) -> str:\n    \"\"\"将额外字段列表格式化为 prompt 中的输出要求。\"\"\"\n    if not extra_fields:\n        return ''\n    parts = [f'{f}：[关于{f}的建议]' for f in extra_fields]\n    return '\\n'.join([''] + parts)  # 前导换行\n\n\ndef _format_reference_files_xml(reference_files_content: Optional[List[Dict[str, str]]]) -> str:\n    \"\"\"Format reference files content as XML structure.\"\"\"\n    if not reference_files_content:\n        return \"\"\n    xml_parts = [\"<uploaded_files>\"]\n    for file_info in reference_files_content:\n        filename = file_info.get('filename', 'unknown')\n        content = file_info.get('content', '')\n        xml_parts.append(f'  <file name=\"{filename}\">')\n        xml_parts.append('    <content>')\n        xml_parts.append(content)\n        xml_parts.append('    </content>')\n        xml_parts.append('  </file>')\n    xml_parts.append('</uploaded_files>')\n    xml_parts.append('')  # Empty line after XML\n    return '\\n'.join(xml_parts)\n\n\ndef _format_requirements(requirements: str, context: str = \"outline\") -> str:\n    \"\"\"格式化用户提供的生成要求，返回可直接拼接到 prompt 中的文本段。\n\n    context: \"outline\" 或 \"description\"，用于生成对应的结构标记示例。\n    \"\"\"\n    if requirements and requirements.strip():\n        if context == \"description\":\n            marker_example = (\n                \"For example, if the user asks to avoid certain symbols, \"\n                \"do NOT use them in the page content, but still use structural markers \"\n                \"like '页面文字：', '图片素材：', and '<!-- PAGE_END -->' as-is.\"\n            )\n        else:\n            marker_example = (\n                \"For example, if the user asks to avoid '#' symbols, \"\n                \"do NOT use '#' in the page content, but still use '## Title' as \"\n                \"the structural heading delimiter between pages.\"\n            )\n        return (\n            \"<user_requirements>\\n\"\n            f\"{requirements.strip()}\\n\"\n            \"</user_requirements>\\n\"\n            \"Note: The requirements above apply to the generated content of each page and \"\n            \"take precedence over other content-related instructions. The required output format \"\n            f\"and structural markers must still be used as-is. {marker_example}\\n\\n\"\n        )\n    return \"\"\n\n\ndef get_default_output_language() -> str:\n    \"\"\"获取环境变量中配置的默认输出语言\"\"\"\n    from config import Config\n    return getattr(Config, 'OUTPUT_LANGUAGE', 'zh')\n\n\ndef get_language_instruction(language: str = None) -> str:\n    \"\"\"获取语言限制指令文本\"\"\"\n    lang = language if language else get_default_output_language()\n    config = LANGUAGE_CONFIG.get(lang, LANGUAGE_CONFIG['zh'])\n    return config['instruction']\n\n\ndef get_ppt_language_instruction(language: str = None) -> str:\n    \"\"\"获取PPT文字语言限制指令\"\"\"\n    lang = language if language else get_default_output_language()\n    config = LANGUAGE_CONFIG.get(lang, LANGUAGE_CONFIG['zh'])\n    return config['ppt_text']\n\n\n# ═══════════════════════════════════════════════════════════════════════════════\n# 2. 大纲 Prompts — 生成、解析、细化大纲\n# ═══════════════════════════════════════════════════════════════════════════════\n\n\ndef get_outline_generation_prompt(project_context: 'ProjectContext', language: str = None) -> str:\n    \"\"\"生成 PPT 大纲的 prompt（JSON 输出）\"\"\"\n    idea_prompt = project_context.idea_prompt or \"\"\n\n    prompt = (f\"\"\"\\\nYou are a helpful assistant that generates an outline for a ppt.\n\nYou can organize the content in two ways:\n\n{_OUTLINE_JSON_FORMAT}\n\nChoose the format that best fits the content. Use parts when the PPT has clear major sections.\nUnless otherwise specified, the first page should be kept simplest, containing only the title, subtitle, and presenter information.\n\nThe user's request: {idea_prompt}.\n{_format_requirements(project_context.outline_requirements)}Now generate the outline, don't include any other text.\n{get_language_instruction(language)}\n\"\"\")\n\n    return _build_prompt(prompt, project_context.reference_files_content, tag='get_outline_generation_prompt')\n\n\ndef get_outline_generation_prompt_markdown(project_context: 'ProjectContext', language: str = None) -> str:\n    \"\"\"生成 PPT 大纲的 prompt（Markdown 输出，用于流式生成）\"\"\"\n    idea_prompt = project_context.idea_prompt or \"\"\n\n    prompt = (f\"\"\"\\\nYou are a helpful assistant that generates an outline for a ppt.\n\nYou can organize the content in two ways:\n\n1. Simple format (for short PPTs without major sections):\n## title1\n- point1\n- point2\n\n## title2\n- point1\n- point2\n\n2. Part-based format (for longer PPTs with major sections):\n# Part 1: Introduction\n## Welcome\n- point1\n- point2\n\n## Overview\n- point1\n- point2\n\n# Part 2: Main Content\n## Topic 1\n- point1\n- point2\n\n## Topic 2\n- point1\n- point2\n\nConstraints:\n- Title should not contain page number.\n- Choose the format that best fits the content. Use parts when the PPT has clear major sections.\n- Unless otherwise specified, the first page should be kept simplest, containing only the title, subtitle, and presenter information.\n\nThe user's request: {idea_prompt}.\n{_format_requirements(project_context.outline_requirements)}Now generate the outline, strictly follow the format provided above, don't include any other text. Output `<!-- END -->` on the last line when finished.\n{get_language_instruction(language)}\n\"\"\")\n\n    return _build_prompt(prompt, project_context.reference_files_content, tag='get_outline_generation_prompt_markdown')\n\n\ndef get_outline_parsing_prompt(project_context: 'ProjectContext', language: str = None) -> str:\n    \"\"\"解析用户提供的大纲文本的 prompt（JSON 输出）\"\"\"\n    outline_text = project_context.outline_text or \"\"\n\n    prompt = (f\"\"\"\\\nYou are a helpful assistant that parses a user-provided PPT outline text into a structured format.\n\nThe user has provided the following outline text:\n\n{outline_text}\n\nYour task is to analyze this text and convert it into a structured JSON format WITHOUT modifying any of the original text content.\nYou should only reorganize and structure the existing content, preserving all titles, points, and text exactly as provided.\n\nYou can organize the content in two ways:\n\n{_OUTLINE_JSON_FORMAT}\n\nImportant rules:\n- DO NOT modify, rewrite, or change any text from the original outline\n- DO NOT add new content that wasn't in the original text\n- DO NOT remove any content from the original text\n- Only reorganize the existing content into the structured format\n- Preserve all titles, bullet points, and text exactly as they appear\n- If the text has clear sections/parts, use the part-based format\n- Extract titles and points from the original text, keeping them exactly as written\n\nNow parse the outline text above into the structured format. Return only the JSON, don't include any other text.\n{get_language_instruction(language)}\n\"\"\")\n\n    return _build_prompt(prompt, project_context.reference_files_content, tag='get_outline_parsing_prompt')\n\n\ndef get_outline_parsing_prompt_markdown(project_context: 'ProjectContext', language: str = None) -> str:\n    \"\"\"解析用户提供的大纲文本的 prompt（Markdown 输出，用于流式生成）\"\"\"\n    outline_text = project_context.outline_text or \"\"\n\n    prompt = (f\"\"\"\\\nYou are a helpful assistant that parses a user-provided PPT outline text into a structured Markdown format.\n\nThe user has provided the following outline text:\n\n{outline_text}\n\nYour task is to analyze this text and convert it into a structured Markdown outline WITHOUT modifying any of the original text content.\n\nOutput rules:\n- Use `# Part Name` for major sections (only if the text has clear parts/chapters)\n- Use `## Page Title` for each page\n- Use `- ` bullet points for key points under each page\n- Preserve all titles, points, and text exactly as provided\n- Do NOT wrap in code blocks or add any extra text\n\nNow parse the outline text above into the Markdown format. Output `<!-- END -->` on the last line when finished.\n{get_language_instruction(language)}\n\"\"\")\n\n    return _build_prompt(prompt, project_context.reference_files_content, tag='get_outline_parsing_prompt_markdown')\n\n\ndef get_description_to_outline_prompt(project_context: 'ProjectContext', language: str = None) -> str:\n    \"\"\"从描述文本解析出大纲的 prompt（JSON 输出）\"\"\"\n    description_text = project_context.description_text or \"\"\n\n    prompt = (f\"\"\"\\\nYou are a helpful assistant that analyzes a user-provided PPT description text and extracts the outline structure from it.\n\nThe user has provided the following description text:\n\n{description_text}\n\nYour task is to analyze this text and extract the outline structure (titles and key points) for each page.\nYou should identify:\n1. How many pages are described\n2. The title for each page\n3. The key points or content structure for each page\n\nYou can organize the content in two ways:\n\n{_OUTLINE_JSON_FORMAT}\n\nImportant rules:\n- Extract the outline structure from the description text\n- Identify page titles and key points\n- If the text has clear sections/parts, use the part-based format\n- Preserve the logical structure and organization from the original text\n- The points should be concise summaries of the main content for each page\n\nNow extract the outline structure from the description text above. Return only the JSON, don't include any other text.\n{get_language_instruction(language)}\n\"\"\")\n\n    return _build_prompt(prompt, project_context.reference_files_content, tag='get_description_to_outline_prompt')\n\n\ndef get_description_to_outline_prompt_markdown(project_context: 'ProjectContext', language: str = None) -> str:\n    \"\"\"从描述文本解析出大纲的 prompt（Markdown 输出，用于流式生成）\"\"\"\n    description_text = project_context.description_text or \"\"\n\n    prompt = (f\"\"\"\\\nYou are a helpful assistant that analyzes a user-provided PPT description text and extracts the outline structure.\n\nThe user has provided the following description text:\n\n{description_text}\n\nYour task is to extract the outline structure (titles and key points) for each page.\n\nOutput rules:\n- Use `# Part Name` for major sections (only if the text has clear parts/chapters)\n- Use `## Page Title` for each page\n- Use `- ` bullet points for key points under each page\n- Preserve the logical structure from the original text\n- Do NOT wrap in code blocks or add any extra text\n\nNow extract the outline structure from the description text above. Output `<!-- END -->` on the last line when finished.\n{get_language_instruction(language)}\n\"\"\")\n\n    return _build_prompt(prompt, project_context.reference_files_content, tag='get_description_to_outline_prompt_markdown')\n\n\ndef get_outline_refinement_prompt(current_outline: List[Dict], user_requirement: str,\n                                   project_context: 'ProjectContext',\n                                   previous_requirements: Optional[List[str]] = None,\n                                   language: str = None) -> str:\n    \"\"\"根据用户要求修改已有大纲的 prompt\"\"\"\n    if not current_outline or len(current_outline) == 0:\n        outline_text = \"(当前没有内容)\"\n    else:\n        outline_text = json.dumps(current_outline, ensure_ascii=False, indent=2)\n\n    prompt = (f\"\"\"\\\nYou are a helpful assistant that modifies PPT outlines based on user requirements.\n{_get_original_input_labeled(project_context)}\n当前的 PPT 大纲结构如下：\n\n{outline_text}\n{_get_previous_requirements_text(previous_requirements)}\n**用户现在提出新的要求：{user_requirement}**\n\n请根据用户的要求修改和调整大纲。你可以：\n- 添加、删除或重新排列页面\n- 修改页面标题和要点\n- 调整页面的组织结构\n- 添加或删除章节（part）\n- 合并或拆分页面\n- 根据用户要求进行任何合理的调整\n- 如果当前没有内容，请根据用户要求和原始输入信息创建新的大纲\n\n输出格式可以选择：\n\n1. 简单格式（适用于没有主要章节的短 PPT）：\n[{{\"title\": \"title1\", \"points\": [\"point1\", \"point2\"]}}, {{\"title\": \"title2\", \"points\": [\"point1\", \"point2\"]}}]\n\n2. 基于章节的格式（适用于有明确主要章节的长 PPT）：\n[\n    {{\n    \"part\": \"第一部分：引言\",\n    \"pages\": [\n        {{\"title\": \"欢迎\", \"points\": [\"point1\", \"point2\"]}},\n        {{\"title\": \"概述\", \"points\": [\"point1\", \"point2\"]}}\n    ]\n    }},\n    {{\n    \"part\": \"第二部分：主要内容\",\n    \"pages\": [\n        {{\"title\": \"主题1\", \"points\": [\"point1\", \"point2\"]}},\n        {{\"title\": \"主题2\", \"points\": [\"point1\", \"point2\"]}}\n    ]\n    }}\n]\n\n选择最适合内容的格式。当 PPT 有清晰的主要章节时使用章节格式。\n\n现在请根据用户要求修改大纲，只输出 JSON 格式的大纲，不要包含其他文字。\n{get_language_instruction(language)}\n\"\"\")\n\n    return _build_prompt(prompt, project_context.reference_files_content, tag='get_outline_refinement_prompt')\n\n\n# ═══════════════════════════════════════════════════════════════════════════════\n# 3. 描述 Prompts — 单页、流式、拆分、细化描述\n# ═══════════════════════════════════════════════════════════════════════════════\n\n\ndef get_page_description_prompt(project_context: 'ProjectContext', outline: list,\n                                page_outline: dict, page_index: int,\n                                part_info: str = \"\",\n                                language: str = None,\n                                detail_level: str = \"default\",\n                                extra_fields: list = None) -> str:\n    \"\"\"生成单个页面描述的 prompt\"\"\"\n    original_input = _get_original_input(project_context)\n\n    # 单页版使用简短的 concise 描述（与流式版略有不同）\n    detail_level_specs = {\n        'concise': '文字极致地压缩和精简',\n        'default': '清晰明了，每条要点控制在15-20字以内, 避免冗长的句子和复杂的表述',\n        'detailed': '忠于原文的基础上做到内容详实，逻辑清晰。',\n    }\n\n    prompt = (f\"\"\"\\\n我们正在为PPT的每一页生成内容描述。\n用户的原始需求是：\\n{original_input}\\n\n我们已经有了完整的大纲：\\n{outline}\\n{part_info}\n{_format_requirements(project_context.description_requirements, \"description\")}现在请为第 {page_index} 页生成描述：\n{page_outline}\n{\"**除非特殊要求，第一页的内容需要保持极简，只放标题副标题以及演讲人等（输出到标题后）, 不添加任何素材。**\" if page_index == 1 else \"\"}\n## 重要提示\n生成的\"页面文字\"部分会直接渲染到PPT页面上，因此请务必不要包含任何额外的说明性文字或注释。\n\n## 输出格式\n\n页面文字：\n\n[此处使用markdown直接放置正文文字, 细致程度要求：{detail_level_specs[detail_level]}\\n\\n, 可包含latex公式、表格等内容, 不要重复添加]\n\n图片素材:\n[如果文件中存在图片请积极添加； 否则忽略图片素材字段]\n{_format_extra_field_instructions(extra_fields)}\n\n## 关于图片\n如果参考文件中包含以 /files/ 开头的本地文件URL图片（例如 /files/mineru/xxx/image.png），请将这些图片以markdown格式输出，例如：![图片描述](/files/mineru/xxx/image.png)。这些图片会被包含在PPT页面中。\n{get_language_instruction(language)}\n\"\"\")\n\n    return _build_prompt(prompt, project_context.reference_files_content, tag='get_page_description_prompt')\n\n\ndef get_all_descriptions_stream_prompt(project_context: 'ProjectContext',\n                                       outline: list,\n                                       flat_pages: list,\n                                       language: str = None,\n                                       detail_level: str = \"default\",\n                                       extra_fields: list = None) -> str:\n    \"\"\"一次性生成所有页面描述的 prompt（用于流式生成）\"\"\"\n    original_input = _get_original_input(project_context)\n\n    # 构建页面大纲列表\n    outline_lines = []\n    for i, page in enumerate(flat_pages):\n        part_str = f\"  [章节: {page['part']}]\" if page.get('part') else \"\"\n        points_str = \", \".join(page.get('points', []))\n        outline_lines.append(f\"第 {i + 1} 页：{page.get('title', '')}{part_str}\\n  要点：{points_str}\")\n    pages_outline_text = \"\\n\".join(outline_lines)\n\n    prompt = (f\"\"\"\\\n我们正在为PPT的每一页生成内容描述。\n用户的原始需求是：\\n{original_input}\\n\n完整大纲如下：\n{pages_outline_text}\n\n{_format_requirements(project_context.description_requirements, \"description\")}请为每一页依次生成描述。先输出 `<!-- BEGIN -->` 标记开始，然后逐页输出内容，每页用 `<!-- PAGE_END -->` 结束，全部完成后输出 `<!-- END -->`。\n\n## 重要提示\n- 生成的页面文字会直接渲染到PPT页面上，请务必不要包含任何额外的说明性文字或注释。\n- **第一页（封面页）保持极简**，只放标题、副标题、演讲人等信息，不添加任何素材。\n- 细致程度要求：{DETAIL_LEVEL_SPECS[detail_level]}\n\n## 输出格式\n每页默认包含\"页面文字\"和\"图片素材\"两个部分。图片素材用于引用参考文件中的图片（以 /files/ 开头的本地路径），如果参考文件中没有相关图片则省略该部分。\n```\n<!-- BEGIN -->\n页面文字：\n[第1页文字内容，可包含标题、副标题、要点、latex公式、表格等，根据实际需求选择，避免堆砌和重复]\n\n图片素材：\n[如果参考文件中存在相关图片，以markdown格式引用，如 ![描述](/files/xxx/image.png)；否则省略此部分。如果用户上传了图片素材请积极地添加]\n{_format_extra_field_instructions(extra_fields)}\n<!-- PAGE_END -->\n页面文字：\n[第2页文字内容]\n\n图片素材：\n[同上]\n{_format_extra_field_instructions(extra_fields)}\n<!-- PAGE_END -->\n...\n<!-- END -->\n```\n\n现在请开始生成，严格按照上述格式输出。\n{get_language_instruction(language)}\n\"\"\")\n\n    return _build_prompt(prompt, project_context.reference_files_content, tag='get_all_descriptions_stream_prompt')\n\n\ndef get_description_split_prompt(project_context: 'ProjectContext',\n                                 outline: List[Dict],\n                                 language: str = None) -> str:\n    \"\"\"从描述文本切分出每页描述的 prompt\"\"\"\n    outline_json = json.dumps(outline, ensure_ascii=False, indent=2)\n    description_text = project_context.description_text or \"\"\n\n    prompt = (f\"\"\"\\\nYou are a helpful assistant that splits a complete PPT description text into individual page descriptions.\n\nThe user has provided a complete description text:\n\n{description_text}\n\nWe have already extracted the outline structure:\n\n{outline_json}\n\nYour task is to split the description text into individual page descriptions based on the outline structure.\nFor each page in the outline, extract the corresponding description from the original text.\n\nReturn a JSON array where each element corresponds to a page in the outline (in the same order).\nEach element should be a string containing the page description in the following format:\n\n页面标题：[页面标题]\n\n页面文字：\n- [要点1]\n- [要点2]\n...\n\n其他页面素材（如果有排版、风格、素材等细节）\n\nExample output format:\n[\n    \"页面标题：人工智能的诞生\\\\n页面文字：\\\\n- 1950 年，图灵提出\"图灵测试\"\\\\n- 奠定了AI的理论基础\\\\n\\\\n其他页面素材：\\\\n排版：标题居中，大字号\\\\n风格：科技感蓝色背景\",\n    \"页面标题：AI 的发展历程\\\\n页面文字：\\\\n- 1950年代：符号主义...\",\n    ...\n]\n\nImportant rules:\n- Split the description text according to the outline structure\n- Each page description should match the corresponding page in the outline\n- Preserve all important content from the original text, including layout details (排版细节), style requirements (风格要求), material specifications (素材说明), and any other design requirements\n- If the user described layout, style, or materials for a page, include them in the \"其他页面素材\" section\n- Keep the format consistent with the example above\n- If a page in the outline doesn't have a clear description in the text, create a reasonable description based on the outline\n\nNow split the description text into individual page descriptions. Return only the JSON array, don't include any other text.\n{get_language_instruction(language)}\n\"\"\")\n\n    logger.debug(f\"[get_description_split_prompt] Final prompt:\\n{prompt}\")\n    return prompt\n\n\ndef get_descriptions_refinement_prompt(current_descriptions: List[Dict], user_requirement: str,\n                                       project_context: 'ProjectContext',\n                                       outline: List[Dict] = None,\n                                       previous_requirements: Optional[List[str]] = None,\n                                       language: str = None) -> str:\n    \"\"\"根据用户要求修改已有页面描述的 prompt\"\"\"\n    # 构建大纲文本\n    outline_text = \"\"\n    if outline:\n        outline_json = json.dumps(outline, ensure_ascii=False, indent=2)\n        outline_text = f\"\\n\\n完整的 PPT 大纲：\\n{outline_json}\\n\"\n\n    # 构建所有页面描述的汇总\n    all_descriptions_text = \"当前所有页面的描述：\\n\\n\"\n    has_any_description = False\n    for desc in current_descriptions:\n        page_num = desc.get('index', 0) + 1\n        title = desc.get('title', '未命名')\n        content = desc.get('description_content', '')\n        if isinstance(content, dict):\n            content = content.get('text', '')\n\n        if content:\n            has_any_description = True\n            all_descriptions_text += f\"--- 第 {page_num} 页：{title} ---\\n{content}\\n\\n\"\n        else:\n            all_descriptions_text += f\"--- 第 {page_num} 页：{title} ---\\n(当前没有内容)\\n\\n\"\n\n    if not has_any_description:\n        all_descriptions_text = \"当前所有页面的描述：\\n\\n(当前没有内容，需要基于大纲生成新的描述)\\n\\n\"\n\n    prompt = (f\"\"\"\\\nYou are a helpful assistant that modifies PPT page descriptions based on user requirements.\n{_get_original_input_labeled(project_context)}{outline_text}\n{all_descriptions_text}\n{_get_previous_requirements_text(previous_requirements)}\n**用户现在提出新的要求：{user_requirement}**\n\n请根据用户的要求修改和调整所有页面的描述。你可以：\n- 修改页面标题和内容\n- 调整页面文字的详细程度\n- 添加或删除要点\n- 调整描述的结构和表达\n- 确保所有页面描述都符合用户的要求\n- 如果当前没有内容，请根据大纲和用户要求创建新的描述\n\n请为每个页面生成修改后的描述，格式如下：\n\n页面标题：[页面标题]\n\n页面文字：\n- [要点1]\n- [要点2]\n...\n其他页面素材（如果有请加上，包括markdown图片链接等）\n\n提示：如果参考文件中包含以 /files/ 开头的本地文件URL图片（例如 /files/mineru/xxx/image.png），请将这些图片以markdown格式输出，例如：![图片描述](/files/mineru/xxx/image.png)，而不是作为普通文本。\n\n请返回一个 JSON 数组，每个元素是一个字符串，对应每个页面的修改后描述（按页面顺序）。\n\n示例输出格式：\n[\n    \"页面标题：人工智能的诞生\\\\n页面文字：\\\\n- 1950 年，图灵提出\\\\\"图灵测试\\\\\"...\",\n    \"页面标题：AI 的发展历程\\\\n页面文字：\\\\n- 1950年代：符号主义...\",\n    ...\n]\n\n现在请根据用户要求修改所有页面描述，只输出 JSON 数组，不要包含其他文字。\n{get_language_instruction(language)}\n\"\"\")\n\n    return _build_prompt(prompt, project_context.reference_files_content, tag='get_descriptions_refinement_prompt')\n\n\n# ═══════════════════════════════════════════════════════════════════════════════\n# 4. 图片生成 Prompts — 文生图、图片编辑\n# ═══════════════════════════════════════════════════════════════════════════════\n\n\ndef get_image_generation_prompt(page_desc: str, outline_text: str,\n                                current_section: str,\n                                has_material_images: bool = False,\n                                extra_requirements: str = None,\n                                language: str = None,\n                                has_template: bool = True,\n                                page_index: int = 1,\n                                aspect_ratio: str = \"16:9\") -> str:\n    \"\"\"生成图片生成 prompt\"\"\"\n    material_images_note = \"\"\n    if has_material_images:\n        material_images_note = (\n            \"\\n\\n提示：\" + (\"除了模板参考图片（用于风格参考）外，还提供了额外的素材图片。\" if has_template else \"用户提供了额外的素材图片。\") +\n            \"这些素材图片是可供挑选和使用的元素，你可以从这些素材图片中选择合适的图片、图标、图表或其他视觉元素\"\n            \"直接整合到生成的PPT页面中。请根据页面内容的需要，智能地选择和组合这些素材图片中的元素。\"\n        )\n\n    extra_req_text = \"\"\n    if extra_requirements and extra_requirements.strip():\n        extra_req_text = f\"\\n\\n额外要求（请务必遵循）：\\n{extra_requirements}\\n\"\n\n    template_style_guideline = \"- 配色和设计语言和模板图片严格相似。\" if has_template else \"- 严格按照风格描述进行设计。\"\n    forbidden_template_text_guidline = \"- 只参考风格设计，禁止出现模板中的文字。\\n\" if has_template else \"\"\n\n    prompt = (f\"\"\"\\\n你是一位专家级UI UX演示设计师，专注于生成设计良好的PPT页面。\n当前PPT页面的页面描述如下:\n<page_description>\n{page_desc}\n</page_description>\n\n<design_guidelines>\n- 要求文字清晰锐利, 画面为4K分辨率，{aspect_ratio}比例。\n{template_style_guideline}\n- 根据内容和要求自动设计最完美的构图，不重不漏地渲染\"页面文字\"段落中的文本。\n- 如非必要，禁止出现 markdown 格式符号（如 # 和 * 等）。\n{forbidden_template_text_guidline}\n</design_guidelines>\n{get_ppt_language_instruction(language)}\n{material_images_note}{extra_req_text}\n\n{\"**注意：当前页面为ppt的封面页，请你采用专业的封面设计美学技巧，务必凸显出页面标题，分清主次，确保一下就能抓住观众的注意力。**\" if page_index == 1 else \"\"}\n\"\"\")\n\n    logger.debug(f\"[get_image_generation_prompt] Final prompt:\\n{prompt}\")\n    return prompt\n\n\ndef get_image_edit_prompt(edit_instruction: str, original_description: str = None) -> str:\n    \"\"\"生成图片编辑 prompt\"\"\"\n    if original_description:\n        if \"其他页面素材\" in original_description:\n            original_description = original_description.split(\"其他页面素材\")[0].strip()\n\n        prompt = (f\"\"\"\\\n该PPT页面的原始页面描述为：\n{original_description}\n\n现在，根据以下指令修改这张PPT页面：{edit_instruction}\n\n要求维持原有的文字内容和设计风格，只按照指令进行修改。提供的参考图中既有新素材，也有用户手动框选出的区域，请你根据原图和参考图的关系智能判断用户意图。\n\"\"\")\n    else:\n        prompt = f\"根据以下指令修改这张PPT页面：{edit_instruction}\\n保持原有的内容结构和设计风格，只按照指令进行修改。提供的参考图中既有新素材，也有用户手动框选出的区域，请你根据原图和参考图的关系智能判断用户意图。\"\n\n    logger.debug(f\"[get_image_edit_prompt] Final prompt:\\n{prompt}\")\n    return prompt\n\n\n# ═══════════════════════════════════════════════════════════════════════════════\n# 5. 图片处理 Prompts — 背景提取、画质修复\n# ═══════════════════════════════════════════════════════════════════════════════\n\n\ndef get_clean_background_prompt() -> str:\n    \"\"\"生成纯背景图的 prompt（去除文字和插画）\"\"\"\n    prompt = \"\"\"\\\n你是一位专业的图片文字&图片擦除专家。你的任务是从原始图片中移除文字和配图，输出一张无任何文字和图表内容、干净纯净的底板图。\n<requirements>\n- 彻底移除页面中的所有文字、插画、图表。必须确保所有文字都被完全去除。\n- 保持原背景设计的完整性（包括渐变、纹理、图案、线条、色块等）。保留原图的文本框和色块。\n- 对于被前景元素遮挡的背景区域，要智能填补，使背景保持无缝和完整，就像被移除的元素从来没有出现过。\n- 输出图片的尺寸、风格、配色必须和原图完全一致。\n- 请勿新增任何元素。\n</requirements>\n\n注意，**任意位置的, 所有的**文字和图表都应该被彻底移除，**输出不应该包含任何文字和图表。**\n\"\"\"\n    logger.debug(f\"[get_clean_background_prompt] Final prompt:\\n{prompt}\")\n    return prompt\n\n\ndef get_quality_enhancement_prompt(inpainted_regions: list = None) -> str:\n    \"\"\"生成画质提升的 prompt（用于百度图像修复后的画质修复）\"\"\"\n    regions_info = \"\"\n    if inpainted_regions and len(inpainted_regions) > 0:\n        regions_json = json.dumps(inpainted_regions, ensure_ascii=False, indent=2)\n        regions_info = f\"\"\"\n以下是被抹除工具处理过的具体区域（共 {len(inpainted_regions)} 个矩形区域），请重点修复这些位置：\n\n```json\n{regions_json}\n```\n\n坐标说明（所有数值都是相对于图片宽高的百分比，范围0-100%）：\n- left: 区域左边缘距离图片左边缘的百分比\n- top: 区域上边缘距离图片上边缘的百分比\n- right: 区域右边缘距离图片左边缘的百分比\n- bottom: 区域下边缘距离图片上边缘的百分比\n- width_percent: 区域宽度占图片宽度的百分比\n- height_percent: 区域高度占图片高度的百分比\n\n例如：left=10 表示区域从图片左侧10%的位置开始。\n\"\"\"\n\n    prompt = f\"\"\"\\\n你是一位专业的图像修复专家。这张ppt页面图片刚刚经过了文字/对象抹除操作，抹除工具在指定区域留下了一些修复痕迹，包括：\n- 色块不均匀、颜色不连贯\n- 模糊的斑块或涂抹痕迹\n- 与周围背景不协调的区域，比如不和谐的渐变色块\n- 可能的纹理断裂或图案不连续\n{regions_info}\n你的任务是修复这些抹除痕迹，让图片看起来像从未有过对象抹除操作一样自然。\n\n要求：\n- **重点修复上述标注的区域**：这些区域刚刚经过抹除处理，需要让它们与周围背景完美融合\n- 保持纹理、颜色、图案的连续性\n- 提升整体画质，消除模糊、噪点、伪影\n- 保持图片的原始构图、布局、色调风格\n- 禁止添加任何文字、图表、插画、图案、边框等元素\n- 除了上述区域，其他区域不要做任何修改，保持和原图像素级别地一致。\n- 输出图片的尺寸必须与原图一致\n\n请输出修复后的高清ppt页面背景图片，不要遗漏修复任何一个被涂抹的区域。\n\"\"\"\n    return prompt\n\n\n# ═══════════════════════════════════════════════════════════════════════════════\n# 6. 内容提取 Prompts — 文字属性、页面内容、排版分析、风格提取\n# ═══════════════════════════════════════════════════════════════════════════════\n\n\ndef get_text_attribute_extraction_prompt(content_hint: str = \"\") -> str:\n    \"\"\"生成文字属性提取的 prompt（提取文字内容、颜色、公式等信息）\"\"\"\n    prompt = \"\"\"你的任务是精确识别这张图片中的文字内容和样式，返回JSON格式的结果。\n\n{content_hint}\n\n## 核心任务\n请仔细观察图片，精确识别：\n1. **文字内容** - 输出你实际看到的文字符号。\n2. **颜色** - 每个字/词的实际颜色\n3. **空格** - 精确识别文本中空格的位置和数量\n4. **公式** - 如果是数学公式，输出 LaTeX 格式\n\n## 注意事项\n- **空格识别**：必须精确还原空格数量，多个连续空格要完整保留，不要合并或省略\n- **颜色分割**：一行文字可能有多种颜色，按颜色分割成片段，一般来说只有两种颜色。\n- **公式识别**：如果片段是数学公式，设置 is_latex=true 并用 LaTeX 格式输出\n- **相邻合并**：相同颜色的相邻普通文字应合并为一个片段\n\n## 输出格式\n- colored_segments: 文字片段数组，每个片段包含：\n  - text: 文字内容（公式时为 LaTeX 格式，如 \"x^2\"、\"\\\\sum_{{i=1}}^n\"）\n  - color: 颜色，十六进制格式 \"#RRGGBB\"\n  - is_latex: 布尔值，true 表示这是一个 LaTeX 公式片段（可选，默认 false）\n\n只返回JSON对象，不要包含任何其他文字。\n示例输出：\n```json\n{{\n    \"colored_segments\": [\n        {{\"text\": \"·  创新合成\", \"color\": \"#000000\"}},\n        {{\"text\": \"1827个任务环境\", \"color\": \"#26397A\"}},\n        {{\"text\": \"与\", \"color\": \"#000000\"}},\n        {{\"text\": \"8.5万提示词\", \"color\": \"#26397A\"}},\n        {{\"text\": \"突破数据瓶颈\", \"color\": \"#000000\"}},\n        {{\"text\": \"x^2 + y^2 = z^2\", \"color\": \"#FF0000\", \"is_latex\": true}}\n    ]\n}}\n```\n\"\"\".format(content_hint=content_hint)\n\n    return prompt\n\n\ndef get_batch_text_attribute_extraction_prompt(text_elements_json: str) -> str:\n    \"\"\"生成批量文字属性提取的 prompt（给模型全图 + 所有文本元素的 bbox）\"\"\"\n    prompt = f\"\"\"你是一位专业的 PPT/文档排版分析专家。请分析这张图片中所有标注的文字区域的样式属性。\n\n我已经从图片中提取了以下文字元素及其位置信息：\n\n```json\n{text_elements_json}\n```\n\n请仔细观察图片，对比每个文字区域在图片中的实际视觉效果，为每个元素分析以下属性：\n\n1. **font_color**: 字体颜色的十六进制值，格式为 \"#RRGGBB\"\n   - 请仔细观察文字的实际颜色，不要只返回黑色\n   - 常见颜色如：白色 \"#FFFFFF\"、蓝色 \"#0066CC\"、红色 \"#FF0000\" 等\n\n2. **is_bold**: 是否为粗体 (true/false)\n   - 观察笔画粗细，标题通常是粗体\n\n3. **is_italic**: 是否为斜体 (true/false)\n\n4. **is_underline**: 是否有下划线 (true/false)\n\n5. **text_alignment**: 文字对齐方式\n   - \"left\": 左对齐\n   - \"center\": 居中对齐\n   - \"right\": 右对齐\n   - \"justify\": 两端对齐\n   - 如果无法判断，根据文字在其区域内的位置推测\n\n请返回一个 JSON 数组，数组中每个对象对应输入的一个元素（按相同顺序），包含以下字段：\n- element_id: 与输入相同的元素ID\n- text_content: 文字内容\n- font_color: 颜色十六进制值\n- is_bold: 布尔值\n- is_italic: 布尔值\n- is_underline: 布尔值\n- text_alignment: 对齐方式字符串\n\n只返回 JSON 数组，不要包含其他文字：\n```json\n[\n    {{\n        \"element_id\": \"xxx\",\n        \"text_content\": \"文字内容\",\n        \"font_color\": \"#RRGGBB\",\n        \"is_bold\": true/false,\n        \"is_italic\": true/false,\n        \"is_underline\": true/false,\n        \"text_alignment\": \"对齐方式\"\n    }},\n    ...\n]\n```\n\"\"\"\n\n    return prompt\n\n\ndef get_ppt_page_content_extraction_prompt(markdown_text: str, language: str = None) -> str:\n    \"\"\"从 fileparser 解析出的 markdown 文本中提取页面内容（title, points, description）\"\"\"\n    prompt = f\"\"\"\\\nYou are a helpful assistant that extracts structured PPT page content from parsed document text.\n\nThe following markdown text was extracted from a single PPT slide:\n\n<slide_content>\n{markdown_text}\n</slide_content>\n\nYour task is to extract the following structured information from this slide:\n\n1. **title**: The main title/heading of the slide\n2. **points**: A list of key bullet points or content items on the slide\n3. **description**: A complete page description suitable for regenerating this slide, following this format:\n\n页面标题：[title]\n\n页面文字：\n- [point 1]\n- [point 2]\n...\n\n其他页面素材（如果有图表、表格、公式等描述，保留原文中的markdown图片完整形式）\n\nRules:\n- Extract the title faithfully from the first heading in the markdown. Do NOT invent or rephrase it\n- Points must be extracted verbatim from the slide content, in their original order\n- In the description, 页面标题 and 页面文字 must be copied verbatim from the original text (punctuation may be normalized, but wording must be identical)\n- The description should capture ALL content on the slide including text, data, and visual element descriptions\n- If there are tables, charts, or formulas, describe them in the description under \"其他页面素材\"\n- Preserve the original language of the content\n\nReturn a JSON object with exactly these three fields: \"title\", \"points\" (array of strings), \"description\" (string).\nReturn only the JSON, no other text.\n{get_language_instruction(language)}\n\"\"\"\n    logger.debug(f\"[get_ppt_page_content_extraction_prompt] Final prompt:\\n{prompt}\")\n    return prompt\n\n\ndef get_layout_caption_prompt() -> str:\n    \"\"\"描述 PPT 页面的排版布局（给 caption model 用）\"\"\"\n    prompt = \"\"\"\\\nYou are a professional PPT layout analyst. Describe the visual layout and composition of this PPT slide image in detail.\n\nFocus on:\n1. **Overall layout**: How elements are arranged (e.g., title at top, content in two columns, image on the right)\n2. **Text placement**: Where text blocks are positioned, their relative sizes, alignment\n3. **Visual elements**: Position and size of images, charts, icons, decorative elements\n4. **Spacing and proportions**: How space is distributed between elements\n\nOutput a concise layout description in Chinese that can be used to recreate a similar layout. Format:\n\n排版布局：\n- 整体结构：[描述]\n- 标题位置：[描述]\n- 内容区域：[描述]\n- 视觉元素：[描述]\n\nOnly describe the layout and spatial arrangement. Do not describe colors, text content, or style.\n\"\"\"\n    logger.debug(f\"[get_layout_caption_prompt] Final prompt:\\n{prompt}\")\n    return prompt\n\n\ndef get_style_extraction_prompt() -> str:\n    \"\"\"从图片中提取风格描述（通用，可复用于所有创建模式）\"\"\"\n    prompt = \"\"\"\\\nYou are a professional PPT design analyst. Analyze this image and extract a detailed style description that can be used to generate PPT slides with a similar visual style.\n\nFocus on:\n1. **Color palette**: Primary colors, secondary colors, accent colors, background colors\n2. **Typography style**: Font style impression (serif/sans-serif, weight, size hierarchy)\n3. **Design elements**: Decorative patterns, shapes, icons style, borders, shadows\n4. **Overall mood**: Professional, playful, minimalist, corporate, creative, etc.\n5. **Layout tendencies**: How content is typically arranged, spacing preferences\n\nOutput a concise style description in Chinese that can be directly used as a style prompt for PPT generation. Write it as a single paragraph, not a list. Example:\n\n\"采用深蓝色渐变背景，搭配白色和金色文字。整体风格简约商务，使用无衬线字体，标题加粗突出。页面装饰以几何线条和半透明色块为主，配色统一协调。内容区域留白充足，视觉层次分明。\"\n\nOnly output the style description text, no other content.\n\"\"\"\n    logger.debug(f\"[get_style_extraction_prompt] Final prompt:\\n{prompt}\")\n    return prompt\n"
  },
  {
    "path": "backend/services/task_manager.py",
    "content": "\"\"\"\nTask Manager - handles background tasks using ThreadPoolExecutor\nNo need for Celery or Redis, uses in-memory task tracking\n\"\"\"\nimport logging\nimport os\nimport threading\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom typing import Callable, List, Dict, Any, Optional\nfrom datetime import datetime\nfrom sqlalchemy import func\nfrom PIL import Image\nfrom models import db, Task, Page, Material, PageImageVersion\nfrom utils import get_filtered_pages\nfrom utils.image_utils import check_image_resolution\n\n\ndef _get_image_prompt_field_names() -> set | None:\n    \"\"\"读取设置中允许进入文生图 prompt 的额外字段名。返回 None 表示全部允许。\"\"\"\n    try:\n        from models import Settings\n        settings = Settings.get_settings()\n        if settings.image_prompt_extra_fields is None:\n            return None  # 未配置 → 全部允许\n        return set(settings.get_image_prompt_extra_fields())\n    except Exception:\n        return None\n\n\ndef _append_extra_fields(desc_text: str, desc_content: dict) -> str:\n    \"\"\"将 extra_fields 拼接到描述文本末尾，供图片生成 prompt 使用。\"\"\"\n    extra_fields = desc_content.get('extra_fields')\n    if not extra_fields or not isinstance(extra_fields, dict):\n        return desc_text\n    allowed = _get_image_prompt_field_names()\n    parts = [desc_text]\n    for name, value in extra_fields.items():\n        if value and (allowed is None or name in allowed):\n            parts.append(f\"\\n{name}：{value}\")\n    return ''.join(parts)\nfrom pathlib import Path\nfrom services.pdf_service import split_pdf_to_pages\n\nlogger = logging.getLogger(__name__)\n\n\nclass TaskManager:\n    \"\"\"Simple task manager using ThreadPoolExecutor\"\"\"\n    \n    def __init__(self, max_workers: int = 4):\n        \"\"\"Initialize task manager\"\"\"\n        self.executor = ThreadPoolExecutor(max_workers=max_workers)\n        self.active_tasks = {}  # task_id -> Future\n        self.lock = threading.Lock()\n    \n    def submit_task(self, task_id: str, func: Callable, *args, **kwargs):\n        \"\"\"Submit a background task\"\"\"\n        future = self.executor.submit(func, task_id, *args, **kwargs)\n        \n        with self.lock:\n            self.active_tasks[task_id] = future\n        \n        # Add callback to clean up when done and log exceptions\n        future.add_done_callback(lambda f: self._task_done_callback(task_id, f))\n    \n    def _task_done_callback(self, task_id: str, future):\n        \"\"\"Handle task completion and log any exceptions\"\"\"\n        try:\n            # Check if task raised an exception\n            exception = future.exception()\n            if exception:\n                logger.error(f\"Task {task_id} failed with exception: {exception}\", exc_info=exception)\n        except Exception as e:\n            logger.error(f\"Error in task callback for {task_id}: {e}\", exc_info=True)\n        finally:\n            self._cleanup_task(task_id)\n    \n    def _cleanup_task(self, task_id: str):\n        \"\"\"Clean up completed task\"\"\"\n        with self.lock:\n            if task_id in self.active_tasks:\n                del self.active_tasks[task_id]\n    \n    def is_task_active(self, task_id: str) -> bool:\n        \"\"\"Check if task is still running\"\"\"\n        with self.lock:\n            return task_id in self.active_tasks\n    \n    def shutdown(self):\n        \"\"\"Shutdown the executor\"\"\"\n        self.executor.shutdown(wait=True)\n\n\n# Global task manager instance\ntask_manager = TaskManager(max_workers=4)\n\n\ndef save_image_with_version(image, project_id: str, page_id: str, file_service,\n                            page_obj=None, image_format: str = 'PNG') -> tuple[str, int]:\n    \"\"\"\n    保存图片并创建历史版本记录的公共函数\n\n    Args:\n        image: PIL Image 对象\n        project_id: 项目ID\n        page_id: 页面ID\n        file_service: FileService 实例\n        page_obj: Page 对象（可选，如果提供则更新页面状态）\n        image_format: 图片格式，默认 PNG\n\n    Returns:\n        tuple: (image_path, version_number) - 图片路径和版本号\n\n    这个函数会：\n    1. 计算下一个版本号（使用 MAX 查询确保安全）\n    2. 标记所有旧版本为非当前版本\n    3. 保存图片到最终位置\n    4. 生成并保存压缩的缓存图片\n    5. 创建新版本记录\n    6. 如果提供了 page_obj，更新页面状态和图片路径\n    \"\"\"\n    # 使用 MAX 查询确保版本号安全（即使有版本被删除也不会重复）\n    max_version = db.session.query(func.max(PageImageVersion.version_number)).filter_by(page_id=page_id).scalar() or 0\n    next_version = max_version + 1\n\n    # 批量更新：标记所有旧版本为非当前版本（使用单条 SQL 更高效）\n    PageImageVersion.query.filter_by(page_id=page_id).update({'is_current': False})\n\n    # 保存原图到最终位置（使用版本号）\n    image_path = file_service.save_generated_image(\n        image, project_id, page_id,\n        version_number=next_version,\n        image_format=image_format\n    )\n\n    # 生成并保存压缩的缓存图片（用于前端快速显示）\n    cached_image_path = file_service.save_cached_image(\n        image, project_id, page_id,\n        version_number=next_version,\n        quality=85\n    )\n\n    # 创建新版本记录\n    new_version = PageImageVersion(\n        page_id=page_id,\n        image_path=image_path,\n        version_number=next_version,\n        is_current=True\n    )\n    db.session.add(new_version)\n\n    # 如果提供了 page_obj，更新页面状态和图片路径\n    if page_obj:\n        page_obj.generated_image_path = image_path\n        page_obj.cached_image_path = cached_image_path\n        page_obj.status = 'COMPLETED'\n        page_obj.updated_at = datetime.utcnow()\n\n    # 提交事务\n    db.session.commit()\n\n    logger.debug(f\"Page {page_id} image saved as version {next_version}: {image_path}, cached: {cached_image_path}\")\n\n    return image_path, next_version\n\n\ndef generate_descriptions_task(task_id: str, project_id: str, ai_service,\n                               project_context, outline: List[Dict],\n                               max_workers: int = 5, app=None,\n                               language: str = None,\n                               detail_level: str = 'default'):\n    \"\"\"\n    Background task for generating page descriptions\n    Based on demo.py gen_desc() with parallel processing\n\n    Note: app instance MUST be passed from the request context\n\n    Args:\n        task_id: Task ID\n        project_id: Project ID\n        ai_service: AI service instance\n        project_context: ProjectContext object containing all project information\n        outline: Complete outline structure\n        max_workers: Maximum number of parallel workers\n        app: Flask app instance\n        language: Output language (zh, en, ja, auto)\n        detail_level: Description detail level (concise/default/detailed)\n    \"\"\"\n    if app is None:\n        raise ValueError(\"Flask app instance must be provided\")\n    \n    # 在整个任务中保持应用上下文\n    with app.app_context():\n        try:\n            # 重要：在后台线程开始时就获取task和设置状态\n            task = Task.query.get(task_id)\n            if not task:\n                logger.error(f\"Task {task_id} not found\")\n                return\n            \n            task.status = 'PROCESSING'\n            db.session.commit()\n            logger.info(f\"Task {task_id} status updated to PROCESSING\")\n            \n            # Flatten outline to get pages\n            pages_data = ai_service.flatten_outline(outline)\n            \n            # Get all pages for this project\n            pages = Page.query.filter_by(project_id=project_id).order_by(Page.order_index).all()\n            \n            if len(pages) != len(pages_data):\n                raise ValueError(\"Page count mismatch\")\n            \n            # Mark all pages as GENERATING_DESCRIPTION before starting\n            for page in pages:\n                page.status = 'GENERATING_DESCRIPTION'\n\n            # Initialize progress\n            task.set_progress({\n                \"total\": len(pages),\n                \"completed\": 0,\n                \"failed\": 0\n            })\n            db.session.commit()\n\n            # Generate descriptions in parallel\n            completed = 0\n            failed = 0\n            \n            def generate_single_desc(page_id, page_outline, page_index):\n                \"\"\"\n                Generate description for a single page\n                注意：只传递 page_id（字符串），不传递 ORM 对象，避免跨线程会话问题\n                \"\"\"\n                # 关键修复：在子线程中也需要应用上下文\n                with app.app_context():\n                    try:\n                        # Get singleton AI service instance\n                        from services.ai_service_manager import get_ai_service\n                        ai_service = get_ai_service()\n                        \n                        desc_result = ai_service.generate_page_description(\n                            project_context, outline, page_outline, page_index,\n                            language=language,\n                            detail_level=detail_level\n                        )\n\n                        # generate_page_description returns dict with text + optional extra_fields\n                        desc_content = {\n                            \"text\": desc_result['text'],\n                            \"generated_at\": datetime.utcnow().isoformat()\n                        }\n                        if desc_result.get('extra_fields'):\n                            desc_content['extra_fields'] = desc_result['extra_fields']\n                        \n                        return (page_id, desc_content, None)\n                    except Exception as e:\n                        import traceback\n                        error_detail = traceback.format_exc()\n                        logger.error(f\"Failed to generate description for page {page_id}: {error_detail}\")\n                        return (page_id, None, str(e))\n            \n            # Use ThreadPoolExecutor for parallel generation\n            # 关键：提前提取 page.id，不要传递 ORM 对象到子线程\n            with ThreadPoolExecutor(max_workers=max_workers) as executor:\n                futures = [\n                    executor.submit(generate_single_desc, page.id, page_data, i)\n                    for i, (page, page_data) in enumerate(zip(pages, pages_data), 1)\n                ]\n                \n                # Process results as they complete\n                for future in as_completed(futures):\n                    page_id, desc_content, error = future.result()\n                    \n                    db.session.expire_all()\n                    \n                    # Update page in database\n                    page = Page.query.get(page_id)\n                    if page:\n                        if error:\n                            page.status = 'FAILED'\n                            failed += 1\n                        else:\n                            page.set_description_content(desc_content)\n                            page.status = 'DESCRIPTION_GENERATED'\n                            completed += 1\n                        \n                        db.session.commit()\n                    \n                    # Update task progress\n                    task = Task.query.get(task_id)\n                    if task:\n                        task.update_progress(completed=completed, failed=failed)\n                        db.session.commit()\n                        logger.info(f\"Description Progress: {completed}/{len(pages)} pages completed\")\n            \n            # Mark task as completed\n            task = Task.query.get(task_id)\n            if task:\n                task.status = 'COMPLETED'\n                task.completed_at = datetime.utcnow()\n                db.session.commit()\n                logger.info(f\"Task {task_id} COMPLETED - {completed} pages generated, {failed} failed\")\n            \n            # Update project status\n            from models import Project\n            project = Project.query.get(project_id)\n            if project and failed == 0:\n                project.status = 'DESCRIPTIONS_GENERATED'\n                db.session.commit()\n                logger.info(f\"Project {project_id} status updated to DESCRIPTIONS_GENERATED\")\n        \n        except Exception as e:\n            # Mark task as failed\n            task = Task.query.get(task_id)\n            if task:\n                task.status = 'FAILED'\n                task.error_message = str(e)\n                task.completed_at = datetime.utcnow()\n                db.session.commit()\n\n\ndef generate_images_task(task_id: str, project_id: str, ai_service, file_service,\n                        outline: List[Dict], use_template: bool = True, \n                        max_workers: int = 8, aspect_ratio: str = \"16:9\",\n                        resolution: str = \"2K\", app=None,\n                        extra_requirements: str = None,\n                        language: str = None,\n                        page_ids: list = None):\n    \"\"\"\n    Background task for generating page images\n    Based on demo.py gen_images_parallel()\n    \n    Note: app instance MUST be passed from the request context\n    \n    Args:\n        language: Output language (zh, en, ja, auto)\n        page_ids: Optional list of page IDs to generate (if not provided, generates all pages)\n    \"\"\"\n    if app is None:\n        raise ValueError(\"Flask app instance must be provided\")\n    \n    with app.app_context():\n        try:\n            # Update task status to PROCESSING\n            task = Task.query.get(task_id)\n            if not task:\n                return\n            \n            task.status = 'PROCESSING'\n            db.session.commit()\n            \n            # Get pages for this project (filtered by page_ids if provided)\n            pages = get_filtered_pages(project_id, page_ids)\n            all_pages_data = ai_service.flatten_outline(outline)\n\n            # Build mapping from order_index to page_data so filtered pages\n            # get matched to the correct outline entry (not just first N)\n            pages_data_by_index = {i: pd for i, pd in enumerate(all_pages_data)}\n            \n            # 注意：不在任务开始时获取模板路径，而是在每个子线程中动态获取\n            # 这样可以确保即使用户在上传新模板后立即生成，也能使用最新模板\n            \n            # Initialize progress\n            task.set_progress({\n                \"total\": len(pages),\n                \"completed\": 0,\n                \"failed\": 0\n            })\n            db.session.commit()\n            \n            # Generate images in parallel\n            completed = 0\n            failed = 0\n            resolution_mismatched = 0  # Count of resolution mismatches\n            \n            def generate_single_image(page_id, page_data, page_index):\n                \"\"\"\n                Generate image for a single page\n                注意：只传递 page_id（字符串），不传递 ORM 对象，避免跨线程会话问题\n                \"\"\"\n                # 关键修复：在子线程中也需要应用上下文\n                with app.app_context():\n                    try:\n                        logger.debug(f\"Starting image generation for page {page_id}, index {page_index}\")\n                        # Get page from database in this thread\n                        page_obj = Page.query.get(page_id)\n                        if not page_obj:\n                            raise ValueError(f\"Page {page_id} not found\")\n                        \n                        # Update page status\n                        page_obj.status = 'GENERATING'\n                        db.session.commit()\n                        logger.debug(f\"Page {page_id} status updated to GENERATING\")\n                        \n                        # Get description content\n                        desc_content = page_obj.get_description_content()\n                        if not desc_content:\n                            raise ValueError(\"No description content for page\")\n                        \n                        # 获取描述文本（可能是 text 字段或 text_content 数组）\n                        desc_text = desc_content.get('text', '')\n                        if not desc_text and desc_content.get('text_content'):\n                            # 如果 text 字段不存在，尝试从 text_content 数组获取\n                            text_content = desc_content.get('text_content', [])\n                            if isinstance(text_content, list):\n                                desc_text = '\\n'.join(text_content)\n                            else:\n                                desc_text = str(text_content)\n\n                        # 将 extra_fields 拼入描述文本供图片生成使用\n                        desc_text = _append_extra_fields(desc_text, desc_content)\n\n                        logger.debug(f\"Got description text for page {page_id}: {desc_text[:100]}...\")\n                        \n                        # 从当前页面的描述内容中提取图片 URL\n                        page_additional_ref_images = []\n                        has_material_images = False\n                        \n                        # 从描述文本中提取图片\n                        if desc_text:\n                            image_urls = ai_service.extract_image_urls_from_markdown(desc_text)\n                            if image_urls:\n                                logger.info(f\"Found {len(image_urls)} image(s) in page {page_id} description\")\n                                page_additional_ref_images = image_urls\n                                has_material_images = True\n                        \n                        # 在子线程中动态获取模板路径，确保使用最新模板\n                        page_ref_image_path = None\n                        if use_template:\n                            page_ref_image_path = file_service.get_template_path(project_id)\n                            # 注意：如果有风格描述，即使没有模板图片也允许生成\n                            # 这个检查已经在 controller 层完成，这里不再检查\n                        \n                        # Generate image prompt\n                        prompt = ai_service.generate_image_prompt(\n                            outline, page_data, desc_text, page_index,\n                            has_material_images=has_material_images,\n                            extra_requirements=extra_requirements,\n                            language=language,\n                            has_template=use_template,\n                            aspect_ratio=aspect_ratio\n                        )\n                        logger.debug(f\"Generated image prompt for page {page_id}\")\n                        \n                        # Generate image\n                        logger.info(f\"🎨 Calling AI service to generate image for page {page_index}/{len(pages)}...\")\n                        image = ai_service.generate_image(\n                            prompt, page_ref_image_path, aspect_ratio, resolution,\n                            additional_ref_images=page_additional_ref_images if page_additional_ref_images else None\n                        )\n                        logger.info(f\"✅ Image generated successfully for page {page_index}\")\n                        \n                        if not image:\n                            raise ValueError(\"Failed to generate image\")\n                        \n                        # Check resolution for all providers\n                        actual_res, is_match = check_image_resolution(image, resolution)\n                        if not is_match:\n                            logger.warning(f\"Resolution mismatch for page {page_index}: requested {resolution}, got {actual_res}\")\n                        \n                        # 优化：直接在子线程中计算版本号并保存到最终位置\n                        # 每个页面独立，使用数据库事务保证版本号原子性，避免临时文件\n                        image_path, next_version = save_image_with_version(\n                            image, project_id, page_id, file_service, page_obj=page_obj\n                        )\n                        \n                        return (page_id, image_path, None, not is_match)\n                        \n                    except Exception as e:\n                        import traceback\n                        error_detail = traceback.format_exc()\n                        logger.error(f\"Failed to generate image for page {page_id}: {error_detail}\")\n                        return (page_id, None, str(e), None)\n            \n            # Use ThreadPoolExecutor for parallel generation\n            # 关键：提前提取 page.id，不要传递 ORM 对象到子线程\n            with ThreadPoolExecutor(max_workers=max_workers) as executor:\n                futures = [\n                    executor.submit(\n                        generate_single_image, page.id,\n                        pages_data_by_index.get(page.order_index, {}), i\n                    )\n                    for i, page in enumerate(pages, 1)\n                ]\n                \n                # Process results as they complete\n                for future in as_completed(futures):\n                    page_id, image_path, error, is_mismatched = future.result()\n                    \n                    if is_mismatched:\n                        resolution_mismatched += 1\n                    \n                    db.session.expire_all()\n                    \n                    # Update page in database (主要是为了更新失败状态)\n                    page = Page.query.get(page_id)\n                    if page:\n                        if error:\n                            page.status = 'FAILED'\n                            failed += 1\n                            db.session.commit()\n                        else:\n                            # 图片已在子线程中保存并创建版本记录，这里只需要更新计数\n                            completed += 1\n                            # 刷新页面对象以获取最新状态\n                            db.session.refresh(page)\n                    \n                    # Update task progress\n                    task = Task.query.get(task_id)\n                    if task:\n                        progress = task.get_progress()\n                        progress['completed'] = completed\n                        progress['failed'] = failed\n                        # 第一次检测到不匹配时设置警告\n                        if resolution_mismatched > 0 and 'warning_message' not in progress:\n                            progress['warning_message'] = \"图片返回分辨率与设置不符，建议使用gemini格式以避免此问题\"\n                        task.set_progress(progress)\n                        db.session.commit()\n                        logger.info(f\"Image Progress: {completed}/{len(pages)} pages completed\")\n            \n            # Mark task as completed\n            task = Task.query.get(task_id)\n            if task:\n                task.status = 'COMPLETED'\n                task.completed_at = datetime.utcnow()\n                if resolution_mismatched > 0:\n                    logger.warning(f\"Task {task_id} has {resolution_mismatched} resolution mismatches\")\n                db.session.commit()\n                logger.info(f\"Task {task_id} COMPLETED - {completed} images generated, {failed} failed\")\n            \n            # Update project status\n            from models import Project\n            project = Project.query.get(project_id)\n            if project and failed == 0:\n                project.status = 'COMPLETED'\n                db.session.commit()\n                logger.info(f\"Project {project_id} status updated to COMPLETED\")\n        \n        except Exception as e:\n            # Mark task as failed\n            task = Task.query.get(task_id)\n            if task:\n                task.status = 'FAILED'\n                task.error_message = str(e)\n                task.completed_at = datetime.utcnow()\n                db.session.commit()\n\n\ndef generate_single_page_image_task(task_id: str, project_id: str, page_id: str, \n                                    ai_service, file_service, outline: List[Dict],\n                                    use_template: bool = True, aspect_ratio: str = \"16:9\",\n                                    resolution: str = \"2K\", app=None,\n                                    extra_requirements: str = None,\n                                    language: str = None):\n    \"\"\"\n    Background task for generating a single page image\n    \n    Note: app instance MUST be passed from the request context\n    \"\"\"\n    if app is None:\n        raise ValueError(\"Flask app instance must be provided\")\n    \n    with app.app_context():\n        try:\n            # Update task status to PROCESSING\n            task = Task.query.get(task_id)\n            if not task:\n                return\n            \n            task.status = 'PROCESSING'\n            db.session.commit()\n            \n            # Get page from database\n            page = Page.query.get(page_id)\n            if not page or page.project_id != project_id:\n                raise ValueError(f\"Page {page_id} not found\")\n            \n            # Update page status\n            page.status = 'GENERATING'\n            db.session.commit()\n            \n            # Get description content\n            desc_content = page.get_description_content()\n            if not desc_content:\n                raise ValueError(\"No description content for page\")\n            \n            # 获取描述文本（可能是 text 字段或 text_content 数组）\n            desc_text = desc_content.get('text', '')\n            if not desc_text and desc_content.get('text_content'):\n                text_content = desc_content.get('text_content', [])\n                if isinstance(text_content, list):\n                    desc_text = '\\n'.join(text_content)\n                else:\n                    desc_text = str(text_content)\n\n            # 将 extra_fields 拼入描述文本供图片生成使用\n            desc_text = _append_extra_fields(desc_text, desc_content)\n\n            # 从描述文本中提取图片 URL\n            additional_ref_images = []\n            has_material_images = False\n            \n            if desc_text:\n                image_urls = ai_service.extract_image_urls_from_markdown(desc_text)\n                if image_urls:\n                    logger.info(f\"Found {len(image_urls)} image(s) in page {page_id} description\")\n                    additional_ref_images = image_urls\n                    has_material_images = True\n            \n            # Get template path if use_template\n            ref_image_path = None\n            if use_template:\n                ref_image_path = file_service.get_template_path(project_id)\n                # 注意：如果有风格描述，即使没有模板图片也允许生成\n                # 这个检查已经在 controller 层完成，这里不再检查\n            \n            # Generate image prompt\n            page_data = page.get_outline_content() or {}\n            if page.part:\n                page_data['part'] = page.part\n            \n            prompt = ai_service.generate_image_prompt(\n                outline, page_data, desc_text, page.order_index + 1,\n                has_material_images=has_material_images,\n                extra_requirements=extra_requirements,\n                language=language,\n                has_template=use_template,\n                aspect_ratio=aspect_ratio\n            )\n            \n            # Generate image\n            logger.info(f\"🎨 Generating image for page {page_id}...\")\n            image = ai_service.generate_image(\n                prompt, ref_image_path, aspect_ratio, resolution,\n                additional_ref_images=additional_ref_images if additional_ref_images else None\n            )\n            \n            if not image:\n                raise ValueError(\"Failed to generate image\")\n            \n            # 保存图片并创建历史版本记录\n            image_path, next_version = save_image_with_version(\n                image, project_id, page_id, file_service, page_obj=page\n            )\n            \n            # Mark task as completed\n            task.status = 'COMPLETED'\n            task.completed_at = datetime.utcnow()\n            task.set_progress({\n                \"total\": 1,\n                \"completed\": 1,\n                \"failed\": 0\n            })\n            db.session.commit()\n            \n            logger.info(f\"✅ Task {task_id} COMPLETED - Page {page_id} image generated\")\n        \n        except Exception as e:\n            import traceback\n            error_detail = traceback.format_exc()\n            logger.error(f\"Task {task_id} FAILED: {error_detail}\")\n            \n            # Mark task as failed\n            task = Task.query.get(task_id)\n            if task:\n                task.status = 'FAILED'\n                task.error_message = str(e)\n                task.completed_at = datetime.utcnow()\n                db.session.commit()\n            \n            # Update page status\n            page = Page.query.get(page_id)\n            if page:\n                page.status = 'FAILED'\n                db.session.commit()\n\n\ndef edit_page_image_task(task_id: str, project_id: str, page_id: str,\n                         edit_instruction: str, ai_service, file_service,\n                         aspect_ratio: str = \"16:9\", resolution: str = \"2K\",\n                         original_description: str = None,\n                         additional_ref_images: List[str] = None,\n                         temp_dir: str = None, app=None):\n    \"\"\"\n    Background task for editing a page image\n    \n    Note: app instance MUST be passed from the request context\n    \"\"\"\n    if app is None:\n        raise ValueError(\"Flask app instance must be provided\")\n    \n    with app.app_context():\n        try:\n            # Update task status to PROCESSING\n            task = Task.query.get(task_id)\n            if not task:\n                return\n            \n            task.status = 'PROCESSING'\n            db.session.commit()\n            \n            # Get page from database\n            page = Page.query.get(page_id)\n            if not page or page.project_id != project_id:\n                raise ValueError(f\"Page {page_id} not found\")\n            \n            if not page.generated_image_path:\n                raise ValueError(\"Page must have generated image first\")\n            \n            # Update page status\n            page.status = 'GENERATING'\n            db.session.commit()\n            \n            # Get current image path\n            current_image_path = file_service.get_absolute_path(page.generated_image_path)\n            \n            # Edit image\n            logger.info(f\"🎨 Editing image for page {page_id}...\")\n            try:\n                image = ai_service.edit_image(\n                    edit_instruction,\n                    current_image_path,\n                    aspect_ratio,\n                    resolution,\n                    original_description=original_description,\n                    additional_ref_images=additional_ref_images if additional_ref_images else None\n                )\n            finally:\n                # Clean up temp directory if created\n                if temp_dir:\n                    import shutil\n                    from pathlib import Path\n                    temp_path = Path(temp_dir)\n                    if temp_path.exists():\n                        shutil.rmtree(temp_dir)\n            \n            if not image:\n                raise ValueError(\"Failed to edit image\")\n            \n            # 保存编辑后的图片并创建历史版本记录\n            image_path, next_version = save_image_with_version(\n                image, project_id, page_id, file_service, page_obj=page\n            )\n            \n            # Mark task as completed\n            task.status = 'COMPLETED'\n            task.completed_at = datetime.utcnow()\n            task.set_progress({\n                \"total\": 1,\n                \"completed\": 1,\n                \"failed\": 0\n            })\n            db.session.commit()\n            \n            logger.info(f\"✅ Task {task_id} COMPLETED - Page {page_id} image edited\")\n        \n        except Exception as e:\n            import traceback\n            error_detail = traceback.format_exc()\n            logger.error(f\"Task {task_id} FAILED: {error_detail}\")\n            \n            # Clean up temp directory on error\n            if temp_dir:\n                import shutil\n                from pathlib import Path\n                temp_path = Path(temp_dir)\n                if temp_path.exists():\n                    shutil.rmtree(temp_dir)\n            \n            # Mark task as failed\n            task = Task.query.get(task_id)\n            if task:\n                task.status = 'FAILED'\n                task.error_message = str(e)\n                task.completed_at = datetime.utcnow()\n                db.session.commit()\n            \n            # Update page status\n            page = Page.query.get(page_id)\n            if page:\n                page.status = 'FAILED'\n                db.session.commit()\n\n\ndef generate_material_image_task(task_id: str, project_id: str, prompt: str,\n                                 ai_service, file_service,\n                                 ref_image_path: str = None,\n                                 additional_ref_images: List[str] = None,\n                                 aspect_ratio: str = \"16:9\",\n                                 resolution: str = \"2K\",\n                                 temp_dir: str = None, app=None):\n    \"\"\"\n    Background task for generating a material image\n    复用核心的generate_image逻辑，但保存到Material表而不是Page表\n    \n    Note: app instance MUST be passed from the request context\n    project_id can be None for global materials (but Task model requires a project_id,\n    so we use a special value 'global' for task tracking)\n    \"\"\"\n    if app is None:\n        raise ValueError(\"Flask app instance must be provided\")\n    \n    with app.app_context():\n        try:\n            # Update task status to PROCESSING\n            task = Task.query.get(task_id)\n            if not task:\n                return\n            \n            task.status = 'PROCESSING'\n            db.session.commit()\n            \n            # Generate image (复用核心逻辑)\n            logger.info(f\"🎨 Generating material image with prompt: {prompt[:100]}...\")\n            image = ai_service.generate_image(\n                prompt=prompt,\n                ref_image_path=ref_image_path,\n                aspect_ratio=aspect_ratio,\n                resolution=resolution,\n                additional_ref_images=additional_ref_images or None,\n            )\n            \n            if not image:\n                raise ValueError(\"Failed to generate image\")\n            \n            # 处理project_id：如果为'global'或None，转换为None\n            actual_project_id = None if (project_id == 'global' or project_id is None) else project_id\n            \n            # Save generated material image\n            relative_path = file_service.save_material_image(image, actual_project_id)\n            relative = Path(relative_path)\n            filename = relative.name\n            \n            # Construct frontend-accessible URL\n            image_url = file_service.get_file_url(actual_project_id, 'materials', filename)\n            \n            # Save material info to database\n            material = Material(\n                project_id=actual_project_id,\n                filename=filename,\n                relative_path=relative_path,\n                url=image_url\n            )\n            db.session.add(material)\n            \n            # Mark task as completed\n            task.status = 'COMPLETED'\n            task.completed_at = datetime.utcnow()\n            task.set_progress({\n                \"total\": 1,\n                \"completed\": 1,\n                \"failed\": 0,\n                \"material_id\": material.id,\n                \"image_url\": image_url\n            })\n            db.session.commit()\n            \n            logger.info(f\"✅ Task {task_id} COMPLETED - Material {material.id} generated\")\n        \n        except Exception as e:\n            import traceback\n            error_detail = traceback.format_exc()\n            logger.error(f\"Task {task_id} FAILED: {error_detail}\")\n            \n            # Mark task as failed\n            task = Task.query.get(task_id)\n            if task:\n                task.status = 'FAILED'\n                task.error_message = str(e)\n                task.completed_at = datetime.utcnow()\n                db.session.commit()\n        \n        finally:\n            # Clean up temp directory\n            if temp_dir:\n                import shutil\n                temp_path = Path(temp_dir)\n                if temp_path.exists():\n                    shutil.rmtree(temp_dir, ignore_errors=True)\n\n\ndef process_ppt_renovation_task(task_id: str, project_id: str, ai_service,\n                                file_service, file_parser_service,\n                                keep_layout: bool = False,\n                                max_workers: int = 5, app=None,\n                                language: str = 'zh'):\n    \"\"\"\n    Background task for PPT renovation: parse PDF pages → extract content → fill outline + description\n\n    Flow:\n    1. Split PDF → per-page PDFs\n    2. Parallel: parse each page PDF → markdown via fileparser\n    3. Parallel: AI extract {title, points, description} from each markdown\n    4. If keep_layout: parallel caption model describe layout → append to description\n    5. Update page.outline_content + page.description_content\n    6. Concatenate descriptions → project.description_text\n    7. project.status = DESCRIPTIONS_GENERATED\n\n    Args:\n        task_id: Task ID\n        project_id: Project ID\n        ai_service: AI service instance\n        file_service: FileService instance\n        file_parser_service: FileParserService instance\n        keep_layout: Whether to preserve original layout via caption model\n        max_workers: Maximum parallel workers\n        app: Flask app instance\n        language: Output language\n    \"\"\"\n    if app is None:\n        raise ValueError(\"Flask app instance must be provided\")\n\n    with app.app_context():\n        try:\n            task = Task.query.get(task_id)\n            if not task:\n                logger.error(f\"Task {task_id} not found\")\n                return\n\n            task.status = 'PROCESSING'\n            db.session.commit()\n\n            from models import Project\n            project = Project.query.get(project_id)\n            if not project:\n                raise ValueError(f\"Project {project_id} not found\")\n\n            # Get the PDF path from project\n            pdf_path = None\n            project_dir = Path(app.config['UPLOAD_FOLDER']) / project_id\n            # Look for the uploaded PDF file\n            for f in (project_dir / \"template\").iterdir() if (project_dir / \"template\").exists() else []:\n                if f.suffix.lower() == '.pdf':\n                    pdf_path = str(f)\n                    break\n\n            if not pdf_path:\n                raise ValueError(\"No PDF file found for renovation project\")\n\n            # Step 1: Split PDF into per-page PDFs\n            split_dir = str(project_dir / \"split_pages\")\n            page_pdfs = split_pdf_to_pages(pdf_path, split_dir)\n            logger.info(f\"Split PDF into {len(page_pdfs)} pages\")\n\n            # Get existing pages\n            pages = Page.query.filter_by(project_id=project_id).order_by(Page.order_index).all()\n\n            # Ensure page count matches\n            if len(pages) != len(page_pdfs):\n                logger.warning(f\"Page count mismatch: {len(pages)} pages vs {len(page_pdfs)} PDFs. Using min.\")\n            page_count = min(len(pages), len(page_pdfs))\n            if page_count == 0:\n                raise ValueError(\"No pages to process\")\n\n            task.set_progress({\n                \"total\": page_count,\n                \"completed\": 0,\n                \"failed\": 0,\n                \"current_step\": \"parsing\"\n            })\n            db.session.commit()\n\n            # Process each page as an independent pipeline:\n            # parse markdown → AI extract content → (optional layout caption) → write to DB\n            logger.info(\"Processing pages (parse → extract → save pipeline)...\")\n            import threading\n            progress_lock = threading.Lock()\n            completed = 0\n            failed = 0\n            extraction_errors = []\n            content_results = {}  # index -> {title, points, description}\n\n            def process_single_page(idx, page_pdf_path):\n                nonlocal completed, failed\n                with app.app_context():\n                    try:\n                        # Step A: Parse page PDF → markdown\n                        filename = os.path.basename(page_pdf_path)\n                        _batch_id, md_text, extract_id, error_msg, _failed = file_parser_service.parse_file(page_pdf_path, filename)\n                        if error_msg:\n                            logger.warning(f\"Page {idx} parse warning: {error_msg}\")\n                        md_text = md_text or ''\n\n                        # Supplement with header/footer from layout.json\n                        if extract_id:\n                            hf_text = file_parser_service.extract_header_footer_from_layout(extract_id)\n                            if hf_text:\n                                md_text = hf_text + '\\n\\n' + md_text\n\n                        if not md_text.strip():\n                            content = {'title': f'Page {idx + 1}', 'points': [], 'description': ''}\n                            error = 'empty_input'\n                        else:\n                            # Step B: AI extract structured content\n                            content = ai_service.extract_page_content(md_text, language=language)\n                            error = None\n\n                        # Step C: Optional layout caption\n                        if keep_layout and not error:\n                            try:\n                                page_obj = pages[idx] if idx < len(pages) else None\n                                if page_obj:\n                                    image_path = None\n                                    if page_obj.cached_image_path:\n                                        image_path = file_service.get_absolute_path(page_obj.cached_image_path)\n                                    elif page_obj.generated_image_path:\n                                        image_path = file_service.get_absolute_path(page_obj.generated_image_path)\n                                    if image_path and Path(image_path).exists():\n                                        caption = ai_service.generate_layout_caption(image_path)\n                                        if caption:\n                                            content['description'] += f\"\\n\\n{caption}\"\n                            except Exception as e:\n                                logger.error(f\"Layout caption failed for page {idx}: {e}\")\n\n                        # Step D: Write to DB immediately\n                        content_results[idx] = content\n                        page_obj = Page.query.get(pages[idx].id)\n                        if page_obj:\n                            title = content.get('title', f'Page {idx + 1}')\n                            points = content.get('points', [])\n                            description = content.get('description', '')\n\n                            page_obj.set_outline_content({\n                                'title': title,\n                                'points': points\n                            })\n                            page_obj.set_description_content({\n                                \"text\": description,\n                                \"generated_at\": datetime.utcnow().isoformat()\n                            })\n                            page_obj.status = 'DESCRIPTION_GENERATED'\n                            db.session.commit()\n\n                        with progress_lock:\n                            if error and error != 'empty_input':\n                                failed += 1\n                                extraction_errors.append(error)\n                            else:\n                                completed += 1\n                            task_obj = Task.query.get(task_id)\n                            if task_obj:\n                                task_obj.update_progress(completed=completed, failed=failed)\n                                db.session.commit()\n\n                        logger.info(f\"Page {idx} pipeline done (completed={completed}, failed={failed})\")\n\n                    except Exception as e:\n                        logger.error(f\"Pipeline failed for page {idx}: {e}\")\n                        with progress_lock:\n                            failed += 1\n                            extraction_errors.append(str(e))\n                            task_obj = Task.query.get(task_id)\n                            if task_obj:\n                                task_obj.update_progress(completed=completed, failed=failed)\n                                db.session.commit()\n\n            with ThreadPoolExecutor(max_workers=max_workers) as executor:\n                futures = [\n                    executor.submit(process_single_page, i, page_pdfs[i])\n                    for i in range(page_count)\n                ]\n                for future in as_completed(futures):\n                    future.result()  # propagate any unexpected exceptions\n\n            logger.info(f\"All pages processed: {completed} completed, {failed} failed\")\n\n            # Fail-fast: any extraction failure aborts the entire task\n            if failed > 0:\n                reason = extraction_errors[0] if extraction_errors else \"empty page content\"\n                raise ValueError(f\"{failed}/{page_count} 页内容提取失败: {reason}\")\n\n            # Update project-level aggregated text\n            project = Project.query.get(project_id)\n            if project:\n                all_outlines = []\n                all_descriptions = []\n                for i in range(page_count):\n                    content = content_results.get(i, {})\n                    title = content.get('title', '')\n                    points = content.get('points', [])\n                    description = content.get('description', '')\n                    header = f\"第{i + 1}页：{title}\"\n                    if points:\n                        all_outlines.append(f\"{header}\\n\" + \"\\n\".join(f\"- {p}\" for p in points))\n                    else:\n                        all_outlines.append(header)\n                    all_descriptions.append(f\"--- 第{i + 1}页 ---\\n{description}\")\n                project.outline_text = \"\\n\\n\".join(all_outlines)\n                project.description_text = \"\\n\\n\".join(all_descriptions)\n                project.status = 'DESCRIPTIONS_GENERATED'\n                project.updated_at = datetime.utcnow()\n\n            db.session.commit()\n\n            # Mark task as completed\n            task = Task.query.get(task_id)\n            if task:\n                task.status = 'COMPLETED'\n                task.completed_at = datetime.utcnow()\n                task.set_progress({\n                    \"total\": page_count,\n                    \"completed\": completed,\n                    \"failed\": failed,\n                    \"current_step\": \"done\"\n                })\n                db.session.commit()\n\n            logger.info(f\"Task {task_id} COMPLETED - PPT renovation processed {page_count} pages\")\n\n        except Exception as e:\n            import traceback\n            error_detail = traceback.format_exc()\n            logger.error(f\"Task {task_id} FAILED: {error_detail}\")\n\n            task = Task.query.get(task_id)\n            if task:\n                task.status = 'FAILED'\n                task.error_message = str(e)\n                task.completed_at = datetime.utcnow()\n\n            # Reset project status so user can retry\n            project = Project.query.get(project_id)\n            if project:\n                project.status = 'DRAFT'\n\n            db.session.commit()\n\n\ndef export_editable_pptx_with_recursive_analysis_task(\n    task_id: str,\n    project_id: str,\n    filename: str,\n    file_service,\n    page_ids: list = None,\n    max_depth: int = 2,\n    max_workers: int = 4,\n    export_extractor_method: str = 'hybrid',\n    export_inpaint_method: str = 'hybrid',\n    app=None\n):\n    \"\"\"\n    使用递归图片可编辑化分析导出可编辑PPTX的后台任务\n    \n    这是新的架构方法，使用ImageEditabilityService进行递归版面分析。\n    与旧方法的区别：\n    - 不再假设图片是16:9\n    - 支持任意尺寸和分辨率\n    - 递归分析图片中的子图和图表\n    - 更智能的坐标映射和元素提取\n    - 不需要 ai_service（使用 ImageEditabilityService 和 MinerU）\n    \n    Args:\n        task_id: 任务ID\n        project_id: 项目ID\n        filename: 输出文件名\n        file_service: 文件服务实例\n        page_ids: 可选的页面ID列表（如果提供，只导出这些页面）\n        max_depth: 最大递归深度\n        max_workers: 并发处理数\n        export_extractor_method: 组件提取方法 ('mineru' 或 'hybrid')\n        export_inpaint_method: 背景修复方法 ('generative', 'baidu', 'hybrid')\n        app: Flask应用实例\n    \"\"\"\n    logger.info(f\"🚀 Task {task_id} started: export_editable_pptx_with_recursive_analysis (project={project_id}, depth={max_depth}, workers={max_workers}, extractor={export_extractor_method}, inpaint={export_inpaint_method})\")\n    \n    if app is None:\n        raise ValueError(\"Flask app instance must be provided\")\n    \n    with app.app_context():\n        import os\n        from datetime import datetime\n        from PIL import Image\n        from models import Project\n        from services.export_service import ExportService, ExportError\n\n        logger.info(f\"开始递归分析导出任务 {task_id} for project {project_id}\")\n\n        try:\n            # Get project\n            project = Project.query.get(project_id)\n            if not project:\n                raise ValueError(f'Project {project_id} not found')\n\n            # 读取项目的导出设置：是否允许返回半成品\n            export_allow_partial = project.export_allow_partial or False\n            fail_fast = not export_allow_partial\n            logger.info(f\"导出设置: export_allow_partial={export_allow_partial}, fail_fast={fail_fast}\")\n\n            # IMPORTANT: Expire cached objects to ensure fresh data from database\n            # This prevents reading stale generated_image_path after page regeneration\n            db.session.expire_all()\n\n            # Get pages (filtered by page_ids if provided)\n            pages = get_filtered_pages(project_id, page_ids)\n            if not pages:\n                raise ValueError('No pages found for project')\n            \n            image_paths = []\n            for page in pages:\n                if page.generated_image_path:\n                    img_path = file_service.get_absolute_path(page.generated_image_path)\n                    if os.path.exists(img_path):\n                        image_paths.append(img_path)\n            \n            if not image_paths:\n                raise ValueError('No generated images found for project')\n            \n            logger.info(f\"找到 {len(image_paths)} 张图片\")\n            \n            # 初始化任务进度（包含消息日志）\n            task = Task.query.get(task_id)\n            task.set_progress({\n                \"total\": 100,  # 使用百分比\n                \"completed\": 0,\n                \"failed\": 0,\n                \"current_step\": \"准备中...\",\n                \"percent\": 0,\n                \"messages\": [\"🚀 开始导出可编辑PPTX...\"]  # 消息日志\n            })\n            db.session.commit()\n            \n            # 进度回调函数 - 更新数据库中的进度\n            progress_messages = [\"🚀 开始导出可编辑PPTX...\"]\n            max_messages = 10  # 最多保留最近10条消息\n            \n            def progress_callback(step: str, message: str, percent: int):\n                \"\"\"更新任务进度到数据库\"\"\"\n                nonlocal progress_messages\n                try:\n                    # 添加新消息到日志\n                    new_message = f\"[{step}] {message}\"\n                    progress_messages.append(new_message)\n                    # 只保留最近的消息\n                    if len(progress_messages) > max_messages:\n                        progress_messages = progress_messages[-max_messages:]\n                    \n                    # 更新数据库\n                    task = Task.query.get(task_id)\n                    if task:\n                        task.set_progress({\n                            \"total\": 100,\n                            \"completed\": percent,\n                            \"failed\": 0,\n                            \"current_step\": message,\n                            \"percent\": percent,\n                            \"messages\": progress_messages.copy()\n                        })\n                        db.session.commit()\n                except Exception as e:\n                    logger.warning(f\"更新进度失败: {e}\")\n            \n            # Step 1: 准备工作\n            logger.info(\"Step 1: 准备工作...\")\n            progress_callback(\"准备\", f\"找到 {len(image_paths)} 张幻灯片图片\", 2)\n            \n            # 准备输出路径\n            exports_dir = os.path.join(app.config['UPLOAD_FOLDER'], project_id, 'exports')\n            os.makedirs(exports_dir, exist_ok=True)\n            \n            # Handle filename collision\n            if not filename.endswith('.pptx'):\n                filename += '.pptx'\n            \n            output_path = os.path.join(exports_dir, filename)\n            if os.path.exists(output_path):\n                base_name = filename.rsplit('.', 1)[0]\n                timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S')\n                filename = f\"{base_name}_{timestamp}.pptx\"\n                output_path = os.path.join(exports_dir, filename)\n                logger.info(f\"文件名冲突，使用新文件名: {filename}\")\n            \n            # 获取第一张图片的尺寸作为参考\n            first_img = Image.open(image_paths[0])\n            slide_width, slide_height = first_img.size\n            first_img.close()\n            \n            logger.info(f\"幻灯片尺寸: {slide_width}x{slide_height}\")\n            logger.info(f\"递归深度: {max_depth}, 并发数: {max_workers}\")\n            progress_callback(\"准备\", f\"幻灯片尺寸: {slide_width}×{slide_height}\", 3)\n            \n            # Step 2: 创建文字属性提取器\n            from services.image_editability import TextAttributeExtractorFactory\n            text_attribute_extractor = TextAttributeExtractorFactory.create_caption_model_extractor()\n            progress_callback(\"准备\", \"文字属性提取器已初始化\", 5)\n            \n            # Step 3: 调用导出方法（使用项目的导出设置）\n            logger.info(f\"Step 3: 创建可编辑PPTX (extractor={export_extractor_method}, inpaint={export_inpaint_method}, fail_fast={fail_fast})...\")\n            progress_callback(\"配置\", f\"提取方法: {export_extractor_method}, 背景修复: {export_inpaint_method}\", 6)\n\n            _, export_warnings = ExportService.create_editable_pptx_with_recursive_analysis(\n                image_paths=image_paths,\n                output_file=output_path,\n                slide_width_pixels=slide_width,\n                slide_height_pixels=slide_height,\n                max_depth=max_depth,\n                max_workers=max_workers,\n                text_attribute_extractor=text_attribute_extractor,\n                progress_callback=progress_callback,\n                export_extractor_method=export_extractor_method,\n                export_inpaint_method=export_inpaint_method,\n                fail_fast=fail_fast\n            )\n            \n            logger.info(f\"✓ 可编辑PPTX已创建: {output_path}\")\n            \n            # Step 4: 标记任务完成\n            download_path = f\"/files/{project_id}/exports/{filename}\"\n            \n            # 添加完成消息\n            progress_messages.append(\"✅ 导出完成！\")\n            \n            # 添加警告信息（如果有）\n            warning_messages = []\n            if export_warnings and export_warnings.has_warnings():\n                warning_messages = export_warnings.to_summary()\n                progress_messages.extend(warning_messages)\n                logger.warning(f\"导出有 {len(warning_messages)} 条警告\")\n            \n            task = Task.query.get(task_id)\n            if task:\n                task.status = 'COMPLETED'\n                task.completed_at = datetime.utcnow()\n                task.set_progress({\n                    \"total\": 100,\n                    \"completed\": 100,\n                    \"failed\": 0,\n                    \"current_step\": \"✓ 导出完成\",\n                    \"percent\": 100,\n                    \"messages\": progress_messages,\n                    \"download_url\": download_path,\n                    \"filename\": filename,\n                    \"method\": \"recursive_analysis\",\n                    \"max_depth\": max_depth,\n                    \"warnings\": warning_messages,  # 单独的警告列表\n                    \"warning_details\": export_warnings.to_dict() if export_warnings else {}  # 详细警告信息\n                })\n                db.session.commit()\n                logger.info(f\"✓ 任务 {task_id} 完成 - 递归分析导出成功（深度={max_depth}）\")\n\n        except ExportError as e:\n            # 导出错误（fail_fast 模式下的详细错误）\n            import traceback\n            error_detail = traceback.format_exc()\n            logger.error(f\"✗ 任务 {task_id} 导出失败: {e.message}\")\n            logger.error(f\"错误类型: {e.error_type}, 详情: {e.details}\")\n\n            # 标记任务失败，包含详细错误信息\n            task = Task.query.get(task_id)\n            if task:\n                task.status = 'FAILED'\n                # 构建详细的错误消息\n                error_message = f\"{e.message}\"\n                if e.help_text:\n                    error_message += f\"\\n\\n💡 {e.help_text}\"\n                task.error_message = error_message\n                task.completed_at = datetime.utcnow()\n                # 在 progress 中保存详细错误信息\n                task.set_progress({\n                    \"total\": 100,\n                    \"completed\": 0,\n                    \"failed\": 1,\n                    \"current_step\": \"导出失败\",\n                    \"percent\": 0,\n                    \"error_type\": e.error_type,\n                    \"error_details\": e.details,\n                    \"help_text\": e.help_text\n                })\n                db.session.commit()\n\n        except Exception as e:\n            import traceback\n            error_detail = traceback.format_exc()\n            logger.error(f\"✗ 任务 {task_id} 失败: {error_detail}\")\n            \n            # 标记任务失败\n            task = Task.query.get(task_id)\n            if task:\n                task.status = 'FAILED'\n                task.error_message = str(e)\n                task.completed_at = datetime.utcnow()\n                db.session.commit()\n"
  },
  {
    "path": "backend/tests/conftest.py",
    "content": "\"\"\"\npytest配置文件 - 提供测试fixtures和配置\n\n用于后端所有测试的共享配置和fixtures\n\"\"\"\n\nimport os\nimport sys\nimport pytest\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import MagicMock, patch\n\n# 确保backend目录在Python路径中\nbackend_path = Path(__file__).parent.parent\nsys.path.insert(0, str(backend_path))\n\n# 设置测试环境变量 - 必须在导入app之前设置\nos.environ['TESTING'] = 'true'\nos.environ['USE_MOCK_AI'] = 'true'  # 标记使用mock AI服务\nos.environ['GOOGLE_API_KEY'] = os.environ.get('GOOGLE_API_KEY', 'mock-api-key-for-testing')\nos.environ['FLASK_ENV'] = 'testing'\n\n\n@pytest.fixture(scope='session')\ndef app():\n    \"\"\"创建Flask测试应用\"\"\"\n    # 创建临时目录用于测试\n    temp_dir = tempfile.mkdtemp()\n    temp_db = os.path.join(temp_dir, 'test.db')\n    \n    # 设置测试数据库路径\n    os.environ['DATABASE_URL'] = f'sqlite:///{temp_db}'\n    \n    # 现在导入app\n    from app import create_app\n    \n    # 使用工厂函数创建测试应用\n    test_app = create_app()\n    \n    # 覆盖配置\n    test_app.config.update({\n        'TESTING': True,\n        'SQLALCHEMY_DATABASE_URI': f'sqlite:///{temp_db}',\n        'WTF_CSRF_ENABLED': False,\n        'UPLOAD_FOLDER': temp_dir,\n    })\n    \n    # 创建应用上下文\n    with test_app.app_context():\n        from models import db\n        db.create_all()\n    \n    yield test_app\n    \n    # 清理\n    import shutil\n    try:\n        shutil.rmtree(temp_dir)\n    except Exception:\n        pass\n\n\n@pytest.fixture(scope='function')\ndef client(app):\n    \"\"\"创建测试客户端\"\"\"\n    with app.test_client() as test_client:\n        with app.app_context():\n            from models import db\n            # 清理旧数据，保持测试隔离\n            db.session.rollback()\n            for table in reversed(db.metadata.sorted_tables):\n                db.session.execute(table.delete())\n            db.session.commit()\n            yield test_client\n            db.session.rollback()\n\n\n@pytest.fixture(scope='function')\ndef db_session(app):\n    \"\"\"创建数据库会话\"\"\"\n    with app.app_context():\n        from models import db\n        db.create_all()\n        yield db.session\n        db.session.remove()\n        db.drop_all()\n\n\n@pytest.fixture\ndef sample_project(client):\n    \"\"\"创建示例项目\"\"\"\n    response = client.post('/api/projects', \n        json={\n            'creation_type': 'idea',\n            'idea_prompt': '测试PPT生成'\n        }\n    )\n    data = response.get_json()\n    return data['data'] if data.get('success') else None\n\n\n@pytest.fixture\ndef mock_ai_service():\n    \"\"\"Mock AI服务，避免真实API调用（使用标准库unittest.mock）\"\"\"\n    with patch('services.ai_service.AIService') as mock:\n        # Mock实例\n        mock_instance = MagicMock()\n        mock.return_value = mock_instance\n        \n        # Mock大纲生成\n        mock_instance.generate_outline.return_value = [\n            {'title': '测试页面1', 'points': ['要点1', '要点2']},\n            {'title': '测试页面2', 'points': ['要点3', '要点4']},\n        ]\n        \n        # Mock扁平化大纲\n        mock_instance.flatten_outline.return_value = [\n            {'title': '测试页面1', 'points': ['要点1', '要点2']},\n            {'title': '测试页面2', 'points': ['要点3', '要点4']},\n        ]\n        \n        # Mock描述生成\n        mock_instance.generate_page_description.return_value = {\n            'title': '测试标题',\n            'text_content': ['内容1', '内容2'],\n            'extra_fields': {'排版布局': '居中布局'}\n        }\n        \n        # Mock图片生成 - 返回一个简单的测试图片\n        from PIL import Image\n        test_image = Image.new('RGB', (1920, 1080), color='blue')\n        mock_instance.generate_image.return_value = test_image\n        \n        yield mock_instance\n\n\n@pytest.fixture\ndef temp_upload_dir():\n    \"\"\"创建临时上传目录\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        yield tmpdir\n\n\n@pytest.fixture\ndef sample_image_file():\n    \"\"\"创建示例图片文件\"\"\"\n    # 创建一个简单的PNG文件（1x1像素的红色图片）\n    import io\n    from PIL import Image\n    \n    img = Image.new('RGB', (100, 100), color='red')\n    img_bytes = io.BytesIO()\n    img.save(img_bytes, format='PNG')\n    img_bytes.seek(0)\n    \n    return img_bytes\n\n\n# =====================================\n# 测试工具函数\n# =====================================\n\ndef assert_success_response(response, status_code=200):\n    \"\"\"断言成功响应\"\"\"\n    assert response.status_code == status_code\n    data = response.get_json()\n    assert data is not None\n    assert data.get('success') is True\n    return data\n\n\ndef assert_error_response(response, expected_status=None):\n    \"\"\"断言错误响应\"\"\"\n    if expected_status:\n        assert response.status_code == expected_status\n    data = response.get_json()\n    assert data is not None\n    assert data.get('success') is False or 'error' in data\n    return data\n\n"
  },
  {
    "path": "backend/tests/integration/README.md",
    "content": "# Backend Integration Tests\n\n## 测试分类\n\n### 1. Flask Test Client 测试（不需要运行服务）\n**文件**: `test_full_workflow.py`\n\n这些测试使用 Flask 的测试客户端（`client` fixture），不需要真实的服务运行。\n\n**特点**：\n- ✅ 快速（毫秒级）\n- ✅ 不需要启动服务\n- ✅ 在 CI 的 `backend-integration-test` 阶段运行\n- ✅ 使用 mock 模式，不需要真实 API key\n\n**运行方式**：\n```bash\ncd backend\nuv run pytest tests/integration/test_full_workflow.py -v\n```\n\n### 2. Real Service 测试（需要运行服务）\n**文件**: `test_api_full_flow.py`\n\n这些测试使用 `requests` 库直接调用 HTTP 端点，需要真实的后端服务运行。\n\n**特点**：\n- ⏱️ 较慢（需要真实 HTTP 请求）\n- 🔧 需要服务运行在 `http://localhost:5000`\n- 🏗️ 在 CI 的 `docker-test` 阶段运行（服务已启动）\n- 🔑 完整流程测试需要真实 AI API key\n\n**标记**: `@pytest.mark.requires_service`\n\n**运行方式**：\n```bash\n# 1. 启动服务\ndocker compose up -d\n\n# 2. 运行测试\ncd backend\nuv run pytest tests/integration/test_api_full_flow.py -v -m \"requires_service\"\n```\n\n## CI/CD 策略\n\n### Backend Integration Test 阶段\n**何时运行**: 在每次 PR 和 push 时\n\n**运行测试**: \n- ✅ 使用 Flask test client 的测试\n- ❌ 跳过需要真实服务的测试\n\n```yaml\n# 跳过 @pytest.mark.requires_service 标记的测试\npytest tests/integration -v -m \"not requires_service\"\n```\n\n**环境变量**:\n```yaml\nTESTING: true\nSKIP_SERVICE_TESTS: true\nGOOGLE_API_KEY: mock-api-key-for-testing\n```\n\n### Docker Test 阶段\n**何时运行**: 在 PR 添加 `ready-for-test` 标签时\n\n**运行测试**:\n- ✅ 运行需要真实服务的测试\n- ✅ 测试完整的 API 调用流程\n\n```yaml\n# 只运行 @pytest.mark.requires_service 标记的测试\npytest tests/integration/test_api_full_flow.py -v -m \"requires_service\"\n```\n\n**环境变量**:\n```yaml\nSKIP_SERVICE_TESTS: false\nGOOGLE_API_KEY: <real-api-key-from-secrets>\n```\n\n## Pytest Markers\n\n所有可用的 markers 定义在 `pytest.ini` 中：\n\n| Marker | 说明 | 示例 |\n|--------|------|------|\n| `unit` | 单元测试 | 测试单个函数或方法 |\n| `integration` | 集成测试 | 测试多个组件交互 |\n| `slow` | 慢速测试 | 需要 AI API 调用的测试 |\n| `requires_service` | 需要运行服务 | 使用 requests 调用 HTTP 端点 |\n| `mock` | 使用 mock | 不调用真实外部服务 |\n| `docker` | Docker 环境测试 | 需要 Docker 环境 |\n\n## 运行示例\n\n### 运行所有集成测试（跳过需要服务的）\n```bash\ncd backend\nSKIP_SERVICE_TESTS=true uv run pytest tests/integration/ -v -m \"not requires_service\"\n```\n\n### 只运行需要服务的测试\n```bash\n# 确保服务已启动\ndocker compose up -d\n\n# 运行测试\ncd backend\nSKIP_SERVICE_TESTS=false uv run pytest tests/integration/ -v -m \"requires_service\"\n```\n\n### 运行所有集成测试（需要服务）\n```bash\n# 确保服务已启动\ndocker compose up -d\n\n# 运行所有测试\ncd backend\nuv run pytest tests/integration/ -v\n```\n\n### 运行特定测试\n```bash\n# 运行快速 API 测试（需要服务）\ncd backend\nuv run pytest tests/integration/test_api_full_flow.py::TestAPIFullFlow::test_quick_api_flow_no_ai -v\n\n# 运行完整流程测试（需要服务和真实 API key）\ncd backend\nuv run pytest tests/integration/test_api_full_flow.py::TestAPIFullFlow::test_api_full_flow_create_to_export -v\n```\n\n## 故障排除\n\n### 问题：`ConnectionRefusedError: [Errno 111] Connection refused`\n\n**原因**: 测试尝试连接 `localhost:5000`，但服务未运行。\n\n**解决方案**:\n1. 启动服务：`docker compose up -d`\n2. 或者跳过这些测试：`pytest -m \"not requires_service\"`\n\n### 问题：测试在 CI 的 backend-integration-test 阶段失败\n\n**原因**: 该阶段不启动服务，应该跳过 `requires_service` 测试。\n\n**解决方案**: 确保 CI 配置使用了正确的 pytest 命令：\n```yaml\npytest tests/integration -v -m \"not requires_service\"\n```\n\n## 最佳实践\n\n1. **新的集成测试**:\n   - 如果测试可以使用 Flask test client → 添加到 `test_full_workflow.py`\n   - 如果测试需要真实 HTTP 调用 → 添加到 `test_api_full_flow.py` 并标记 `@pytest.mark.requires_service`\n\n2. **Marker 使用**:\n   ```python\n   @pytest.mark.integration\n   @pytest.mark.requires_service\n   def test_real_api_call(self):\n       response = requests.post('http://localhost:5000/api/projects', ...)\n   ```\n\n3. **环境检查**:\n   - 文件级跳过：使用 `pytestmark = pytest.mark.skipif(...)`\n   - 测试级跳过：使用 `@pytest.mark.skipif(...)`\n\n---\n\n**更新日期**: 2025-12-22  \n**维护者**: Banana Slides Team\n\n"
  },
  {
    "path": "backend/tests/integration/__init__.py",
    "content": "# 后端集成测试模块\n\n"
  },
  {
    "path": "backend/tests/integration/test_api_full_flow.py",
    "content": "\"\"\"\nAPI Full Flow Integration Test\n\nThis test validates the complete API flow without UI:\n1. Create project from idea\n2. Upload template image\n3. Generate outline\n4. Generate descriptions  \n5. Generate images (using template)\n6. Export PPT\n\nNote: \n- This test requires REAL running backend service (not Flask test client)\n- This test requires real AI API keys (GOOGLE_API_KEY)\n- These tests should only run in the docker-test stage of CI\n\"\"\"\n\nimport pytest\nimport requests\nimport time\nimport os\nimport io\nfrom pathlib import Path\nfrom PIL import Image\n\n\n# Skip these tests if service is not running (for backend-integration-test stage)\npytestmark = pytest.mark.skipif(\n    os.environ.get('SKIP_SERVICE_TESTS', '').lower() == 'true',\n    reason=\"Skipping tests that require running backend service\"\n)\n\n\nBASE_URL = \"http://localhost:5000\"\nAPI_TIMEOUT = 180  # 3 minutes timeout for AI operations\n\n\ndef wait_for_project_status(project_id: str, expected_status: str, timeout: int = 180):\n    \"\"\"Wait for project to reach expected status with smart retry.\"\"\"\n    start_time = time.time()\n    check_interval = 2  # Start with 2 seconds\n    max_interval = 10\n    consecutive_errors = 0\n    max_consecutive_errors = 3\n    \n    while time.time() - start_time < timeout:\n        try:\n            response = requests.get(f\"{BASE_URL}/api/projects/{project_id}\", timeout=10)\n            \n            if not response.ok:\n                consecutive_errors += 1\n                if consecutive_errors >= max_consecutive_errors:\n                    raise Exception(f\"Failed to get project status after {max_consecutive_errors} consecutive errors\")\n                time.sleep(check_interval * 2)\n                continue\n            \n            consecutive_errors = 0\n            data = response.json()\n            current_status = data['data']['status']\n            \n            elapsed = int(time.time() - start_time)\n            print(f\"[{elapsed}s] Project status: {current_status}, waiting for: {expected_status}\")\n            \n            if current_status == expected_status:\n                print(f\"✓ Project reached status: {expected_status} (took {elapsed}s)\")\n                return\n            \n            if current_status == 'FAILED':\n                error_msg = data['data'].get('error', 'Unknown error')\n                raise Exception(f\"Project generation failed. Expected: {expected_status}, Got: {current_status}. Error: {error_msg}\")\n            \n            # Adaptive interval\n            elapsed_time = time.time() - start_time\n            if elapsed_time > 30:\n                check_interval = min(max_interval, check_interval + 1)\n            \n            time.sleep(check_interval)\n            \n        except Exception as e:\n            if \"Failed to get project status\" in str(e) or \"Project generation failed\" in str(e):\n                raise\n            consecutive_errors += 1\n            if consecutive_errors >= max_consecutive_errors:\n                raise Exception(f\"Network error: {str(e)}\")\n            time.sleep(check_interval * 2)\n    \n    raise Exception(f\"Timeout: Project did not reach status {expected_status} within {timeout}s\")\n\n\ndef wait_for_task_completion(project_id: str, task_id: str, timeout: int = 120):\n    \"\"\"Wait for task to complete with smart retry.\"\"\"\n    start_time = time.time()\n    check_interval = 3\n    max_interval = 10\n    consecutive_errors = 0\n    max_consecutive_errors = 3\n    \n    while time.time() - start_time < timeout:\n        try:\n            response = requests.get(\n                f\"{BASE_URL}/api/projects/{project_id}/tasks/{task_id}\",\n                timeout=10\n            )\n            \n            if not response.ok:\n                consecutive_errors += 1\n                if consecutive_errors >= max_consecutive_errors:\n                    raise Exception(f\"Failed to get task status after {max_consecutive_errors} consecutive errors\")\n                time.sleep(check_interval * 2)\n                continue\n            \n            consecutive_errors = 0\n            data = response.json()\n            task_status = data['data']['status']\n            \n            elapsed = int(time.time() - start_time)\n            print(f\"[{elapsed}s] Task {task_id[:8]}... status: {task_status}\")\n            \n            if task_status == 'COMPLETED':\n                print(f\"✓ Task {task_id[:8]}... completed (took {elapsed}s)\")\n                return\n            \n            if task_status == 'FAILED':\n                error_msg = data['data'].get('error_message', 'Unknown error')\n                raise Exception(f\"Task {task_id} failed: {error_msg}\")\n            \n            # Adaptive interval\n            elapsed_time = time.time() - start_time\n            if elapsed_time > 60:\n                check_interval = min(max_interval, check_interval + 1)\n            \n            time.sleep(check_interval)\n            \n        except Exception as e:\n            if \"Failed to get task status\" in str(e) or \"Task\" in str(e) and \"failed\" in str(e):\n                raise\n            consecutive_errors += 1\n            if consecutive_errors >= max_consecutive_errors:\n                raise Exception(f\"Network error: {str(e)}\")\n            time.sleep(check_interval * 2)\n    \n    raise Exception(f\"Timeout: Task {task_id} did not complete within {timeout}s\")\n\n\n@pytest.fixture\ndef project_id():\n    \"\"\"Fixture that creates a project and cleans up after test.\"\"\"\n    created_project_ids = []\n    \n    def register_project(pid):\n        created_project_ids.append(pid)\n    \n    yield register_project\n    \n    # Cleanup\n    for pid in created_project_ids:\n        try:\n            requests.delete(f\"{BASE_URL}/api/projects/{pid}\", timeout=10)\n            print(f\"✓ Cleaned up project: {pid}\")\n        except Exception as e:\n            print(f\"Failed to cleanup project {pid}: {e}\")\n\n\nclass TestAPIFullFlow:\n    \"\"\"API Integration Tests - Full workflow from creation to export.\n    \n    These tests require a running backend service and are designed to run\n    in the docker-test stage of CI where services are started.\n    \"\"\"\n    \n    @pytest.mark.integration\n    @pytest.mark.slow\n    @pytest.mark.requires_service\n    def test_api_full_flow_create_to_export(self, project_id):\n        \"\"\"\n        Test complete API flow: Create project → Upload template → Outline → Descriptions → Images (with template) → Export PPT\n        \n        This test requires real AI API keys and takes 5-10 minutes to complete.\n        \"\"\"\n        print('\\n' + '=' * 40)\n        print('🚀 Starting API full flow integration test')\n        print('=' * 40 + '\\n')\n        \n        # Step 1: Create project\n        print('📝 Step 1: Creating project...')\n        response = requests.post(\n            f\"{BASE_URL}/api/projects\",\n            json={\n                'creation_type': 'idea',\n                'idea_prompt': '创建一份关于人工智能基础的简短PPT，包含3页内容：什么是AI、AI的应用、AI的未来'\n            },\n            timeout=30\n        )\n        \n        assert response.status_code in [200, 201]  # 201 Created is also valid\n        data = response.json()\n        assert data['success'] is True\n        assert 'project_id' in data['data']\n        \n        pid = data['data']['project_id']\n        project_id(pid)  # Register for cleanup\n        print(f\"✓ Project created successfully: {pid}\\n\")\n        \n        # Step 1.5: Upload template image\n        print('🖼️  Step 1.5: Uploading template image...')\n        # Create a simple test template image\n        template_img = Image.new('RGB', (1920, 1080), color='lightblue')\n        img_bytes = io.BytesIO()\n        template_img.save(img_bytes, format='PNG')\n        img_bytes.seek(0)\n        \n        response = requests.post(\n            f\"{BASE_URL}/api/projects/{pid}/template\",\n            files={'template_image': ('template.png', img_bytes, 'image/png')},\n            timeout=30\n        )\n        \n        assert response.status_code in [200, 201]\n        data = response.json()\n        assert data['success'] is True\n        print('✓ Template image uploaded successfully\\n')\n        \n        # Step 2: Generate outline\n        print('📋 Step 2: Triggering outline generation...')\n        response = requests.post(\n            f\"{BASE_URL}/api/projects/{pid}/generate/outline\",\n            json={},\n            timeout=30\n        )\n        \n        assert response.status_code == 200\n        data = response.json()\n        assert data['success'] is True\n        print('✓ Outline generation request submitted\\n')\n        \n        # Step 3: Wait for outline completion\n        print('⏳ Step 3: Waiting for outline generation to complete...')\n        wait_for_project_status(pid, 'OUTLINE_GENERATED', timeout=API_TIMEOUT)\n        \n        # Verify pages were created\n        response = requests.get(f\"{BASE_URL}/api/projects/{pid}\", timeout=10)\n        data = response.json()\n        pages = data['data']['pages']\n        \n        assert pages is not None\n        assert len(pages) > 0\n        print(f\"✓ Outline generated successfully, contains {len(pages)} pages\\n\")\n        \n        # Step 4: Generate descriptions\n        print('✍️  Step 4: Starting to generate page descriptions...')\n        response = requests.post(\n            f\"{BASE_URL}/api/projects/{pid}/generate/descriptions\",\n            json={},\n            timeout=30\n        )\n        \n        assert response.status_code == 202  # 202 Accepted for async operations\n        data = response.json()\n        assert data['success'] is True\n        \n        desc_task_id = data['data']['task_id']\n        print(f\"  Task ID: {desc_task_id}\")\n        \n        # Wait for description generation\n        wait_for_task_completion(pid, desc_task_id, timeout=API_TIMEOUT)\n        wait_for_project_status(pid, 'DESCRIPTIONS_GENERATED', timeout=10)\n        print('✓ All page descriptions generated\\n')\n        \n        # Step 5: Generate images\n        print('🎨 Step 5: Starting to generate page images...')\n        response = requests.post(\n            f\"{BASE_URL}/api/projects/{pid}/generate/images\",\n            json={\n                'use_template': True,  # Use the uploaded template\n                'aspect_ratio': '16:9',\n                'resolution': '1080p'\n            },\n            timeout=30\n        )\n        \n        assert response.status_code == 202  # 202 Accepted for async operations\n        data = response.json()\n        assert data['success'] is True\n        \n        image_task_id = data['data']['task_id']\n        print(f\"  Task ID: {image_task_id}\")\n        \n        # Wait for image generation (slower, 5 minutes timeout)\n        wait_for_task_completion(pid, image_task_id, timeout=300)\n        wait_for_project_status(pid, 'COMPLETED', timeout=10)\n        print('✓ All page images generated\\n')\n        \n        # Verify all pages have images\n        response = requests.get(f\"{BASE_URL}/api/projects/{pid}\", timeout=10)\n        data = response.json()\n        pages = data['data'].get('pages', [])\n        \n        assert len(pages) > 0\n        \n        for page in pages:\n            assert page.get('generated_image_url') is not None\n            assert page.get('status') == 'COMPLETED'\n            print(f\"  ✓ Page {page['order_index'] + 1}: Image generated\")\n        print()\n        \n        # Step 6: Export PPT\n        print('📦 Step 6: Exporting PPT file...')\n        response = requests.get(\n            f\"{BASE_URL}/api/projects/{pid}/export/pptx?filename=integration-test.pptx\",\n            timeout=60\n        )\n        \n        assert response.status_code == 200\n        data = response.json()\n        assert data['success'] is True\n        assert 'download_url' in data['data']\n        assert '.pptx' in data['data']['download_url']\n        \n        print(f\"  Export URL: {data['data']['download_url']}\")\n        \n        # Step 7: Verify PPT can be downloaded\n        print('📥 Step 7: Verifying PPT file can be downloaded...')\n        download_url = data['data']['download_url']\n        response = requests.get(f\"{BASE_URL}{download_url}\", timeout=30)\n        \n        assert response.status_code == 200\n        \n        # Verify it's a PPTX file - check Content-Type or file extension\n        content_type = response.headers.get('content-type', '').lower()\n        is_pptx_content_type = (\n            'application/vnd.openxmlformats-officedocument.presentationml.presentation' in content_type or\n            'application/octet-stream' in content_type  # Flask may serve as octet-stream\n        )\n        is_pptx_filename = download_url.endswith('.pptx')\n        \n        assert is_pptx_content_type or is_pptx_filename, \\\n            f\"Expected PPTX file, got Content-Type: {content_type}, URL: {download_url}\"\n        \n        ppt_data = response.content\n        assert len(ppt_data) > 1000  # PPT should be larger than 1KB\n        \n        print(f\"✓ PPT file downloaded successfully, size: {len(ppt_data) / 1024:.2f} KB\\n\")\n        \n        print('=' * 40)\n        print('✅ API integration test passed!')\n        print('=' * 40 + '\\n')\n    \n    @pytest.mark.integration\n    @pytest.mark.requires_service\n    def test_quick_api_flow_no_ai(self):\n        \"\"\"Quick test: Only verify API endpoints work (skip AI generation).\n        \n        This test requires a running backend service.\n        \"\"\"\n        print('\\n🏃 Quick API flow test (skip AI generation)\\n')\n        \n        # Create project\n        response = requests.post(\n            f\"{BASE_URL}/api/projects\",\n            json={\n                'creation_type': 'idea',\n                'idea_prompt': 'API test project'\n            },\n            timeout=30\n        )\n        \n        assert response.status_code in [200, 201]  # 201 Created is also valid\n        data = response.json()\n        pid = data['data']['project_id']\n        print(f\"✓ Project created: {pid}\")\n        \n        # Get project info\n        response = requests.get(f\"{BASE_URL}/api/projects/{pid}\", timeout=10)\n        assert response.status_code == 200\n        print('✓ Project query successful')\n        \n        # List all projects\n        response = requests.get(f\"{BASE_URL}/api/projects\", timeout=10)\n        assert response.status_code == 200\n        data = response.json()\n        assert 'projects' in data['data']\n        print(f\"✓ Project list query successful, total {len(data['data']['projects'])} projects\")\n        \n        # Delete project\n        response = requests.delete(f\"{BASE_URL}/api/projects/{pid}\", timeout=10)\n        assert response.status_code == 200\n        print('✓ Project deleted successfully\\n')\n\n"
  },
  {
    "path": "backend/tests/integration/test_full_workflow.py",
    "content": "\"\"\"\n完整工作流集成测试\n\n测试从创建项目到导出PPTX的完整流程\n\"\"\"\n\nimport pytest\nimport time\nfrom conftest import assert_success_response\n\n\nclass TestFullWorkflow:\n    \"\"\"完整工作流测试\"\"\"\n    \n    def test_create_project_and_get_details(self, client):\n        \"\"\"测试创建项目并获取详情\"\"\"\n        # 1. 创建项目\n        create_response = client.post('/api/projects', json={\n            'creation_type': 'idea',\n            'idea_prompt': '生成一份关于量子计算的PPT，共3页'\n        })\n        \n        data = assert_success_response(create_response, 201)\n        project_id = data['data']['project_id']\n        \n        # 2. 获取项目详情\n        get_response = client.get(f'/api/projects/{project_id}')\n        \n        data = assert_success_response(get_response)\n        assert data['data']['project_id'] == project_id\n        assert data['data']['status'] == 'DRAFT'\n    \n    def test_template_upload_workflow(self, client, sample_image_file):\n        \"\"\"测试模板上传工作流\"\"\"\n        # 1. 创建项目\n        create_response = client.post('/api/projects', json={\n            'creation_type': 'idea',\n            'idea_prompt': '测试模板上传'\n        })\n        \n        data = assert_success_response(create_response, 201)\n        project_id = data['data']['project_id']\n        \n        # 2. 上传模板\n        upload_response = client.post(\n            f'/api/projects/{project_id}/template',\n            data={'template_image': (sample_image_file, 'template.png')},\n            content_type='multipart/form-data'\n        )\n        \n        # 检查上传结果\n        assert upload_response.status_code in [200, 201]\n    \n    def test_project_lifecycle(self, client):\n        \"\"\"测试项目完整生命周期\"\"\"\n        # 1. 创建\n        create_response = client.post('/api/projects', json={\n            'creation_type': 'idea',\n            'idea_prompt': '生命周期测试'\n        })\n        data = assert_success_response(create_response, 201)\n        project_id = data['data']['project_id']\n        \n        # 2. 读取\n        get_response = client.get(f'/api/projects/{project_id}')\n        assert_success_response(get_response)\n        \n        # 3. 更新（如果API支持）\n        # update_response = client.put(f'/api/projects/{project_id}', json={...})\n        \n        # 4. 删除\n        delete_response = client.delete(f'/api/projects/{project_id}')\n        assert_success_response(delete_response)\n        \n        # 5. 确认删除\n        verify_response = client.get(f'/api/projects/{project_id}')\n        assert verify_response.status_code == 404\n\n\nclass TestAPIErrorHandling:\n    \"\"\"API错误处理测试\"\"\"\n    \n    def test_invalid_json_body(self, client):\n        \"\"\"测试无效的JSON请求体\"\"\"\n        response = client.post(\n            '/api/projects',\n            data='invalid json',\n            content_type='application/json'\n        )\n        \n        assert response.status_code in [400, 415, 422]\n    \n    def test_missing_required_fields(self, client):\n        \"\"\"测试缺少必需字段\"\"\"\n        response = client.post('/api/projects', json={})\n        \n        assert response.status_code in [400, 422]\n    \n    def test_method_not_allowed(self, client):\n        \"\"\"测试不允许的HTTP方法\"\"\"\n        response = client.patch('/api/projects')\n        \n        # PATCH可能不被支持\n        assert response.status_code in [404, 405]\n\n\nclass TestConcurrentRequests:\n    \"\"\"并发请求测试\"\"\"\n    \n    def test_multiple_project_creation(self, client):\n        \"\"\"测试多个项目创建不冲突\"\"\"\n        project_ids = []\n        \n        for i in range(3):\n            response = client.post('/api/projects', json={\n                'creation_type': 'idea',\n                'idea_prompt': f'并发测试项目 {i}'\n            })\n            \n            data = assert_success_response(response, 201)\n            project_ids.append(data['data']['project_id'])\n        \n        # 确保所有项目ID都不同\n        assert len(set(project_ids)) == 3\n        \n        # 清理\n        for pid in project_ids:\n            client.delete(f'/api/projects/{pid}')\n\n"
  },
  {
    "path": "backend/tests/pytest.ini",
    "content": "[pytest]\n# pytest配置文件\n\n# 测试文件匹配模式\npython_files = test_*.py\npython_classes = Test*\npython_functions = test_*\n\n# 测试目录\ntestpaths = tests\n\n# 输出选项\naddopts = \n    -v\n    --strict-markers\n    --tb=short\n    --disable-warnings\n    -p no:cacheprovider\n\n# 标记定义\nmarkers =\n    unit: 单元测试\n    integration: 集成测试\n    e2e: 端到端测试\n    slow: 慢速测试（需要API调用）\n    mock: 使用mock的测试\n    docker: Docker环境测试\n    requires_service: 需要真实运行的后端服务（在docker-test阶段运行）\n\n# 覆盖率配置\n[coverage:run]\nsource = .\nomit = \n    */tests/*\n    */venv/*\n    */.venv/*\n    */migrations/*\n    */config.py\n\n[coverage:report]\nprecision = 2\nshow_missing = True\nskip_covered = False\n\n[coverage:html]\ndirectory = htmlcov\n\n"
  },
  {
    "path": "backend/tests/unit/__init__.py",
    "content": "# 后端单元测试模块\n\n"
  },
  {
    "path": "backend/tests/unit/test_ai_mock.py",
    "content": "\"\"\"\nAI服务Mock测试\n\n验证AI服务被正确mock，不会真正调用外部API\n\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock\n\n\nclass TestAIMock:\n    \"\"\"AI Mock测试\"\"\"\n    \n    def test_ai_service_is_mocked(self, mock_ai_service):\n        \"\"\"验证AI服务被正确mock\"\"\"\n        # 调用mock的方法\n        outline = mock_ai_service.generate_outline(\"测试prompt\")\n        \n        # 验证返回mock数据\n        assert len(outline) == 2\n        assert outline[0]['title'] == '测试页面1'\n        \n        # 验证方法被调用\n        mock_ai_service.generate_outline.assert_called_once_with(\"测试prompt\")\n    \n    def test_description_generation_mocked(self, mock_ai_service):\n        \"\"\"验证描述生成被mock\"\"\"\n        desc = mock_ai_service.generate_page_description(\n            \"idea\", [], {}, 1\n        )\n        \n        assert desc['title'] == '测试标题'\n        assert 'text_content' in desc\n    \n    def test_image_generation_mocked(self, mock_ai_service):\n        \"\"\"验证图片生成被mock\"\"\"\n        image = mock_ai_service.generate_image(\"prompt\", \"ref.png\")\n        \n        # 应该返回一个PIL Image对象\n        assert image is not None\n        assert image.size == (1920, 1080)\n    \n    def test_no_real_api_calls(self, mock_ai_service):\n        \"\"\"确保没有真实API调用\"\"\"\n        # 多次调用\n        for _ in range(10):\n            mock_ai_service.generate_outline(\"test\")\n            mock_ai_service.generate_page_description(\"idea\", [], {}, 1)\n        \n        # 验证调用次数\n        assert mock_ai_service.generate_outline.call_count == 10\n        assert mock_ai_service.generate_page_description.call_count == 10\n\n\nclass TestEnvironmentFlags:\n    \"\"\"环境标志测试\"\"\"\n    \n    def test_testing_flag_is_set(self):\n        \"\"\"验证测试标志已设置\"\"\"\n        import os\n        assert os.environ.get('TESTING') == 'true'\n    \n    def test_mock_ai_flag_is_set(self):\n        \"\"\"验证mock AI标志已设置\"\"\"\n        import os\n        assert os.environ.get('USE_MOCK_AI') == 'true'\n\n"
  },
  {
    "path": "backend/tests/unit/test_api_health.py",
    "content": "\"\"\"\n健康检查API单元测试\n\"\"\"\n\nimport pytest\n\n\nclass TestHealthEndpoint:\n    \"\"\"健康检查端点测试\"\"\"\n    \n    def test_health_check_returns_ok(self, client):\n        \"\"\"测试健康检查返回正常状态\"\"\"\n        response = client.get('/health')\n        \n        assert response.status_code == 200\n        data = response.get_json()\n        assert data['status'] == 'ok'\n        assert 'message' in data\n    \n    def test_health_check_response_format(self, client):\n        \"\"\"测试健康检查响应格式\"\"\"\n        response = client.get('/health')\n        \n        data = response.get_json()\n        assert isinstance(data, dict)\n        assert 'status' in data\n        assert 'message' in data\n\n"
  },
  {
    "path": "backend/tests/unit/test_api_material.py",
    "content": "\"\"\"\nMaterial upload API tests - including caption generation\n\"\"\"\nimport io\nimport pytest\nfrom unittest.mock import patch, MagicMock\nfrom PIL import Image\nfrom conftest import assert_success_response, assert_error_response\n\n\ndef _create_test_image():\n    \"\"\"Helper to create a test PNG image bytes\"\"\"\n    img = Image.new('RGB', (100, 100), color='red')\n    img_bytes = io.BytesIO()\n    img.save(img_bytes, format='PNG')\n    img_bytes.seek(0)\n    return img_bytes\n\n\n@pytest.mark.unit\nclass TestMaterialUpload:\n    \"\"\"Material upload endpoint tests\"\"\"\n\n    def test_upload_material_without_caption(self, client):\n        \"\"\"Upload without generate_caption param should not include caption in response\"\"\"\n        img_bytes = _create_test_image()\n        response = client.post(\n            '/api/materials/upload',\n            data={'file': (img_bytes, 'test.png')},\n            content_type='multipart/form-data'\n        )\n        data = assert_success_response(response, 201)\n        assert 'url' in data['data']\n        assert 'caption' not in data['data']\n\n    @patch('controllers.material_controller._generate_image_caption')\n    def test_upload_material_with_caption(self, mock_caption, client):\n        \"\"\"Upload with generate_caption=true should include AI caption\"\"\"\n        mock_caption.return_value = '一张红色方块图片'\n        img_bytes = _create_test_image()\n        response = client.post(\n            '/api/materials/upload?generate_caption=true',\n            data={'file': (img_bytes, 'test.png')},\n            content_type='multipart/form-data'\n        )\n        data = assert_success_response(response, 201)\n        assert data['data']['caption'] == '一张红色方块图片'\n        assert 'url' in data['data']\n        mock_caption.assert_called_once()\n\n    @patch('controllers.material_controller._generate_image_caption')\n    def test_upload_material_caption_failure_still_succeeds(self, mock_caption, client):\n        \"\"\"Caption failure should return empty string, upload still succeeds\"\"\"\n        mock_caption.return_value = ''\n        img_bytes = _create_test_image()\n        response = client.post(\n            '/api/materials/upload?generate_caption=true',\n            data={'file': (img_bytes, 'test.png')},\n            content_type='multipart/form-data'\n        )\n        data = assert_success_response(response, 201)\n        assert data['data']['caption'] == ''\n        assert 'url' in data['data']\n\n    @patch('controllers.material_controller._generate_image_caption')\n    def test_upload_material_caption_false_param(self, mock_caption, client):\n        \"\"\"generate_caption=false should not trigger caption generation\"\"\"\n        img_bytes = _create_test_image()\n        response = client.post(\n            '/api/materials/upload?generate_caption=false',\n            data={'file': (img_bytes, 'test.png')},\n            content_type='multipart/form-data'\n        )\n        data = assert_success_response(response, 201)\n        assert 'caption' not in data['data']\n        mock_caption.assert_not_called()\n\n    def test_upload_material_invalid_file_type(self, client):\n        \"\"\"Unsupported file type should return 400\"\"\"\n        response = client.post(\n            '/api/materials/upload',\n            data={'file': (io.BytesIO(b'fake data'), 'test.txt')},\n            content_type='multipart/form-data'\n        )\n        assert response.status_code == 400\n\n    def test_upload_material_no_file(self, client):\n        \"\"\"No file should return 400\"\"\"\n        response = client.post(\n            '/api/materials/upload',\n            content_type='multipart/form-data'\n        )\n        assert response.status_code == 400\n\n\n@pytest.mark.unit\nclass TestGenerateImageCaption:\n    \"\"\"Unit tests for _generate_image_caption function\"\"\"\n\n    def test_caption_returns_empty_on_missing_gemini_key(self, app):\n        \"\"\"Caption returns empty when Gemini API key is not configured\"\"\"\n        with app.app_context():\n            app.config['AI_PROVIDER_FORMAT'] = 'gemini'\n            app.config['GOOGLE_API_KEY'] = ''\n            from controllers.material_controller import _generate_image_caption\n            # Create a temp image\n            import tempfile\n            with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:\n                img = Image.new('RGB', (10, 10), color='blue')\n                img.save(f, format='PNG')\n                tmp_path = f.name\n            try:\n                result = _generate_image_caption(tmp_path)\n                assert result == ''\n            finally:\n                import os\n                os.unlink(tmp_path)\n\n    def test_caption_returns_empty_on_missing_openai_key(self, app):\n        \"\"\"Caption returns empty when OpenAI API key is not configured\"\"\"\n        with app.app_context():\n            app.config['AI_PROVIDER_FORMAT'] = 'openai'\n            app.config['OPENAI_API_KEY'] = ''\n            from controllers.material_controller import _generate_image_caption\n            import tempfile\n            with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:\n                img = Image.new('RGB', (10, 10), color='blue')\n                img.save(f, format='PNG')\n                tmp_path = f.name\n            try:\n                result = _generate_image_caption(tmp_path)\n                assert result == ''\n            finally:\n                import os\n                os.unlink(tmp_path)\n\n    def test_caption_returns_empty_on_invalid_file(self, app):\n        \"\"\"Caption returns empty on invalid image file\"\"\"\n        with app.app_context():\n            app.config['AI_PROVIDER_FORMAT'] = 'gemini'\n            app.config['GOOGLE_API_KEY'] = 'fake-key'\n            from controllers.material_controller import _generate_image_caption\n            result = _generate_image_caption('/nonexistent/path/image.png')\n            assert result == ''\n\n    @patch('google.genai.Client')\n    def test_caption_gemini_success(self, mock_client_class, app):\n        \"\"\"Caption with Gemini provider returns expected text\"\"\"\n        with app.app_context():\n            app.config['AI_PROVIDER_FORMAT'] = 'gemini'\n            app.config['GOOGLE_API_KEY'] = 'test-key'\n            app.config['GOOGLE_API_BASE'] = ''\n            app.config['IMAGE_CAPTION_MODEL'] = 'test-model'\n\n            mock_client = MagicMock()\n            mock_client_class.return_value = mock_client\n            mock_result = MagicMock()\n            mock_result.text = '  一张测试图片  '\n            mock_client.models.generate_content.return_value = mock_result\n\n            from controllers.material_controller import _generate_image_caption\n            import tempfile\n            with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:\n                img = Image.new('RGB', (10, 10), color='green')\n                img.save(f, format='PNG')\n                tmp_path = f.name\n            try:\n                result = _generate_image_caption(tmp_path)\n                assert result == '一张测试图片'\n                mock_client.models.generate_content.assert_called_once()\n            finally:\n                import os\n                os.unlink(tmp_path)\n"
  },
  {
    "path": "backend/tests/unit/test_api_project.py",
    "content": "\"\"\"\n项目管理API单元测试\n\"\"\"\n\nimport pytest\nfrom conftest import assert_success_response, assert_error_response\n\n\nclass TestProjectCreate:\n    \"\"\"项目创建测试\"\"\"\n    \n    def test_create_project_idea_mode(self, client):\n        \"\"\"测试从想法创建项目\"\"\"\n        response = client.post('/api/projects', json={\n            'creation_type': 'idea',\n            'idea_prompt': '生成一份关于AI的PPT'\n        })\n        \n        data = assert_success_response(response, 201)\n        assert 'project_id' in data['data']\n        assert data['data']['status'] == 'DRAFT'\n    \n    def test_create_project_outline_mode(self, client):\n        \"\"\"测试从大纲创建项目\"\"\"\n        response = client.post('/api/projects', json={\n            'creation_type': 'outline',\n            'outline': [\n                {'title': '第一页', 'points': ['要点1']},\n                {'title': '第二页', 'points': ['要点2']}\n            ]\n        })\n        \n        data = assert_success_response(response, 201)\n        assert 'project_id' in data['data']\n    \n    def test_create_project_missing_type(self, client):\n        \"\"\"测试缺少creation_type参数\"\"\"\n        response = client.post('/api/projects', json={\n            'idea_prompt': '测试'\n        })\n        \n        # 应该返回错误\n        assert response.status_code in [400, 422]\n    \n    def test_create_project_invalid_type(self, client):\n        \"\"\"测试无效的creation_type\"\"\"\n        response = client.post('/api/projects', json={\n            'creation_type': 'invalid_type',\n            'idea_prompt': '测试'\n        })\n        \n        assert response.status_code in [400, 422]\n\n\nclass TestProjectGet:\n    \"\"\"项目获取测试\"\"\"\n    \n    def test_get_project_success(self, client, sample_project):\n        \"\"\"测试获取项目成功\"\"\"\n        if not sample_project:\n            pytest.skip(\"项目创建失败\")\n        \n        project_id = sample_project['project_id']\n        response = client.get(f'/api/projects/{project_id}')\n        \n        data = assert_success_response(response)\n        assert data['data']['project_id'] == project_id\n    \n    def test_get_project_not_found(self, client):\n        \"\"\"测试获取不存在的项目\"\"\"\n        response = client.get('/api/projects/non-existent-id')\n        \n        assert response.status_code == 404\n    \n    def test_get_project_invalid_id_format(self, client):\n        \"\"\"测试无效的项目ID格式\"\"\"\n        response = client.get('/api/projects/invalid!@#$%id')\n        \n        # 可能返回404或400\n        assert response.status_code in [400, 404]\n\n\nclass TestProjectUpdate:\n    \"\"\"项目更新测试\"\"\"\n    \n    def test_update_project_status(self, client, sample_project):\n        \"\"\"测试更新项目状态\"\"\"\n        if not sample_project:\n            pytest.skip(\"项目创建失败\")\n        \n        project_id = sample_project['project_id']\n        response = client.put(f'/api/projects/{project_id}', json={\n            'status': 'GENERATING'\n        })\n        \n        # 状态更新应该成功\n        assert response.status_code == 200\n        data = response.get_json()\n        assert data['success'] is True\n\n\nclass TestProjectDelete:\n    \"\"\"项目删除测试\"\"\"\n    \n    def test_delete_project_success(self, client, sample_project):\n        \"\"\"测试删除项目成功\"\"\"\n        if not sample_project:\n            pytest.skip(\"项目创建失败\")\n        \n        project_id = sample_project['project_id']\n        response = client.delete(f'/api/projects/{project_id}')\n        \n        data = assert_success_response(response)\n        \n        # 确认项目已删除\n        get_response = client.get(f'/api/projects/{project_id}')\n        assert get_response.status_code == 404\n    \n    def test_delete_project_not_found(self, client):\n        \"\"\"测试删除不存在的项目\"\"\"\n        response = client.delete('/api/projects/non-existent-id')\n        \n        assert response.status_code == 404\n\n"
  },
  {
    "path": "backend/tests/unit/test_api_settings_provider.py",
    "content": "\"\"\"\nSettings controller tests for provider format handling.\n\"\"\"\n\nfrom types import SimpleNamespace\nfrom unittest.mock import MagicMock, patch\n\nfrom flask import Flask\n\nfrom controllers.settings_controller import update_settings, verify_api_key\n\n\ndef _build_settings(**overrides):\n    defaults = {\n        'ai_provider_format': 'gemini',\n        'api_key': None,\n        'api_base_url': None,\n        'text_model': None,\n    }\n    defaults.update(overrides)\n\n    settings = SimpleNamespace(**defaults)\n    settings.to_dict = lambda: {\n        'ai_provider_format': settings.ai_provider_format,\n        'api_key_length': len(settings.api_key) if settings.api_key else 0,\n    }\n    return settings\n\n\ndef test_update_settings_accepts_lazyllm_provider():\n    \"\"\"`lazyllm` should be accepted as a valid provider format.\"\"\"\n    app = Flask(__name__)\n\n    settings = _build_settings()\n    with app.app_context():\n        with app.test_request_context('/api/settings/', method='PUT', json={'ai_provider_format': 'lazyllm'}):\n            with patch('controllers.settings_controller.Settings.get_settings', return_value=settings):\n                with patch('controllers.settings_controller.db.session.commit'):\n                    with patch('controllers.settings_controller._sync_settings_to_config'):\n                        response, status_code = update_settings()\n\n    assert status_code == 200\n    data = response.get_json()\n    assert data['success'] is True\n    assert data['data']['ai_provider_format'] == 'lazyllm'\n\n\ndef test_verify_uses_configured_text_model():\n    \"\"\"Verify endpoint should use configured text model, not a hardcoded gemini model.\"\"\"\n    app = Flask(__name__)\n    app.config.update(\n        TEXT_MODEL='gemini-3-flash-preview',\n        AI_PROVIDER_FORMAT='lazyllm',\n    )\n\n    settings = _build_settings(ai_provider_format='lazyllm', text_model='deepseek-chat')\n    mock_provider = MagicMock()\n    mock_provider.generate_text.return_value = 'OK'\n\n    with app.app_context():\n        with app.test_request_context('/api/settings/verify', method='POST'):\n            with patch('controllers.settings_controller.Settings.get_settings', return_value=settings):\n                with patch('services.ai_providers.get_text_provider', return_value=mock_provider) as mock_get_provider:\n                    response, status_code = verify_api_key()\n\n    assert status_code == 200\n    data = response.get_json()\n    assert data['success'] is True\n    assert data['data']['available'] is True\n    mock_get_provider.assert_called_once_with(model='deepseek-chat')\n    mock_provider.generate_text.assert_called_once()\n"
  },
  {
    "path": "backend/tests/unit/test_editable_pptx_style_extraction.py",
    "content": "from pathlib import Path\n\nfrom services.export_service import ExportError, ExportService\nfrom services.image_editability.text_attribute_extractors import TextStyleResult\n\n\nclass FailingExtractor:\n    def extract_batch_with_full_image(self, full_image, text_elements, **kwargs):\n        raise RuntimeError(\"caption_provider 不支持图片输入\")\n\n    def extract(self, image, text_content=None, **kwargs):\n        return TextStyleResult(confidence=0.0, metadata={\"error\": \"caption_provider 不支持图片输入\"})\n\n\nclass EmptyGlobalExtractor:\n    def extract_batch_with_full_image(self, full_image, text_elements, **kwargs):\n        return {}\n\n    def extract(self, image, text_content=None, **kwargs):\n        return TextStyleResult(font_color_rgb=(255, 0, 0), confidence=0.9)\n\n\nclass EditableImageStub:\n    class BBox:\n        def __init__(self):\n            self.x0 = 0\n            self.y0 = 0\n            self.x1 = 100\n            self.y1 = 40\n\n    class Element:\n        def __init__(self, image_path: str):\n            self.element_type = \"text\"\n            self.element_id = \"text_0\"\n            self.content = \"hello\"\n            self.image_path = image_path\n            self.bbox = EditableImageStub.BBox()\n            self.bbox_global = self.bbox\n            self.children = []\n\n    def __init__(self, image_path: str):\n        self.image_path = image_path\n        self.elements = [EditableImageStub.Element(image_path)]\n\n\ndef _make_editable_images(tmp_path):\n    image_path = Path(tmp_path) / \"text.png\"\n    image_path.write_bytes(b\"png\")\n    return [EditableImageStub(str(image_path))]\n\n\ndef test_hybrid_style_extraction_fails_fast_when_provider_has_no_image_input(tmp_path):\n    editable_images = _make_editable_images(tmp_path)\n\n    try:\n        ExportService._batch_extract_text_styles_hybrid(\n            editable_images=editable_images,\n            text_attribute_extractor=FailingExtractor(),\n            max_workers=2,\n            fail_fast=True,\n        )\n        assert False, \"expected ExportError\"\n    except ExportError as exc:\n        assert exc.error_type == \"style_extraction\"\n        assert \"不支持图片输入\" in exc.message\n        assert \"image caption\" in exc.help_text\n\n\ndef test_hybrid_style_extraction_reports_missing_global_results_when_not_fail_fast(tmp_path):\n    editable_images = _make_editable_images(tmp_path)\n\n    results, failures = ExportService._batch_extract_text_styles_hybrid(\n        editable_images=editable_images,\n        text_attribute_extractor=EmptyGlobalExtractor(),\n        max_workers=2,\n        fail_fast=False,\n    )\n\n    assert \"text_0\" in results\n    assert failures == [(\"text_0\", \"全局识别未返回完整结果\")]\n"
  },
  {
    "path": "backend/tests/unit/test_file_parser_service.py",
    "content": "\"\"\"\nUnit tests for FileParserService provider-specific behavior.\n\"\"\"\n\nimport os\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import MagicMock, patch\n\nfrom PIL import Image\n\nfrom services.file_parser_service import FileParserService\n\n\ndef _create_temp_image() -> str:\n    with tempfile.NamedTemporaryFile(prefix='caption_test_', suffix='.png', delete=False) as tmp:\n        Image.new('RGB', (20, 20), color='green').save(tmp.name)\n        return tmp.name\n\n\ndef test_generate_single_caption_openai_uses_configured_model():\n    \"\"\"OpenAI caption generation should use `image_caption_model` from service config.\"\"\"\n    image_path = _create_temp_image()\n    try:\n        service = FileParserService(\n            mineru_token='test-token',\n            openai_api_key='test-openai-key',\n            image_caption_model='gpt-4.1-mini',\n            provider_format='openai',\n        )\n\n        mock_client = MagicMock()\n        mock_response = MagicMock()\n        mock_response.choices = [MagicMock(message=MagicMock(content='示例描述'))]\n        mock_client.chat.completions.create.return_value = mock_response\n\n        with patch('utils.path_utils.find_mineru_file_with_prefix', return_value=Path(image_path)):\n            with patch.object(service, '_get_openai_client', return_value=mock_client):\n                caption = service._generate_single_caption('/files/mineru/demo.png')\n\n        assert caption == '示例描述'\n        assert mock_client.chat.completions.create.call_args.kwargs['model'] == 'gpt-4.1-mini'\n    finally:\n        if os.path.exists(image_path):\n            os.remove(image_path)\n\n\ndef test_can_generate_captions_does_not_accept_legacy_prefixes():\n    \"\"\"LazyLLM caption check should ignore legacy BANANA_*/LAZYLLM_* key prefixes.\"\"\"\n    source = 'unit_test_source'\n    with patch.dict(\n        os.environ,\n        {\n            f'BANANA_{source.upper()}_API_KEY': 'test-key',\n            f'LAZYLLM_{source.upper()}_API_KEY': 'test-key',\n            f'BANANA_SLIDES_{source.upper()}_API_KEY': 'test-key',\n        },\n        clear=False,\n    ):\n        service = FileParserService(\n            mineru_token='test-token',\n            provider_format='lazyllm',\n            lazyllm_image_caption_source=source,\n        )\n        assert service._can_generate_captions() is False\n\n\ndef test_can_generate_captions_accepts_vendor_prefix_key():\n    \"\"\"LazyLLM caption check should accept {SOURCE}_API_KEY vendor prefix.\"\"\"\n    source = 'qwen'\n    key_name = f'{source.upper()}_API_KEY'\n\n    with patch.dict(os.environ, {key_name: 'test-key'}, clear=False):\n        service = FileParserService(\n            mineru_token='test-token',\n            provider_format='lazyllm',\n            lazyllm_image_caption_source=source,\n        )\n        assert service._can_generate_captions() is True\n"
  },
  {
    "path": "backend/tests/unit/test_image_prompt_ratio.py",
    "content": "\"\"\"Test that image generation prompt uses the correct aspect ratio.\"\"\"\nfrom services.prompts import get_image_generation_prompt\n\n\nclass TestImagePromptAspectRatio:\n    def test_default_ratio_is_16_9(self):\n        prompt = get_image_generation_prompt(\n            page_desc=\"Test page\",\n            outline_text=\"Test outline\",\n            current_section=\"Section 1\",\n        )\n        assert \"16:9比例\" in prompt\n\n    def test_custom_ratio_4_3(self):\n        prompt = get_image_generation_prompt(\n            page_desc=\"Test page\",\n            outline_text=\"Test outline\",\n            current_section=\"Section 1\",\n            aspect_ratio=\"4:3\",\n        )\n        assert \"4:3比例\" in prompt\n        assert \"16:9比例\" not in prompt\n\n    def test_custom_ratio_1_1(self):\n        prompt = get_image_generation_prompt(\n            page_desc=\"Test page\",\n            outline_text=\"Test outline\",\n            current_section=\"Section 1\",\n            aspect_ratio=\"1:1\",\n        )\n        assert \"1:1比例\" in prompt\n        assert \"16:9比例\" not in prompt\n"
  },
  {
    "path": "backend/tests/unit/test_lazyllm_image_content_type.py",
    "content": "\"\"\"\nUnit tests for LazyLLM image provider content-type fallback.\n\nVerifies that when LazyLLM raises a content-type error (S3 returns\napplication/octet-stream), the provider falls back to manual download.\n\"\"\"\nimport io\nimport sys\nimport types\nimport pytest\nfrom unittest.mock import MagicMock, patch\nfrom PIL import Image\n\n\ndef _make_png_bytes() -> bytes:\n    img = Image.new('RGB', (100, 60), color=(255, 0, 0))\n    buf = io.BytesIO()\n    img.save(buf, format='PNG')\n    return buf.getvalue()\n\n\ndef _inject_lazyllm_mock():\n    \"\"\"Inject a fake lazyllm into sys.modules so the provider can be imported.\"\"\"\n    lz = types.ModuleType('lazyllm')\n    lz.namespace = MagicMock(return_value=MagicMock())\n\n    components = types.ModuleType('lazyllm.components')\n    formatter = types.ModuleType('lazyllm.components.formatter')\n    formatter.decode_query_with_filepaths = MagicMock(return_value={'files': []})\n\n    sys.modules.setdefault('lazyllm', lz)\n    sys.modules.setdefault('lazyllm.components', components)\n    sys.modules.setdefault('lazyllm.components.formatter', formatter)\n    return lz, formatter\n\n\nclass TestLazyLLMContentTypeFallback:\n\n    def setup_method(self):\n        self._lz, self._formatter = _inject_lazyllm_mock()\n        # Remove cached provider module so it re-imports with our mock\n        for key in ('services.ai_providers.image.lazyllm_provider',\n                    'backend.services.ai_providers.image.lazyllm_provider'):\n            sys.modules.pop(key, None)\n\n    def _make_provider(self):\n        with patch('services.ai_providers.image.lazyllm_provider.ensure_lazyllm_namespace_key'):\n            from services.ai_providers.image.lazyllm_provider import LazyLLMImageProvider\n            provider = LazyLLMImageProvider.__new__(LazyLLMImageProvider)\n            provider._source = 'siliconflow'\n            provider.client = MagicMock()\n            return provider\n\n    def test_fallback_on_content_type_error(self):\n        \"\"\"Provider downloads image manually when LazyLLM raises content-type error.\"\"\"\n        provider = self._make_provider()\n\n        s3_url = 'https://s3.siliconflow.cn/outputs/test.png?X-Amz-Signature=abc'\n        error_msg = (\n            f'ModuleExecutionError: Failed to load image from {s3_url}\\n'\n            f'Invalid content type for image: application/octet-stream from {s3_url}\\n'\n            'Expected content type starting with \"image/\".'\n        )\n        provider.client.side_effect = Exception(error_msg)\n\n        png_bytes = _make_png_bytes()\n        mock_resp = MagicMock()\n        mock_resp.raise_for_status = MagicMock()\n        mock_resp.iter_content = MagicMock(return_value=iter([png_bytes]))\n\n        with patch('services.ai_providers.image.lazyllm_provider.requests.get',\n                   return_value=mock_resp) as mock_get:\n            result = provider.generate_image(prompt='test prompt')\n\n        assert result is not None\n        assert isinstance(result, Image.Image)\n        mock_get.assert_called_once()\n        assert 's3.siliconflow.cn' in mock_get.call_args[0][0]\n\n    def test_untrusted_host_is_not_fetched(self):\n        \"\"\"URLs from untrusted hosts should not be fetched (SSRF prevention).\"\"\"\n        provider = self._make_provider()\n\n        evil_url = 'https://evil.example.com/steal.png'\n        error_msg = (\n            f'Failed to load image from {evil_url}\\n'\n            'Invalid content type for image: application/octet-stream'\n        )\n        provider.client.side_effect = Exception(error_msg)\n\n        with patch('services.ai_providers.image.lazyllm_provider.requests.get') as mock_get:\n            with pytest.raises(Exception):\n                provider.generate_image(prompt='test prompt')\n        mock_get.assert_not_called()\n\n    def test_non_content_type_error_is_reraised(self):\n        \"\"\"Non content-type errors propagate normally.\"\"\"\n        provider = self._make_provider()\n        provider.client.side_effect = RuntimeError('network timeout')\n\n        with pytest.raises(RuntimeError, match='network timeout'):\n            provider.generate_image(prompt='test prompt')\n"
  },
  {
    "path": "backend/tests/unit/test_smart_merge.py",
    "content": "\"\"\"Test _smart_merge_pages position-based logic with a minimal Flask app.\"\"\"\nimport json\nimport os\nimport sys\nimport tempfile\nimport pytest\n\n# Ensure backend is on path\nsys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))\nos.environ.setdefault('TESTING', 'true')\nos.environ.setdefault('GOOGLE_API_KEY', 'mock')\n\n\n@pytest.fixture(scope='module')\ndef merge_app():\n    \"\"\"Minimal Flask app for testing _smart_merge_pages.\"\"\"\n    from flask import Flask\n    from models import db, Page, Project\n\n    app = Flask(__name__)\n    tmp = tempfile.mkdtemp()\n    app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{tmp}/test.db'\n    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False\n    db.init_app(app)\n    with app.app_context():\n        db.create_all()\n    yield app\n    import shutil\n    shutil.rmtree(tmp, ignore_errors=True)\n\n\n@pytest.fixture\ndef ctx(merge_app):\n    with merge_app.app_context():\n        from models import db\n        yield\n        db.session.rollback()\n        for t in reversed(db.metadata.sorted_tables):\n            db.session.execute(t.delete())\n        db.session.commit()\n\n\ndef _make_project(pid='test-proj'):\n    from models import db, Project\n    p = Project(id=pid, creation_type='idea', idea_prompt='test')\n    db.session.add(p)\n    db.session.commit()\n    return pid\n\n\ndef _make_page(project_id, title, order, desc=None, image_path=None, status='DRAFT'):\n    from models import db, Page\n    page = Page(project_id=project_id, order_index=order, status=status)\n    page.set_outline_content({'title': title, 'points': ['p1']})\n    if desc:\n        page.set_description_content({'text': desc})\n    if image_path:\n        page.generated_image_path = image_path\n    db.session.add(page)\n    db.session.commit()\n    return page\n\n\nclass TestPositionBasedMerge:\n\n    def test_equal_pages_preserves_description_and_image(self, ctx):\n        \"\"\"Same number of pages: outline updated, description/image kept.\"\"\"\n        from controllers.project_controller import _smart_merge_pages\n        from models import db\n\n        pid = _make_project()\n        old0 = _make_page(pid, 'Old Title A', 0, desc='desc A', image_path='/img/a.png', status='IMAGE_GENERATED')\n        old1 = _make_page(pid, 'Old Title B', 1, desc='desc B', image_path='/img/b.png', status='IMAGE_GENERATED')\n\n        result = _smart_merge_pages(pid, [\n            {'title': 'New Title A', 'points': ['new']},\n            {'title': 'New Title B', 'points': ['new']},\n        ])\n        db.session.flush()\n\n        assert len(result) == 2\n        # Same page objects reused\n        assert result[0].id == old0.id\n        assert result[1].id == old1.id\n        # Outline updated\n        assert result[0].get_outline_content()['title'] == 'New Title A'\n        assert result[1].get_outline_content()['title'] == 'New Title B'\n        # Description and image preserved\n        assert result[0].get_description_content()['text'] == 'desc A'\n        assert result[0].generated_image_path == '/img/a.png'\n        assert result[1].get_description_content()['text'] == 'desc B'\n        assert result[1].generated_image_path == '/img/b.png'\n\n    def test_more_pages_creates_new_ones(self, ctx):\n        \"\"\"New outline has more pages: old pages kept, new pages created.\"\"\"\n        from controllers.project_controller import _smart_merge_pages\n        from models import db\n\n        pid = _make_project('proj-more')\n        old0 = _make_page(pid, 'Page A', 0, desc='desc A')\n\n        result = _smart_merge_pages(pid, [\n            {'title': 'Page A updated', 'points': []},\n            {'title': 'Page B new', 'points': ['b1']},\n        ])\n        db.session.flush()\n\n        assert len(result) == 2\n        assert result[0].id == old0.id\n        assert result[0].get_description_content()['text'] == 'desc A'\n        assert result[1].status == 'DRAFT'\n        assert result[1].get_description_content() is None\n\n    def test_fewer_pages_deletes_trailing(self, ctx):\n        \"\"\"New outline has fewer pages: trailing old pages deleted.\"\"\"\n        from controllers.project_controller import _smart_merge_pages\n        from models import db, Page\n\n        pid = _make_project('proj-fewer')\n        old0 = _make_page(pid, 'Keep', 0, desc='keep me')\n        old1 = _make_page(pid, 'Delete', 1, desc='gone')\n        old2 = _make_page(pid, 'Also Delete', 2, desc='also gone')\n\n        result = _smart_merge_pages(pid, [\n            {'title': 'Kept Page', 'points': []},\n        ])\n        db.session.flush()\n\n        assert len(result) == 1\n        assert result[0].id == old0.id\n        assert result[0].get_description_content()['text'] == 'keep me'\n        assert Page.query.get(old1.id) is None\n        assert Page.query.get(old2.id) is None\n\n    def test_no_old_pages_creates_all_new(self, ctx):\n        \"\"\"No existing pages: all new pages created.\"\"\"\n        from controllers.project_controller import _smart_merge_pages\n        from models import db\n\n        pid = _make_project('proj-empty')\n\n        result = _smart_merge_pages(pid, [\n            {'title': 'Brand New', 'points': ['x']},\n        ])\n        db.session.flush()\n\n        assert len(result) == 1\n        assert result[0].status == 'DRAFT'\n        assert result[0].get_outline_content()['title'] == 'Brand New'\n\n    def test_part_field_updated(self, ctx):\n        \"\"\"Part field is updated from new data.\"\"\"\n        from controllers.project_controller import _smart_merge_pages\n        from models import db\n\n        pid = _make_project('proj-part')\n        old0 = _make_page(pid, 'Page', 0)\n\n        result = _smart_merge_pages(pid, [\n            {'title': 'Page', 'points': [], 'part': 'Chapter 2'},\n        ])\n        db.session.flush()\n\n        assert result[0].part == 'Chapter 2'\n\n    def test_order_index_updated(self, ctx):\n        \"\"\"Order indices are set sequentially.\"\"\"\n        from controllers.project_controller import _smart_merge_pages\n        from models import db\n\n        pid = _make_project('proj-order')\n        _make_page(pid, 'A', 0)\n        _make_page(pid, 'B', 1)\n\n        result = _smart_merge_pages(pid, [\n            {'title': 'X', 'points': []},\n            {'title': 'Y', 'points': []},\n            {'title': 'Z', 'points': []},\n        ])\n        db.session.flush()\n\n        assert [p.order_index for p in result] == [0, 1, 2]\n"
  },
  {
    "path": "backend/utils/__init__.py",
    "content": "\"\"\"Utils package\"\"\"\nfrom .response import (\n    success_response, \n    error_response, \n    bad_request, \n    not_found, \n    invalid_status,\n    ai_service_error,\n    rate_limit_error\n)\nfrom .validators import validate_project_status, validate_page_status, allowed_file\nfrom .path_utils import convert_mineru_path_to_local, find_mineru_file_with_prefix, find_file_with_prefix\nfrom .pptx_builder import PPTXBuilder\nfrom .page_utils import parse_page_ids_from_query, parse_page_ids_from_body, get_filtered_pages\n\n__all__ = [\n    'success_response',\n    'error_response',\n    'bad_request',\n    'not_found',\n    'invalid_status',\n    'ai_service_error',\n    'rate_limit_error',\n    'validate_project_status',\n    'validate_page_status',\n    'allowed_file',\n    'convert_mineru_path_to_local',\n    'find_mineru_file_with_prefix',\n    'find_file_with_prefix',\n    'PPTXBuilder',\n    'parse_page_ids_from_query',\n    'parse_page_ids_from_body',\n    'get_filtered_pages'\n]\n\n"
  },
  {
    "path": "backend/utils/image_utils.py",
    "content": "\"\"\"\nImage utility functions\n\"\"\"\nfrom typing import Tuple\nfrom PIL import Image\n\n\ndef check_image_resolution(image: Image.Image, expected_resolution: str) -> Tuple[str, bool]:\n    \"\"\"\n    Check if the actual image resolution matches expected resolution.\n    \n    Args:\n        image: PIL Image object\n        expected_resolution: Expected resolution setting (\"1K\", \"2K\", \"4K\")\n        \n    Returns:\n        Tuple of (actual_resolution_category, is_match)\n    \"\"\"\n    max_dimension = max(image.width, image.height)\n    \n    # Determine actual resolution category\n    if max_dimension < 1500:\n        actual = \"1K\"\n    elif max_dimension < 3000:\n        actual = \"2K\"\n    else:\n        actual = \"4K\"\n    \n    is_match = actual == expected_resolution.upper()\n    return actual, is_match\n"
  },
  {
    "path": "backend/utils/latex_utils.py",
    "content": "\"\"\"\nLaTeX 工具模块 - 处理 LaTeX 公式转换\n\n提供以下功能：\n1. 简单 LaTeX 转文本（转义字符、简单符号）\n2. LaTeX 转 MathML\n3. MathML 转 OMML（用于 PPTX）\n\"\"\"\nimport re\nimport logging\nfrom typing import Optional, Tuple\n\nlogger = logging.getLogger(__name__)\n\n# LaTeX 转义字符映射\nLATEX_ESCAPES = {\n    r'\\%': '%',\n    r'\\$': '$',\n    r'\\&': '&',\n    r'\\#': '#',\n    r'\\_': '_',\n    r'\\{': '{',\n    r'\\}': '}',\n    r'\\ ': ' ',\n    r'\\,': ' ',  # thin space\n    r'\\;': ' ',  # thick space\n    r'\\!': '',   # negative thin space\n    r'\\quad': '  ',\n    r'\\qquad': '    ',\n}\n\n# 常用 LaTeX 符号到 Unicode 映射\nLATEX_SYMBOLS = {\n    # 希腊字母\n    r'\\alpha': 'α', r'\\beta': 'β', r'\\gamma': 'γ', r'\\delta': 'δ',\n    r'\\epsilon': 'ε', r'\\zeta': 'ζ', r'\\eta': 'η', r'\\theta': 'θ',\n    r'\\iota': 'ι', r'\\kappa': 'κ', r'\\lambda': 'λ', r'\\mu': 'μ',\n    r'\\nu': 'ν', r'\\xi': 'ξ', r'\\pi': 'π', r'\\rho': 'ρ',\n    r'\\sigma': 'σ', r'\\tau': 'τ', r'\\upsilon': 'υ', r'\\phi': 'φ',\n    r'\\chi': 'χ', r'\\psi': 'ψ', r'\\omega': 'ω',\n    r'\\Gamma': 'Γ', r'\\Delta': 'Δ', r'\\Theta': 'Θ', r'\\Lambda': 'Λ',\n    r'\\Xi': 'Ξ', r'\\Pi': 'Π', r'\\Sigma': 'Σ', r'\\Phi': 'Φ',\n    r'\\Psi': 'Ψ', r'\\Omega': 'Ω',\n    # 数学运算符\n    r'\\times': '×', r'\\div': '÷', r'\\pm': '±', r'\\mp': '∓',\n    r'\\cdot': '·', r'\\ast': '∗', r'\\star': '☆',\n    r'\\leq': '≤', r'\\geq': '≥', r'\\neq': '≠', r'\\approx': '≈',\n    r'\\equiv': '≡', r'\\sim': '∼', r'\\propto': '∝',\n    r'\\infty': '∞', r'\\partial': '∂', r'\\nabla': '∇',\n    r'\\sum': '∑', r'\\prod': '∏', r'\\int': '∫',\n    r'\\sqrt': '√', r'\\angle': '∠', r'\\degree': '°',\n    # 箭头\n    r'\\leftarrow': '←', r'\\rightarrow': '→', r'\\leftrightarrow': '↔',\n    r'\\Leftarrow': '⇐', r'\\Rightarrow': '⇒', r'\\Leftrightarrow': '⇔',\n    # 其他\n    r'\\ldots': '…', r'\\cdots': '⋯', r'\\vdots': '⋮',\n    r'\\forall': '∀', r'\\exists': '∃', r'\\in': '∈', r'\\notin': '∉',\n    r'\\subset': '⊂', r'\\supset': '⊃', r'\\cup': '∪', r'\\cap': '∩',\n}\n\n# 上标数字映射\nSUPERSCRIPT_MAP = {\n    '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴',\n    '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹',\n    '+': '⁺', '-': '⁻', '=': '⁼', '(': '⁽', ')': '⁾',\n    'n': 'ⁿ', 'i': 'ⁱ',\n}\n\n# 下标数字映射\nSUBSCRIPT_MAP = {\n    '0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄',\n    '5': '₅', '6': '₆', '7': '₇', '8': '₈', '9': '₉',\n    '+': '₊', '-': '₋', '=': '₌', '(': '₍', ')': '₎',\n    'a': 'ₐ', 'e': 'ₑ', 'o': 'ₒ', 'x': 'ₓ',\n    'i': 'ᵢ', 'j': 'ⱼ', 'n': 'ₙ', 'm': 'ₘ',\n}\n\n\ndef is_simple_latex(latex: str) -> bool:\n    \"\"\"\n    判断是否是简单的 LaTeX（可以直接转换为文本）\n    \n    简单 LaTeX 包括：\n    - 纯转义字符（如 10\\%）\n    - 简单符号（如 \\alpha）\n    - 简单上下标（如 x^2, x_1）\n    \"\"\"\n    # 移除所有已知的简单模式\n    test = latex\n    \n    # 移除转义字符\n    for escape in LATEX_ESCAPES:\n        test = test.replace(escape, '')\n    \n    # 移除符号\n    for symbol in LATEX_SYMBOLS:\n        test = test.replace(symbol, '')\n    \n    # 移除简单上下标 ^{...} 或 ^x\n    test = re.sub(r'\\^{[^{}]*}', '', test)\n    test = re.sub(r'\\^[0-9a-zA-Z]', '', test)\n    \n    # 移除简单下标 _{...} 或 _x\n    test = re.sub(r'_{[^{}]*}', '', test)\n    test = re.sub(r'_[0-9a-zA-Z]', '', test)\n    \n    # 如果剩余的都是普通字符，则是简单 LaTeX\n    remaining = test.strip()\n    # 检查是否还有未处理的 LaTeX 命令\n    if '\\\\' in remaining and not remaining.replace('\\\\', '').isalnum():\n        return False\n    \n    return True\n\n\ndef latex_to_text(latex: str) -> str:\n    \"\"\"\n    将简单 LaTeX 转换为 Unicode 文本\n    \n    Args:\n        latex: LaTeX 字符串\n    \n    Returns:\n        转换后的文本\n    \"\"\"\n    result = latex\n    \n    # 1. 处理转义字符\n    for escape, char in LATEX_ESCAPES.items():\n        result = result.replace(escape, char)\n    \n    # 2. 处理符号\n    for symbol, char in LATEX_SYMBOLS.items():\n        result = result.replace(symbol, char)\n    \n    # 3. 处理上标 ^{...} 或 ^x\n    def convert_superscript(match):\n        content = match.group(1) if match.group(1) else match.group(2)\n        return ''.join(SUPERSCRIPT_MAP.get(c, c) for c in content)\n    \n    result = re.sub(r'\\^{([^{}]*)}|\\^([0-9a-zA-Z])', convert_superscript, result)\n    \n    # 4. 处理下标 _{...} 或 _x\n    def convert_subscript(match):\n        content = match.group(1) if match.group(1) else match.group(2)\n        return ''.join(SUBSCRIPT_MAP.get(c, c) for c in content)\n    \n    result = re.sub(r'_{([^{}]*)}|_([0-9a-zA-Z])', convert_subscript, result)\n    \n    # 5. 移除剩余的 LaTeX 命令（如 \\text{}, \\mathrm{} 等）\n    result = re.sub(r'\\\\(?:text|mathrm|mathbf|mathit|mathbb|mathcal){([^{}]*)}', r'\\1', result)\n    \n    # 6. 清理多余的空格和花括号\n    result = result.replace('{', '').replace('}', '')\n    result = re.sub(r'\\s+', ' ', result).strip()\n    \n    return result\n\n\ndef latex_to_mathml(latex: str) -> Optional[str]:\n    \"\"\"\n    将 LaTeX 转换为 MathML\n    \n    Args:\n        latex: LaTeX 字符串\n    \n    Returns:\n        MathML 字符串，失败返回 None\n    \"\"\"\n    try:\n        import latex2mathml.converter\n        mathml = latex2mathml.converter.convert(latex)\n        return mathml\n    except Exception as e:\n        logger.warning(f\"LaTeX to MathML conversion failed: {e}\")\n        return None\n\n\ndef mathml_to_omml(mathml: str) -> Optional[str]:\n    \"\"\"\n    将 MathML 转换为 OMML (Office Math Markup Language)\n    \n    使用 Microsoft 的 MML2OMML.xsl 样式表进行转换\n    \n    Args:\n        mathml: MathML 字符串\n    \n    Returns:\n        OMML 字符串，失败返回 None\n    \"\"\"\n    try:\n        from lxml import etree\n        import os\n        \n        # MML2OMML.xsl 样式表路径\n        xsl_path = os.path.join(os.path.dirname(__file__), 'MML2OMML.xsl')\n        \n        if not os.path.exists(xsl_path):\n            logger.warning(f\"MML2OMML.xsl not found at {xsl_path}\")\n            return None\n        \n        # 解析 MathML\n        mathml_tree = etree.fromstring(mathml.encode('utf-8'))\n        \n        # 加载 XSLT\n        xslt_tree = etree.parse(xsl_path)\n        transform = etree.XSLT(xslt_tree)\n        \n        # 转换\n        omml_tree = transform(mathml_tree)\n        return etree.tostring(omml_tree, encoding='unicode')\n    \n    except ImportError:\n        logger.warning(\"lxml not installed, cannot convert to OMML\")\n        return None\n    except Exception as e:\n        logger.warning(f\"MathML to OMML conversion failed: {e}\")\n        return None\n\n\ndef convert_latex_for_pptx(latex: str) -> Tuple[str, Optional[str]]:\n    \"\"\"\n    为 PPTX 转换 LaTeX 公式\n    \n    Args:\n        latex: LaTeX 字符串\n    \n    Returns:\n        (text_fallback, omml) 元组\n        - text_fallback: 文本回退方案（总是有值）\n        - omml: OMML 字符串（如果转换成功）\n    \"\"\"\n    # 总是生成文本回退\n    text_fallback = latex_to_text(latex)\n    \n    # 对于简单 LaTeX，不需要 OMML\n    if is_simple_latex(latex):\n        return text_fallback, None\n    \n    # 尝试生成 OMML\n    mathml = latex_to_mathml(latex)\n    if mathml:\n        omml = mathml_to_omml(mathml)\n        if omml:\n            return text_fallback, omml\n    \n    return text_fallback, None\n\n"
  },
  {
    "path": "backend/utils/mask_utils.py",
    "content": "\"\"\"\n掩码图像生成工具\n用于从边界框（bbox）生成黑白掩码图像\n\"\"\"\nimport logging\nfrom typing import List, Tuple, Union, Callable\nfrom PIL import Image, ImageDraw\n\nlogger = logging.getLogger(__name__)\n\n\n# ============== Bbox 工具函数 ==============\n\ndef normalize_bbox(bbox: Union[Tuple, List, dict]) -> Tuple[int, int, int, int]:\n    \"\"\"\n    将各种格式的bbox标准化为 (x1, y1, x2, y2) 元组格式\n    \n    支持的输入格式：\n    - 元组/列表: (x1, y1, x2, y2)\n    - 字典: {\"x1\": x1, \"y1\": y1, \"x2\": x2, \"y2\": y2}\n    - 字典: {\"x\": x, \"y\": y, \"width\": w, \"height\": h}\n    \"\"\"\n    if isinstance(bbox, dict):\n        if 'x1' in bbox:\n            return (bbox['x1'], bbox['y1'], bbox['x2'], bbox['y2'])\n        elif 'x' in bbox:\n            return (bbox['x'], bbox['y'], \n                   bbox['x'] + bbox['width'], \n                   bbox['y'] + bbox['height'])\n        else:\n            raise ValueError(f\"无法识别的bbox字典格式: {bbox}\")\n    elif isinstance(bbox, (tuple, list)) and len(bbox) == 4:\n        return tuple(bbox)\n    else:\n        raise ValueError(f\"无法识别的bbox格式: {bbox}\")\n\n\ndef normalize_bboxes(bboxes: List[Union[Tuple, List, dict]]) -> List[Tuple[int, int, int, int]]:\n    \"\"\"批量标准化bbox列表\"\"\"\n    result = []\n    for bbox in bboxes:\n        try:\n            result.append(normalize_bbox(bbox))\n        except ValueError as e:\n            logger.warning(str(e))\n    return result\n\n\ndef merge_two_boxes(box1: Tuple, box2: Tuple) -> Tuple[int, int, int, int]:\n    \"\"\"合并两个bbox为一个包含它们的最小bbox\"\"\"\n    return (\n        min(box1[0], box2[0]),\n        min(box1[1], box2[1]),\n        max(box1[2], box2[2]),\n        max(box1[3], box2[3])\n    )\n\n\ndef _iterative_merge(\n    bboxes: List[Tuple[int, int, int, int]],\n    should_merge_fn: Callable[[Tuple, Tuple], bool]\n) -> List[Tuple[int, int, int, int]]:\n    \"\"\"\n    通用的迭代合并算法\n    \n    Args:\n        bboxes: 标准化后的bbox列表\n        should_merge_fn: 判断两个bbox是否应该合并的函数\n    \n    Returns:\n        合并后的bbox列表\n    \"\"\"\n    if not bboxes:\n        return []\n    if len(bboxes) == 1:\n        return list(bboxes)\n    \n    normalized = list(bboxes)\n    merged = True\n    \n    while merged:\n        merged = False\n        new_boxes = []\n        used = set()\n        \n        for i, box1 in enumerate(normalized):\n            if i in used:\n                continue\n            \n            current_box = box1\n            \n            for j, box2 in enumerate(normalized):\n                if j <= i or j in used:\n                    continue\n                \n                if should_merge_fn(current_box, box2):\n                    current_box = merge_two_boxes(current_box, box2)\n                    used.add(j)\n                    merged = True\n            \n            new_boxes.append(current_box)\n            used.add(i)\n        \n        normalized = new_boxes\n    \n    return normalized\n\n\ndef create_mask_from_bboxes(\n    image_size: Tuple[int, int],\n    bboxes: List[Union[Tuple[int, int, int, int], dict]],\n    mask_color: Tuple[int, int, int] = (255, 255, 255),\n    background_color: Tuple[int, int, int] = (0, 0, 0),\n    expand_pixels: int = 0\n) -> Image.Image:\n    \"\"\"\n    从边界框列表创建掩码图像\n    \n    Args:\n        image_size: 图像尺寸 (width, height)\n        bboxes: 边界框列表，每个元素可以是：\n                - 元组格式: (x1, y1, x2, y2) 其中 (x1,y1) 是左上角，(x2,y2) 是右下角\n                - 字典格式: {\"x\": x, \"y\": y, \"width\": w, \"height\": h}\n                - 字典格式: {\"x1\": x1, \"y1\": y1, \"x2\": x2, \"y2\": y2}\n        mask_color: 掩码区域的颜色（默认白色），表示需要消除的区域\n        background_color: 背景区域的颜色（默认黑色），表示保留的区域\n        expand_pixels: 扩展像素数，可以让掩码区域略微扩大（用于更好的消除效果）\n        \n    Returns:\n        PIL Image 对象，RGB 模式的掩码图像\n    \"\"\"\n    try:\n        # 创建黑色背景图像\n        mask = Image.new('RGB', image_size, background_color)\n        draw = ImageDraw.Draw(mask)\n        \n        logger.info(f\"创建掩码图像，尺寸: {image_size}, bbox数量: {len(bboxes)}\")\n        \n        # 绘制每个 bbox 为白色区域\n        bbox_list = []  # 用于记录所有bbox坐标\n        for i, bbox in enumerate(bboxes):\n            # 解析不同格式的 bbox\n            if isinstance(bbox, dict):\n                if 'x1' in bbox and 'y1' in bbox and 'x2' in bbox and 'y2' in bbox:\n                    # 格式: {\"x1\": x1, \"y1\": y1, \"x2\": x2, \"y2\": y2}\n                    x1 = bbox['x1']\n                    y1 = bbox['y1']\n                    x2 = bbox['x2']\n                    y2 = bbox['y2']\n                elif 'x' in bbox and 'y' in bbox and 'width' in bbox and 'height' in bbox:\n                    # 格式: {\"x\": x, \"y\": y, \"width\": w, \"height\": h}\n                    x1 = bbox['x']\n                    y1 = bbox['y']\n                    x2 = x1 + bbox['width']\n                    y2 = y1 + bbox['height']\n                else:\n                    logger.warning(f\"无法识别的 bbox 字典格式: {bbox}\")\n                    continue\n            elif isinstance(bbox, (tuple, list)) and len(bbox) == 4:\n                # 格式: (x1, y1, x2, y2)\n                x1, y1, x2, y2 = bbox\n            else:\n                logger.warning(f\"无法识别的 bbox 格式: {bbox}\")\n                continue\n            \n            # 记录原始坐标\n            x1_orig, y1_orig, x2_orig, y2_orig = x1, y1, x2, y2\n            \n            # 应用扩展或收缩\n            if expand_pixels > 0:\n                # 扩展\n                x1 = max(0, x1 - expand_pixels)\n                y1 = max(0, y1 - expand_pixels)\n                x2 = min(image_size[0], x2 + expand_pixels)\n                y2 = min(image_size[1], y2 + expand_pixels)\n            elif expand_pixels < 0:\n                # 收缩（向内收缩）\n                shrink = abs(expand_pixels)\n                x1 = x1 + shrink\n                y1 = y1 + shrink\n                x2 = x2 - shrink\n                y2 = y2 - shrink\n                # 确保收缩后仍然有效（宽度和高度必须大于0）\n                if x2 <= x1 or y2 <= y1:\n                    logger.warning(f\"bbox {i+1} 收缩后无效: ({x1}, {y1}, {x2}, {y2})，跳过\")\n                    continue\n            \n            # 确保坐标在图像范围内\n            x1 = max(0, min(x1, image_size[0]))\n            y1 = max(0, min(y1, image_size[1]))\n            x2 = max(0, min(x2, image_size[0]))\n            y2 = max(0, min(y2, image_size[1]))\n            \n            # 再次检查有效性\n            if x2 <= x1 or y2 <= y1:\n                logger.warning(f\"bbox {i+1} 最终坐标无效: ({x1}, {y1}, {x2}, {y2})，跳过\")\n                continue\n            \n            # 绘制矩形\n            draw.rectangle([x1, y1, x2, y2], fill=mask_color)\n            width = x2 - x1\n            height = y2 - y1\n            if expand_pixels > 0:\n                bbox_list.append(f\"  [{i+1}] 原始: ({x1_orig}, {y1_orig}, {x2_orig}, {y2_orig}) -> 扩展后: ({x1}, {y1}, {x2}, {y2}) 尺寸: {width}x{height}\")\n            elif expand_pixels < 0:\n                bbox_list.append(f\"  [{i+1}] 原始: ({x1_orig}, {y1_orig}, {x2_orig}, {y2_orig}) -> 收缩后: ({x1}, {y1}, {x2}, {y2}) 尺寸: {width}x{height}\")\n            else:\n                bbox_list.append(f\"  [{i+1}] ({x1}, {y1}, {x2}, {y2}) 尺寸: {width}x{height}\")\n            logger.debug(f\"bbox {i+1}: ({x1}, {y1}, {x2}, {y2}) 尺寸: {width}x{height}\")\n        \n        # 输出所有bbox的详细信息\n        if bbox_list:\n            logger.info(f\"添加了 {len(bbox_list)} 个bbox的mask:\")\n            for bbox_info in bbox_list:\n                logger.info(bbox_info)\n        \n        logger.info(f\"掩码图像创建完成\")\n        return mask\n        \n    except Exception as e:\n        logger.error(f\"创建掩码图像失败: {str(e)}\", exc_info=True)\n        raise\n\n\ndef create_inverse_mask_from_bboxes(\n    image_size: Tuple[int, int],\n    bboxes: List[Union[Tuple[int, int, int, int], dict]],\n    expand_pixels: int = 0\n) -> Image.Image:\n    \"\"\"\n    创建反向掩码（保留 bbox 区域，消除其他区域）\n    \n    Args:\n        image_size: 图像尺寸 (width, height)\n        bboxes: 边界框列表\n        expand_pixels: 扩展像素数\n        \n    Returns:\n        PIL Image 对象，反向掩码图像\n    \"\"\"\n    # 交换颜色即可\n    return create_mask_from_bboxes(\n        image_size,\n        bboxes,\n        mask_color=(0, 0, 0),  # bbox 区域为黑色（保留）\n        background_color=(255, 255, 255),  # 背景为白色（消除）\n        expand_pixels=expand_pixels\n    )\n\n\ndef create_mask_from_image_and_bboxes(\n    image: Image.Image,\n    bboxes: List[Union[Tuple[int, int, int, int], dict]],\n    expand_pixels: int = 0\n) -> Image.Image:\n    \"\"\"\n    从图像和边界框创建掩码（便捷函数）\n    \n    Args:\n        image: 原始图像\n        bboxes: 边界框列表\n        expand_pixels: 扩展像素数\n        \n    Returns:\n        掩码图像\n    \"\"\"\n    return create_mask_from_bboxes(\n        image.size,\n        bboxes,\n        expand_pixels=expand_pixels\n    )\n\n\ndef visualize_mask_overlay(\n    original_image: Image.Image,\n    mask_image: Image.Image,\n    alpha: float = 0.5\n) -> Image.Image:\n    \"\"\"\n    将掩码叠加到原始图像上以便可视化\n    \n    Args:\n        original_image: 原始图像\n        mask_image: 掩码图像\n        alpha: 掩码透明度 (0.0-1.0)\n        \n    Returns:\n        叠加后的图像\n    \"\"\"\n    try:\n        # 确保两个图像尺寸相同\n        if original_image.size != mask_image.size:\n            logger.warning(f\"图像尺寸不匹配，调整掩码尺寸: {mask_image.size} -> {original_image.size}\")\n            mask_image = mask_image.resize(original_image.size, Image.LANCZOS)\n        \n        # 转换为 RGBA\n        if original_image.mode != 'RGBA':\n            original_rgba = original_image.convert('RGBA')\n        else:\n            original_rgba = original_image.copy()\n        \n        # 创建黑色半透明掩码用于可视化\n        mask_rgba = Image.new('RGBA', original_image.size, (0, 0, 0, 0))\n        draw = ImageDraw.Draw(mask_rgba)\n        \n        # 遍历掩码图像，将白色区域绘制为黑色半透明\n        mask_array = mask_image.load()\n        mask_rgba_array = mask_rgba.load()\n        \n        for y in range(mask_image.size[1]):\n            for x in range(mask_image.size[0]):\n                pixel = mask_array[x, y]\n                # 如果是白色（或接近白色），设置为黑色半透明\n                if isinstance(pixel, tuple):\n                    brightness = sum(pixel) / len(pixel)\n                else:\n                    brightness = pixel\n                \n                if brightness > 200:  # 接近白色\n                    mask_rgba_array[x, y] = (0, 0, 0, int(128 * alpha))\n        \n        # 叠加\n        result = Image.alpha_composite(original_rgba, mask_rgba)\n        return result.convert('RGB')\n        \n    except Exception as e:\n        logger.error(f\"可视化掩码叠加失败: {str(e)}\", exc_info=True)\n        return original_image\n\n\ndef merge_vertical_nearby_bboxes(\n    bboxes: List[Tuple[int, int, int, int]],\n    vertical_gap_ratio: float = 0.8,\n    horizontal_overlap_ratio: float = 0.3\n) -> List[Tuple[int, int, int, int]]:\n    \"\"\"\n    合并上下间距很小的边界框（适用于文字行合并）\n    \n    合并策略（基于原始bbox判断，避免雪球效应）：\n    - 按y坐标排序后，先判断每对相邻原始bbox是否应该合并\n    - 如果垂直间距小于平均行高的 vertical_gap_ratio 倍\n    - 并且在水平方向上有至少 horizontal_overlap_ratio 的重叠\n    - 则标记为可合并，最后统一执行合并\n    \n    Args:\n        bboxes: 边界框列表 [(x1, y1, x2, y2), ...]\n        vertical_gap_ratio: 垂直间距阈值，相对于平均行高的比例，默认0.8\n        horizontal_overlap_ratio: 水平重叠比例阈值，默认0.3\n        \n    Returns:\n        合并后的边界框列表\n    \"\"\"\n    if not bboxes or len(bboxes) <= 1:\n        return list(bboxes) if bboxes else []\n    \n    normalized = normalize_bboxes(bboxes)\n    if not normalized:\n        return []\n    \n    # 按y坐标排序（从上到下）\n    normalized.sort(key=lambda b: b[1])\n    \n    # 计算原始bbox的平均行高\n    avg_height = sum(b[3] - b[1] for b in normalized) / len(normalized)\n    max_vertical_gap = avg_height * vertical_gap_ratio\n    \n    def get_horizontal_overlap(box1, box2):\n        \"\"\"计算两个bbox在水平方向的重叠比例（相对于较小的宽度）\"\"\"\n        overlap_start = max(box1[0], box2[0])\n        overlap_end = min(box1[2], box2[2])\n        overlap = max(0, overlap_end - overlap_start)\n        min_width = min(box1[2] - box1[0], box2[2] - box2[0])\n        return overlap / min_width if min_width > 0 else 0\n    \n    def should_merge_adjacent(box1, box2):\n        \"\"\"判断两个相邻（按y排序）的原始bbox是否应该合并\"\"\"\n        # 垂直间距 = box2的顶部 - box1的底部\n        v_gap = box2[1] - box1[3]\n        \n        # 如果垂直间距太大，不合并\n        if v_gap > max_vertical_gap:\n            return False\n        \n        # 检查水平重叠\n        h_overlap = get_horizontal_overlap(box1, box2)\n        if h_overlap >= horizontal_overlap_ratio:\n            return True\n        \n        # 没有重叠但水平距离很近也合并\n        if h_overlap <= 0:\n            h_gap = max(0, max(box2[0] - box1[2], box1[0] - box2[2]))\n            if h_gap < avg_height:\n                return True\n        \n        return False\n    \n    # 第一步：基于原始bbox判断哪些相邻对应该合并\n    merge_with_next = []\n    for i in range(len(normalized) - 1):\n        merge_with_next.append(should_merge_adjacent(normalized[i], normalized[i + 1]))\n    \n    # 第二步：根据标记执行合并\n    result = []\n    current_box = normalized[0]\n    \n    for i in range(len(merge_with_next)):\n        if merge_with_next[i]:\n            # 和下一个合并\n            current_box = merge_two_boxes(current_box, normalized[i + 1])\n        else:\n            # 不合并，保存当前，开始新组\n            result.append(current_box)\n            current_box = normalized[i + 1]\n    \n    # 添加最后一个\n    result.append(current_box)\n    \n    logger.info(f\"合并相邻文字行bbox：{len(bboxes)} -> {len(result)}\")\n    return result\n\n\ndef merge_overlapping_bboxes(\n    bboxes: List[Tuple[int, int, int, int]],\n    merge_threshold: int = 10\n) -> List[Tuple[int, int, int, int]]:\n    \"\"\"\n    合并重叠或相邻的边界框\n    \n    Args:\n        bboxes: 边界框列表 [(x1, y1, x2, y2), ...]\n        merge_threshold: 合并阈值（像素），边界框距离小于此值时会合并\n        \n    Returns:\n        合并后的边界框列表\n    \"\"\"\n    if not bboxes:\n        return []\n    \n    normalized = normalize_bboxes(bboxes)\n    if not normalized:\n        return []\n    \n    def should_merge(box1, box2):\n        x1, y1, x2, y2 = box1\n        bx1, by1, bx2, by2 = box2\n        return (x1 - merge_threshold <= bx2 and bx1 <= x2 + merge_threshold and\n                y1 - merge_threshold <= by2 and by1 <= y2 + merge_threshold)\n    \n    result = _iterative_merge(normalized, should_merge)\n    logger.info(f\"合并边界框：{len(bboxes)} -> {len(result)}\")\n    return result\n\n"
  },
  {
    "path": "backend/utils/page_utils.py",
    "content": "\"\"\"\nPage utilities - shared helpers for parsing page_ids and fetching pages\n\"\"\"\nfrom typing import List, Optional, Union\nfrom flask import Request\n\n\ndef parse_page_ids_from_query(request: Request) -> List[str]:\n    \"\"\"\n    Parse page_ids from query parameters (comma-separated string).\n    \n    Args:\n        request: Flask request object\n        \n    Returns:\n        List of page ID strings (empty list if none provided)\n    \"\"\"\n    page_ids_param = request.args.get('page_ids', '')\n    if not page_ids_param:\n        return []\n    return [pid.strip() for pid in page_ids_param.split(',') if pid.strip()]\n\n\ndef parse_page_ids_from_body(data: dict) -> List[str]:\n    \"\"\"\n    Parse page_ids from request body (array of IDs).\n    \n    Args:\n        data: Request JSON data dict\n        \n    Returns:\n        List of page ID strings (empty list if invalid or none provided)\n    \"\"\"\n    page_ids = data.get('page_ids', [])\n    if not isinstance(page_ids, list):\n        return []\n    return page_ids\n\n\ndef get_filtered_pages(project_id: str, page_ids: Optional[List[str]] = None):\n    \"\"\"\n    Fetch pages for a project, optionally filtered by page IDs.\n    \n    Args:\n        project_id: Project ID\n        page_ids: Optional list of page IDs to filter by\n        \n    Returns:\n        List of Page objects ordered by order_index\n    \"\"\"\n    from models import Page\n    \n    if page_ids:\n        return Page.query.filter(\n            Page.project_id == project_id,\n            Page.id.in_(page_ids)\n        ).order_by(Page.order_index).all()\n    else:\n        return Page.query.filter_by(project_id=project_id).order_by(Page.order_index).all()\n\n"
  },
  {
    "path": "backend/utils/path_utils.py",
    "content": "\"\"\"\nPath utilities for handling MinerU file paths and prefix matching\n\"\"\"\nimport os\nimport logging\nfrom pathlib import Path\nfrom typing import Optional\n\nlogger = logging.getLogger(__name__)\n\n\ndef convert_mineru_path_to_local(mineru_path: str, project_root: Optional[Path] = None) -> Optional[Path]:\n    \"\"\"\n    将 /files/mineru/{extract_id}/{rel_path} 格式的路径转换为本地文件系统路径\n    \n    Args:\n        mineru_path: MinerU URL 路径，格式为 /files/mineru/{extract_id}/{rel_path}\n        project_root: 项目根目录路径（如果为 None，则自动计算）\n        \n    Returns:\n        本地文件系统路径（Path 对象），如果转换失败则返回 None\n    \"\"\"\n    try:\n        if not mineru_path.startswith('/files/mineru/'):\n            return None\n        \n        # Remove '/files/mineru/' prefix\n        rel_path = mineru_path.replace('/files/mineru/', '')\n        \n        # Get project root if not provided\n        if project_root is None:\n            # Navigate to project root (assuming this file is in backend/utils/)\n            current_file = Path(__file__).resolve()\n            backend_dir = current_file.parent.parent\n            project_root = backend_dir.parent\n        \n        # Construct full path: {project_root}/uploads/mineru_files/{rel_path}\n        local_path = project_root / 'uploads' / 'mineru_files' / rel_path\n        \n        return local_path\n    except Exception as e:\n        logger.warning(f\"Failed to convert MinerU path to local: {mineru_path}, error: {str(e)}\")\n        return None\n\n\ndef find_mineru_file_with_prefix(mineru_path: str, project_root: Optional[Path] = None) -> Optional[Path]:\n    \"\"\"\n    查找 MinerU 文件，支持前缀匹配\n    \n    首先尝试直接路径匹配，如果失败则尝试前缀匹配。\n    前缀匹配逻辑：如果文件名看起来像是一个前缀+扩展名（前缀长度 >= 5），\n    则在目录中查找以该前缀开头的文件。\n    \n    Args:\n        mineru_path: MinerU URL 路径，格式为 /files/mineru/{extract_id}/{rel_path}\n        project_root: 项目根目录路径（如果为 None，则自动计算）\n        \n    Returns:\n        找到的文件路径（Path 对象），如果未找到则返回 None\n    \"\"\"\n    # First try direct path conversion\n    local_path = convert_mineru_path_to_local(mineru_path, project_root)\n    \n    if local_path is None:\n        return None\n    \n    # Direct file matching\n    if local_path.exists() and local_path.is_file():\n        return local_path\n    \n    # Try prefix match using the generic function\n    return find_file_with_prefix(local_path)\n\n\ndef find_file_with_prefix(file_path: Path) -> Optional[Path]:\n    \"\"\"\n    查找文件，支持前缀匹配\n    \n    首先检查文件是否存在，如果不存在则尝试前缀匹配。\n    前缀匹配逻辑：如果文件名看起来像是一个前缀+扩展名（前缀长度 >= 5），\n    则在目录中查找以该前缀开头的文件。\n    \n    Args:\n        file_path: 要查找的文件路径（Path 对象）\n        \n    Returns:\n        找到的文件路径（Path 对象），如果未找到则返回 None\n    \"\"\"\n    # Direct file matching\n    if file_path.exists() and file_path.is_file():\n        return file_path\n    \n    # Try prefix match if not found and filename looks like a prefix with extension\n    filename = file_path.name\n    dirpath = file_path.parent\n    \n    if '.' in filename and dirpath.exists() and dirpath.is_dir():\n        prefix, ext = os.path.splitext(filename)\n        if len(prefix) >= 5:\n            try:\n                for fname in os.listdir(dirpath):\n                    fp, fe = os.path.splitext(fname)\n                    if fp.lower().startswith(prefix.lower()) and fe.lower() == ext.lower():\n                        matched_path = dirpath / fname\n                        if matched_path.is_file():\n                            logger.debug(f\"Prefix match found: {file_path} -> {matched_path}\")\n                            return matched_path\n            except OSError as e:\n                logger.warning(f\"Failed to list directory {dirpath}: {str(e)}\")\n    \n    return None\n\n"
  },
  {
    "path": "backend/utils/pptx_builder.py",
    "content": "\"\"\"\nPPTX Builder - utilities for creating editable PPTX files\nBased on OpenDCAI/DataFlow-Agent's implementation\n\"\"\"\nimport os\nimport logging\nfrom datetime import datetime, timezone\nfrom typing import List, Dict, Any, Optional, Tuple\nfrom pathlib import Path\nfrom pptx import Presentation\nfrom pptx.util import Inches, Pt\nfrom pptx.enum.text import PP_ALIGN\nfrom pptx.dml.color import RGBColor\nfrom PIL import Image, ImageFont, ImageDraw\nfrom html.parser import HTMLParser\n\nlogger = logging.getLogger(__name__)\n\n\nclass HTMLTableParser(HTMLParser):\n    \"\"\"Parse HTML table into row/column data\"\"\"\n    \n    def __init__(self):\n        super().__init__()\n        self.table_data = []\n        self.current_row = []\n        self.current_cell = []\n        self.in_table = False\n        self.in_row = False\n        self.in_cell = False\n    \n    def handle_starttag(self, tag, attrs):\n        if tag == 'table':\n            self.in_table = True\n            self.table_data = []\n        elif tag == 'tr':\n            self.in_row = True\n            self.current_row = []\n        elif tag in ['td', 'th']:\n            self.in_cell = True\n            self.current_cell = []\n    \n    def handle_endtag(self, tag):\n        if tag == 'table':\n            self.in_table = False\n        elif tag == 'tr':\n            self.in_row = False\n            if self.current_row:\n                self.table_data.append(self.current_row)\n        elif tag in ['td', 'th']:\n            self.in_cell = False\n            cell_text = ''.join(self.current_cell).strip()\n            self.current_row.append(cell_text)\n    \n    def handle_data(self, data):\n        if self.in_cell:\n            self.current_cell.append(data)\n    \n    @staticmethod\n    def parse_html_table(html: str) -> List[List[str]]:\n        \"\"\"Parse HTML table string into 2D array of cells\"\"\"\n        parser = HTMLTableParser()\n        parser.feed(html)\n        return parser.table_data\n\n\nclass PPTXBuilder:\n    \"\"\"Builder class for creating editable PPTX files from structured content\"\"\"\n    \n    # Standard slide dimensions (16:9 aspect ratio)\n    DEFAULT_SLIDE_WIDTH_INCHES = 10\n    DEFAULT_SLIDE_HEIGHT_INCHES = 5.625\n    \n    # Default DPI for pixel to inch conversion\n    DEFAULT_DPI = 96\n    \n    # python-pptx size limits (1-56 inches, 914400-51206400 EMU)\n    # See: https://github.com/scanny/python-pptx/issues/93\n    MAX_SLIDE_WIDTH_INCHES = 56.0\n    MAX_SLIDE_HEIGHT_INCHES = 56.0\n    MIN_SLIDE_WIDTH_INCHES = 1.0\n    MIN_SLIDE_HEIGHT_INCHES = 1.0\n    \n    # Global font size limits (to prevent extreme cases)\n    MIN_FONT_SIZE = 6   # Minimum readable size\n    MAX_FONT_SIZE = 200  # Maximum reasonable size\n    \n    # 项目内置字体（Noto Sans CJK SC，支持中日韩文字）\n    FONT_PATH = os.path.join(os.path.dirname(__file__), \"..\", \"fonts\", \"NotoSansSC-Regular.ttf\")\n    \n    # Font cache: {size_pt: ImageFont}\n    _font_cache: Dict[float, ImageFont.FreeTypeFont] = {}\n    \n    @classmethod\n    def _get_font(cls, size_pt: float) -> Optional[ImageFont.FreeTypeFont]:\n        \"\"\"Get font object for given size (with caching)\"\"\"\n        # Round to 0.5pt for cache efficiency\n        cache_key = round(size_pt * 2) / 2\n        \n        if cache_key not in cls._font_cache:\n            try:\n                cls._font_cache[cache_key] = ImageFont.truetype(cls.FONT_PATH, int(size_pt))\n            except Exception as e:\n                logger.warning(f\"Failed to load font {cls.FONT_PATH}: {e}\")\n                return None\n        \n        return cls._font_cache[cache_key]\n    \n    @classmethod\n    def _measure_text_width(cls, text: str, font_size_pt: float) -> Optional[float]:\n        \"\"\"\n        Measure text width in points using the actual font\n        \n        Args:\n            text: Text to measure\n            font_size_pt: Font size in points\n            \n        Returns:\n            Text width in points, or None if measurement failed\n        \"\"\"\n        font = cls._get_font(font_size_pt)\n        if font is None:\n            return None\n        \n        try:\n            # Get text bounding box: (left, top, right, bottom)\n            bbox = font.getbbox(text)\n            width_px = bbox[2] - bbox[0]\n            # Font is loaded at size=font_size_pt, so pixel width ≈ point width\n            return width_px\n        except Exception as e:\n            logger.warning(f\"Failed to measure text: {e}\")\n            return None\n    \n    def __init__(self, slide_width_inches: float = None, slide_height_inches: float = None):\n        \"\"\"\n        Initialize PPTX builder\n        \n        Args:\n            slide_width_inches: Slide width in inches (default: 10)\n            slide_height_inches: Slide height in inches (default: 5.625)\n        \"\"\"\n        self.slide_width_inches = slide_width_inches or self.DEFAULT_SLIDE_WIDTH_INCHES\n        self.slide_height_inches = slide_height_inches or self.DEFAULT_SLIDE_HEIGHT_INCHES\n        self.prs = None\n        self.current_slide = None\n        \n    def create_presentation(self) -> Presentation:\n        \"\"\"Create a new presentation with configured dimensions\"\"\"\n        self.prs = Presentation()\n        self.prs.slide_width = Inches(self.slide_width_inches)\n        self.prs.slide_height = Inches(self.slide_height_inches)\n        self._set_core_properties(self.prs)\n        return self.prs\n\n    @staticmethod\n    def _set_core_properties(prs: Presentation) -> None:\n        \"\"\"Set author/date metadata for exported PPTX.\"\"\"\n        try:\n            core = prs.core_properties\n            now = datetime.now(timezone.utc)\n            core.author = \"banana-slides\"\n            core.last_modified_by = \"banana-slides\"\n            core.created = now\n            core.modified = now\n            core.last_printed = None\n        except Exception as e:\n            logger.warning(f\"Failed to set core properties: {e}\")\n    \n    def setup_presentation_size(self, width_pixels: int, height_pixels: int, dpi: int = None):\n        \"\"\"\n        Setup presentation size based on pixel dimensions\n        Automatically clamps to python-pptx limits (1-56 inches) while preserving aspect ratio\n        \n        Args:\n            width_pixels: Width in pixels\n            height_pixels: Height in pixels\n            dpi: DPI for conversion (default: 96)\n        \"\"\"\n        dpi = dpi or self.DEFAULT_DPI\n        \n        # Convert pixels to inches\n        width_inches = width_pixels / dpi\n        height_inches = height_pixels / dpi\n        \n        # Check if dimensions exceed python-pptx limits and scale down if needed\n        # python-pptx enforces: 1 <= dimension <= 56 inches\n        scale_factor = 1.0\n        \n        if width_inches > self.MAX_SLIDE_WIDTH_INCHES:\n            scale_factor = self.MAX_SLIDE_WIDTH_INCHES / width_inches\n            logger.warning(\n                f\"Slide width {width_inches:.2f}\\\" exceeds python-pptx limit ({self.MAX_SLIDE_WIDTH_INCHES}\\\"), \"\n                f\"scaling down by {scale_factor:.3f}x to maintain aspect ratio\"\n            )\n        \n        if height_inches > self.MAX_SLIDE_HEIGHT_INCHES:\n            height_scale = self.MAX_SLIDE_HEIGHT_INCHES / height_inches\n            if height_scale < scale_factor:\n                scale_factor = height_scale\n                logger.warning(\n                    f\"Slide height {height_inches:.2f}\\\" exceeds python-pptx limit ({self.MAX_SLIDE_HEIGHT_INCHES}\\\"), \"\n                    f\"scaling down by {scale_factor:.3f}x to maintain aspect ratio\"\n                )\n        \n        # Apply scale factor if needed\n        if scale_factor < 1.0:\n            width_inches *= scale_factor\n            height_inches *= scale_factor\n            logger.info(\n                f\"Final slide dimensions after scaling: {width_inches:.2f}\\\" x {height_inches:.2f}\\\" \"\n                f\"(from {width_pixels}x{height_pixels}px @ {dpi} DPI)\"\n            )\n        \n        # Ensure minimum size constraints\n        width_inches = max(self.MIN_SLIDE_WIDTH_INCHES, width_inches)\n        height_inches = max(self.MIN_SLIDE_HEIGHT_INCHES, height_inches)\n        \n        self.slide_width_inches = width_inches\n        self.slide_height_inches = height_inches\n        \n        if self.prs:\n            self.prs.slide_width = Inches(self.slide_width_inches)\n            self.prs.slide_height = Inches(self.slide_height_inches)\n    \n    def add_blank_slide(self):\n        \"\"\"Add a blank slide to the presentation\"\"\"\n        if not self.prs:\n            self.create_presentation()\n        \n        # Use blank layout (layout 6 is typically blank)\n        blank_layout = self.prs.slide_layouts[6]\n        self.current_slide = self.prs.slides.add_slide(blank_layout)\n        return self.current_slide\n    \n    def pixels_to_inches(self, pixels: float, dpi: int = None) -> float:\n        \"\"\"\n        Convert pixels to inches\n        \n        Args:\n            pixels: Pixel value\n            dpi: DPI for conversion (default: 96)\n            \n        Returns:\n            Value in inches\n        \"\"\"\n        dpi = dpi or self.DEFAULT_DPI\n        return pixels / dpi\n    \n    def calculate_font_size(self, bbox: List[int], text: str, text_level: Any = None, dpi: int = None) -> float:\n        \"\"\"\n        Calculate appropriate font size based on bounding box and text content.\n        Uses precise font measurement when available, falls back to estimation otherwise.\n        Supports both single-line and multi-line (auto-wrap) text.\n        \n        Args:\n            bbox: Bounding box [x0, y0, x1, y1] in pixels\n            text: Text content\n            text_level: Text level (kept for compatibility, not used in calculation)\n            dpi: DPI for pixel to inch conversion\n            \n        Returns:\n            Font size in points (float for precision)\n        \"\"\"\n        dpi = dpi or self.DEFAULT_DPI\n        \n        # Get bbox dimensions in pixels\n        width_px = bbox[2] - bbox[0]\n        height_px = bbox[3] - bbox[1]\n        \n        # Convert to points (1 inch = 72 points)\n        width_pt = (width_px / dpi) * 72\n        height_pt = (height_px / dpi) * 72\n        \n        # MinerU bbox is tight, use it directly\n        # Textbox margins are set to 0 in add_text_element()\n        usable_width_pt = width_pt\n        usable_height_pt = height_pt\n        \n        if usable_width_pt <= 0 or usable_height_pt <= 0:\n            logger.warning(f\"Bbox too small for text: {width_px}x{height_px}px, text: '{text[:30]}...'\")\n            return self.MIN_FONT_SIZE\n        \n        text_length = len(text)\n        \n        # Line height ratio: 1.0 for tight bbox\n        line_height_ratio = 1.0\n        \n        # Try precise measurement first (check if font file exists)\n        use_precise = os.path.exists(self.FONT_PATH)\n        \n        # Binary search: find largest font size that fits\n        best_size = self.MIN_FONT_SIZE\n        \n        for font_size in range(int(self.MAX_FONT_SIZE), int(self.MIN_FONT_SIZE) - 1, -1):\n            font_size = float(font_size)\n            \n            # For text with explicit newlines, calculate each line's width separately\n            lines = text.split('\\n')\n            total_required_lines = 0\n            \n            for line in lines:\n                if not line:\n                    total_required_lines += 1\n                    continue\n                    \n                # Measure line width (precise or estimated)\n                if use_precise:\n                    line_width_pt = self._measure_text_width(line, font_size)\n                    if line_width_pt is None:\n                        use_precise = False\n                \n                if not use_precise:\n                    # Fallback: estimate based on character count\n                    cjk_count = sum(1 for c in line if '\\u4e00' <= c <= '\\u9fff' or '\\u3040' <= c <= '\\u30ff' or '\\uac00' <= c <= '\\ud7af')\n                    non_cjk_count = len(line) - cjk_count\n                    line_width_pt = (cjk_count * 1.0 + non_cjk_count * 0.5) * font_size\n                \n                # How many lines does this explicit line need (auto-wrap)?\n                lines_needed = max(1, -(-int(line_width_pt) // int(usable_width_pt)))\n                total_required_lines += lines_needed\n            \n            required_lines = total_required_lines\n            \n            # Calculate total height needed\n            line_height_pt = font_size * line_height_ratio\n            total_height_pt = required_lines * line_height_pt\n            \n            # Check if it fits\n            if total_height_pt <= usable_height_pt:\n                best_size = font_size\n                break\n        \n        if best_size == self.MIN_FONT_SIZE and text_length > 3:\n            logger.warning(f\"Text may overflow: '{text[:50]}...' in bbox {width_px}x{height_px}px\")\n        \n        # Debug log for font size calculation\n        logger.debug(\n            f\"Font size calc: '{text[:20]}{'...' if len(text) > 20 else ''}' \"\n            f\"bbox={width_px}x{height_px}px -> {best_size}pt \"\n            f\"(usable: {usable_width_pt:.1f}x{usable_height_pt:.1f}pt)\"\n        )\n        \n        return best_size\n    \n    def add_text_element(\n        self,\n        slide,\n        text: str,\n        bbox: List[int],\n        text_level: Any = None,\n        dpi: int = None,\n        align: str = 'left',\n        text_style: Any = None\n    ):\n        \"\"\"\n        Add text element to slide\n        \n        Args:\n            slide: Target slide\n            text: Text content (used as fallback if text_style has no colored_segments)\n            bbox: Bounding box [x0, y0, x1, y1] in pixels\n            text_level: Text level (1=title, 2=heading, etc.) or type string\n            dpi: DPI for conversion (default: 96)\n            align: Text alignment ('left', 'center', 'right')\n            text_style: TextStyleResult object with font color, bold, italic etc. (optional)\n                        If text_style has colored_segments, those will be used for rendering\n                        and the text content will come from the segments.\n        \"\"\"\n        dpi = dpi or self.DEFAULT_DPI\n        \n        # Check if we have colored segments (multi-color text)\n        has_colored_segments = (\n            text_style and \n            hasattr(text_style, 'colored_segments') and \n            text_style.colored_segments and \n            len(text_style.colored_segments) > 0\n        )\n        \n        # Determine the actual text to use\n        # If we have colored_segments, use the text from segments (model's recognized text)\n        if has_colored_segments:\n            actual_text = ''.join(seg.text for seg in text_style.colored_segments)\n        else:\n            actual_text = text\n        \n        # Expand bbox slightly to prevent text overflow\n        # MinerU bbox is tight, but font rendering may need extra space\n        EXPAND_RATIO = 0.01  # 1% expansion\n        bbox_width = bbox[2] - bbox[0]\n        bbox_height = bbox[3] - bbox[1]\n        expand_w = bbox_width * EXPAND_RATIO\n        expand_h = bbox_height * EXPAND_RATIO\n        \n        # Convert expanded bbox to inches (expand evenly on all sides)\n        left = Inches(self.pixels_to_inches(bbox[0] - expand_w / 2, dpi))\n        top = Inches(self.pixels_to_inches(bbox[1] - expand_h / 2, dpi))\n        width = Inches(self.pixels_to_inches(bbox_width + expand_w, dpi))\n        height = Inches(self.pixels_to_inches(bbox_height + expand_h, dpi))\n        \n        # Add text box\n        textbox = slide.shapes.add_textbox(left, top, width, height)\n        text_frame = textbox.text_frame\n        text_frame.word_wrap = True\n        \n        # Remove margins completely - bbox is tight, no extra space needed\n        text_frame.margin_left = Inches(0)\n        text_frame.margin_right = Inches(0)\n        text_frame.margin_top = Inches(0)\n        text_frame.margin_bottom = Inches(0)\n        \n        def replace_some_chars(text: str) -> str:\n            # replace logic\n            # replace · to • if starts with ·\n            text = text.replace('·', '•', 1) if text.lstrip().startswith('·') else text\n            return text\n        actual_text = replace_some_chars(actual_text)\n        \n        # Calculate font size\n        font_size = self.calculate_font_size(bbox, actual_text, text_level, dpi)\n        \n        # Determine effective alignment - text_style优先，否则使用参数\n        effective_align = align\n        if text_style and hasattr(text_style, 'text_alignment') and text_style.text_alignment:\n            effective_align = text_style.text_alignment\n        \n        # Get style attributes\n        is_bold = False\n        is_italic = False\n        is_underline = False\n        if text_style:\n            is_bold = getattr(text_style, 'is_bold', False)\n            is_italic = getattr(text_style, 'is_italic', False)\n            is_underline = getattr(text_style, 'is_underline', False)\n        \n        # Make title text bold (legacy behavior)\n        if text_level == 1 or text_level == 'title':\n            is_bold = True\n        \n        # Render text with colors\n        if has_colored_segments:\n            # Multi-color text: use runs for each segment\n            paragraph = text_frame.paragraphs[0]\n            paragraph.clear()\n            \n            latex_count = 0\n            for seg in text_style.colored_segments:\n                run = paragraph.add_run()\n                run.text = replace_some_chars(seg.text)\n                run.font.size = Pt(font_size)\n                run.font.bold = is_bold\n                run.font.underline = is_underline\n                # Set segment-specific color\n                r, g, b = seg.color_rgb\n                run.font.color.rgb = RGBColor(r, g, b)\n                \n                # Handle LaTeX formula segments\n                if hasattr(seg, 'is_latex') and seg.is_latex:\n                    # For LaTeX formulas, use italic style as visual hint\n                    # TODO: In future, could render as actual equation using OMML\n                    run.font.italic = True\n                    latex_count += 1\n                    logger.debug(f\"  LaTeX formula detected: '{seg.text}'\")\n                else:\n                    run.font.italic = is_italic\n            \n            latex_info = f\", {latex_count} latex\" if latex_count > 0 else \"\"\n            style_info = f\" | multi-color: {len(text_style.colored_segments)} segments{latex_info}\"\n        else:\n            # Single color text: use simple text assignment\n            text_frame.text = actual_text\n            # IMPORTANT: Re-get paragraph after setting text_frame.text\n            # because setting text_frame.text creates a new paragraph object\n            paragraph = text_frame.paragraphs[0]\n            paragraph.font.size = Pt(font_size)\n            paragraph.font.bold = is_bold\n            paragraph.font.italic = is_italic\n            paragraph.font.underline = is_underline\n            \n            # Apply single font color if provided\n            if text_style and hasattr(text_style, 'font_color_rgb') and text_style.font_color_rgb:\n                r, g, b = text_style.font_color_rgb\n                paragraph.font.color.rgb = RGBColor(r, g, b)\n            \n            style_info = f\" | color={text_style.font_color_rgb if text_style else 'default'}\"\n        \n        # Apply alignment after paragraph is finalized\n        if effective_align == 'center':\n            paragraph.alignment = PP_ALIGN.CENTER\n        elif effective_align == 'right':\n            paragraph.alignment = PP_ALIGN.RIGHT\n        elif effective_align == 'justify':\n            paragraph.alignment = PP_ALIGN.JUSTIFY\n        else:\n            paragraph.alignment = PP_ALIGN.LEFT\n        \n        # Calculate bbox dimensions for logging\n        bbox_width = bbox[2] - bbox[0]\n        bbox_height = bbox[3] - bbox[1]\n        logger.debug(f\"Text: '{actual_text[:35]}' | box: {bbox_width}x{bbox_height}px | font: {font_size:.1f}pt | chars: {len(actual_text)}{style_info}\")\n    \n    def add_image_element(\n        self,\n        slide,\n        image_path: str,\n        bbox: List[int],\n        dpi: int = None\n    ):\n        \"\"\"\n        Add image element to slide\n        \n        Args:\n            slide: Target slide\n            image_path: Path to image file\n            bbox: Bounding box [x0, y0, x1, y1] in pixels\n            dpi: DPI for conversion (default: 96)\n        \"\"\"\n        dpi = dpi or self.DEFAULT_DPI\n        \n        # Check if image exists\n        if not os.path.exists(image_path):\n            logger.warning(f\"Image not found: {image_path}, adding placeholder\")\n            self.add_image_placeholder(slide, bbox, dpi)\n            return\n        \n        # Convert bbox to inches\n        left = Inches(self.pixels_to_inches(bbox[0], dpi))\n        top = Inches(self.pixels_to_inches(bbox[1], dpi))\n        width = Inches(self.pixels_to_inches(bbox[2] - bbox[0], dpi))\n        height = Inches(self.pixels_to_inches(bbox[3] - bbox[1], dpi))\n        \n        try:\n            # Add image\n            slide.shapes.add_picture(image_path, left, top, width, height)\n            logger.debug(f\"Added image: {image_path} at bbox {bbox}\")\n        except Exception as e:\n            logger.error(f\"Failed to add image {image_path}: {str(e)}\")\n            self.add_image_placeholder(slide, bbox, dpi)\n    \n    def add_image_placeholder(\n        self,\n        slide,\n        bbox: List[int],\n        dpi: int = None\n    ):\n        \"\"\"\n        Add a placeholder for missing images\n        \n        Args:\n            slide: Target slide\n            bbox: Bounding box [x0, y0, x1, y1] in pixels\n            dpi: DPI for conversion (default: 96)\n        \"\"\"\n        dpi = dpi or self.DEFAULT_DPI\n        \n        # Convert bbox to inches\n        left = Inches(self.pixels_to_inches(bbox[0], dpi))\n        top = Inches(self.pixels_to_inches(bbox[1], dpi))\n        width = Inches(self.pixels_to_inches(bbox[2] - bbox[0], dpi))\n        height = Inches(self.pixels_to_inches(bbox[3] - bbox[1], dpi))\n        \n        # Add a text box as placeholder\n        textbox = slide.shapes.add_textbox(left, top, width, height)\n        text_frame = textbox.text_frame\n        text_frame.text = \"[Image]\"\n        paragraph = text_frame.paragraphs[0]\n        paragraph.alignment = PP_ALIGN.CENTER\n        paragraph.font.size = Pt(12)\n        paragraph.font.italic = True\n    \n    def add_table_element(\n        self,\n        slide,\n        html_table: str,\n        bbox: List[int],\n        dpi: int = None\n    ):\n        \"\"\"\n        Add editable table to slide from HTML table string\n        \n        Args:\n            slide: Target slide\n            html_table: HTML table string\n            bbox: Bounding box [x0, y0, x1, y1] in pixels\n            dpi: DPI for conversion (default: 96)\n        \"\"\"\n        dpi = dpi or self.DEFAULT_DPI\n        \n        # Parse HTML table\n        try:\n            table_data = HTMLTableParser.parse_html_table(html_table)\n        except Exception as e:\n            logger.error(f\"Failed to parse HTML table: {str(e)}\")\n            return\n        \n        if not table_data or not table_data[0]:\n            logger.warning(\"Empty table data\")\n            return\n        \n        rows = len(table_data)\n        cols = len(table_data[0])\n        \n        # Convert bbox to inches\n        left = Inches(self.pixels_to_inches(bbox[0], dpi))\n        top = Inches(self.pixels_to_inches(bbox[1], dpi))\n        width = Inches(self.pixels_to_inches(bbox[2] - bbox[0], dpi))\n        height = Inches(self.pixels_to_inches(bbox[3] - bbox[1], dpi))\n        \n        try:\n            # Add table shape\n            table_shape = slide.shapes.add_table(rows, cols, left, top, width, height)\n            table = table_shape.table\n            \n            # Calculate cell dimensions\n            cell_width = width / cols\n            cell_height = height / rows\n            \n            # Fill table with data\n            for row_idx, row_data in enumerate(table_data):\n                for col_idx, cell_text in enumerate(row_data):\n                    if col_idx < cols:  # Safety check\n                        cell = table.cell(row_idx, col_idx)\n                        cell.text = cell_text\n                        \n                        # Style the cell\n                        text_frame = cell.text_frame\n                        text_frame.word_wrap = True\n                        \n                        # Calculate font size for table cell\n                        # Use a conservative size to fit in cell\n                        cell_height_px = (bbox[3] - bbox[1]) / rows\n                        cell_width_px = (bbox[2] - bbox[0]) / cols\n                        \n                        # Estimate font size (smaller for tables)\n                        font_size = min(18, max(8, cell_height_px * 0.3))\n                        \n                        for paragraph in text_frame.paragraphs:\n                            paragraph.font.size = Pt(font_size)\n                            paragraph.alignment = PP_ALIGN.CENTER\n                            \n                            # Header row (first row) should be bold\n                            if row_idx == 0:\n                                paragraph.font.bold = True\n            \n            logger.info(f\"Added editable table: {rows}x{cols} at bbox {bbox}\")\n            \n        except Exception as e:\n            logger.error(f\"Failed to create table: {str(e)}\")\n    \n    def save(self, output_path: str):\n        \"\"\"\n        Save presentation to file\n        \n        Args:\n            output_path: Output file path\n        \"\"\"\n        if not self.prs:\n            raise ValueError(\"No presentation to save\")\n        \n        # Ensure directory exists\n        output_path_obj = Path(output_path)\n        output_dir = output_path_obj.parent\n        if str(output_dir) != '.':  # Only create directory if it's not current directory\n            output_dir.mkdir(parents=True, exist_ok=True)\n        \n        self.prs.save(output_path)\n        logger.info(f\"Saved presentation to: {output_path}\")\n    \n    def get_presentation(self) -> Presentation:\n        \"\"\"Get the current presentation object\"\"\"\n        return self.prs\n\n"
  },
  {
    "path": "backend/utils/response.py",
    "content": "\"\"\"\nUnified response format utilities\n\"\"\"\nfrom flask import jsonify\nfrom typing import Any, Dict, Optional\n\n\ndef success_response(data: Any = None, message: str = \"Success\", status_code: int = 200):\n    \"\"\"\n    Generate a successful response\n    \n    Args:\n        data: Response data\n        message: Success message\n        status_code: HTTP status code\n    \n    Returns:\n        Flask response with JSON format\n    \"\"\"\n    response = {\n        \"success\": True,\n        \"message\": message\n    }\n    \n    if data is not None:\n        response[\"data\"] = data\n    \n    return jsonify(response), status_code\n\n\ndef error_response(error_code: str, message: str, status_code: int = 400):\n    \"\"\"\n    Generate an error response\n    \n    Args:\n        error_code: Error code identifier\n        message: Error message\n        status_code: HTTP status code\n    \n    Returns:\n        Flask response with JSON format\n    \"\"\"\n    return jsonify({\n        \"success\": False,\n        \"error\": {\n            \"code\": error_code,\n            \"message\": message\n        }\n    }), status_code\n\n\n# Common error responses\ndef bad_request(message: str = \"Invalid request\"):\n    return error_response(\"INVALID_REQUEST\", message, 400)\n\n\ndef not_found(resource: str = \"Resource\"):\n    return error_response(f\"{resource.upper()}_NOT_FOUND\", f\"{resource} not found\", 404)\n\n\ndef invalid_status(message: str = \"Invalid status for this operation\"):\n    return error_response(\"INVALID_PROJECT_STATUS\", message, 400)\n\n\ndef ai_service_error(message: str = \"AI service error\"):\n    return error_response(\"AI_SERVICE_ERROR\", message, 503)\n\n\ndef rate_limit_error(message: str = \"Rate limit exceeded\"):\n    return error_response(\"RATE_LIMIT_EXCEEDED\", message, 429)\n\n"
  },
  {
    "path": "backend/utils/validators.py",
    "content": "\"\"\"\nData validation utilities\n\"\"\"\nimport re\nfrom math import gcd\nfrom typing import Set\n\n# --- Aspect ratio validation ---\n\n_ASPECT_RATIO_PATTERN = re.compile(r\"^\\d+:\\d+$\")\n_ASPECT_RATIO_MIN = 0.2\n_ASPECT_RATIO_MAX = 5.0\n\n\ndef normalize_aspect_ratio(raw_value) -> str:\n    \"\"\"\n    Normalize and validate aspect ratio input.\n\n    - Accepts \"W:H\" where W/H are positive integers.\n    - Reduces by gcd (e.g., \"1920:1080\" -> \"16:9\").\n    - Rejects obviously invalid or extreme ratios.\n    - Returns the normalized string like \"16:9\".\n    \"\"\"\n    if raw_value is None:\n        raise ValueError(\"Image aspect ratio is required\")\n\n    value = str(raw_value).strip()\n    if value == \"\":\n        raise ValueError(\"Image aspect ratio is required\")\n\n    if not _ASPECT_RATIO_PATTERN.fullmatch(value):\n        raise ValueError(\n            \"Image aspect ratio must match \\\\d+:\\\\d+ (e.g., 16:9, 4:3, 1:1)\"\n        )\n\n    width, height = (int(part) for part in value.split(\":\", 1))\n    if width <= 0 or height <= 0:\n        raise ValueError(\"Image aspect ratio must be positive integers (e.g., 16:9)\")\n\n    divisor = gcd(width, height)\n    width //= divisor\n    height //= divisor\n\n    ratio_value = width / height\n    if ratio_value < _ASPECT_RATIO_MIN or ratio_value > _ASPECT_RATIO_MAX:\n        raise ValueError(\n            f\"Image aspect ratio must be between {_ASPECT_RATIO_MIN:.1f} and {_ASPECT_RATIO_MAX:.1f} (e.g., 16:9)\"\n        )\n\n    normalized = f\"{width}:{height}\"\n    if len(normalized) > 10:\n        raise ValueError(\"Image aspect ratio is too long\")\n\n    return normalized\n\n# Project status states\nPROJECT_STATUSES = {\n    'DRAFT', \n    'OUTLINE_GENERATED', \n    'DESCRIPTIONS_GENERATED', \n    'GENERATING_IMAGES', \n    'COMPLETED'\n}\n\n# Page status states\nPAGE_STATUSES = {\n    'DRAFT', \n    'DESCRIPTION_GENERATED', \n    'GENERATING', \n    'COMPLETED', \n    'FAILED'\n}\n\n# Task status states\nTASK_STATUSES = {\n    'PENDING',\n    'PROCESSING',\n    'COMPLETED',\n    'FAILED'\n}\n\n# Task types\nTASK_TYPES = {\n    'GENERATE_DESCRIPTIONS',\n    'GENERATE_IMAGES',\n    'EXPORT_EDITABLE_PPTX'\n}\n\n\ndef validate_project_status(status: str) -> bool:\n    \"\"\"Validate project status\"\"\"\n    return status in PROJECT_STATUSES\n\n\ndef validate_page_status(status: str) -> bool:\n    \"\"\"Validate page status\"\"\"\n    return status in PAGE_STATUSES\n\n\ndef validate_task_status(status: str) -> bool:\n    \"\"\"Validate task status\"\"\"\n    return status in TASK_STATUSES\n\n\ndef validate_task_type(task_type: str) -> bool:\n    \"\"\"Validate task type\"\"\"\n    return task_type in TASK_TYPES\n\n\ndef allowed_file(filename: str, allowed_extensions: Set[str]) -> bool:\n    \"\"\"Check if file extension is allowed\"\"\"\n    return '.' in filename and \\\n           filename.rsplit('.', 1)[1].lower() in allowed_extensions\n\n"
  },
  {
    "path": "create-test-data.mjs",
    "content": "import fetch from 'node-fetch';\nimport FormData from 'form-data';\nimport fs from 'fs';\nimport path from 'path';\n\nconst BASE_URL = process.env.BASE_URL || 'http://localhost:5401';\n\n// 创建项目\nasync function createProject(title) {\n  const response = await fetch(`${BASE_URL}/api/projects`, {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({\n      creation_type: 'idea',\n      idea_prompt: title,\n      template_style: '简约商务风格',\n      image_aspect_ratio: '16:9'\n    })\n  });\n  const data = await response.json();\n  return data.data.project_id;\n}\n\n// 创建临时测试文件\nfunction createTempFile(filename, content) {\n  const tempDir = '/tmp/test-attachments';\n  if (!fs.existsSync(tempDir)) {\n    fs.mkdirSync(tempDir, { recursive: true });\n  }\n  const filepath = path.join(tempDir, filename);\n  fs.writeFileSync(filepath, content);\n  return filepath;\n}\n\n// 上传参考文件\nasync function uploadFile(projectId, filename, content) {\n  const filepath = createTempFile(filename, content);\n  const formData = new FormData();\n  formData.append('file', fs.createReadStream(filepath));\n  if (projectId) {\n    formData.append('project_id', projectId);\n  }\n\n  const response = await fetch(`${BASE_URL}/api/reference-files`, {\n    method: 'POST',\n    body: formData\n  });\n\n  fs.unlinkSync(filepath);\n  return response.json();\n}\n\nasync function main() {\n  console.log('Creating test projects and attachments...\\n');\n\n  const projects = [\n    '产品发布会演示',\n    '季度业绩报告',\n    '市场营销策略',\n    '技术架构设计',\n    '团队培训材料'\n  ];\n\n  for (const title of projects) {\n    console.log(`Creating project: ${title}`);\n    const projectId = await createProject(title);\n\n    // 为每个项目上传2-3个文件\n    const fileCount = Math.floor(Math.random() * 2) + 2;\n    for (let i = 0; i < fileCount; i++) {\n      const filename = `${title.substring(0, 4)}_文档${i + 1}.txt`;\n      const content = `这是 ${title} 的参考文档 ${i + 1}\\n创建时间: ${new Date().toISOString()}`;\n      await uploadFile(projectId, filename, content);\n      console.log(`  - Uploaded: ${filename}`);\n      await new Promise(resolve => setTimeout(resolve, 200));\n    }\n  }\n\n  // 上传一些全局文件（不关联项目）\n  console.log('\\nCreating global attachments...');\n  const globalFiles = ['通用模板.txt', '公司Logo说明.txt', '品牌指南.txt'];\n  for (const filename of globalFiles) {\n    await uploadFile(null, filename, `全局文件: ${filename}\\n${new Date().toISOString()}`);\n    console.log(`  - Uploaded: ${filename}`);\n    await new Promise(resolve => setTimeout(resolve, 200));\n  }\n\n  console.log('\\n✅ Test data created successfully!');\n}\n\nmain().catch(console.error);\n"
  },
  {
    "path": "create-test-data.sh",
    "content": "#!/bin/bash\nBASE_URL=\"http://localhost:5401\"\n\necho \"Creating test projects and attachments...\"\n\n# 创建项目并上传文件\nprojects=(\"产品发布会演示\" \"季度业绩报告\" \"市场营销策略\" \"技术架构设计\" \"团队培训材料\")\n\nfor title in \"${projects[@]}\"; do\n  echo -e \"\\nCreating project: $title\"\n  \n  # 创建项目\n  response=$(curl -s -X POST \"$BASE_URL/api/projects\" \\\n    -H \"Content-Type: application/json\" \\\n    -d \"{\\\"creation_type\\\":\\\"idea\\\",\\\"idea_prompt\\\":\\\"$title\\\",\\\"template_style\\\":\\\"简约商务风格\\\",\\\"image_aspect_ratio\\\":\\\"16:9\\\"}\")\n  \n  project_id=$(echo $response | grep -o '\"project_id\":\"[^\"]*\"' | cut -d'\"' -f4)\n  \n  if [ -n \"$project_id\" ]; then\n    # 为每个项目上传2-3个文件\n    for i in {1..3}; do\n      filename=\"${title:0:8}_文档${i}.txt\"\n      echo \"这是 $title 的参考文档 $i\" > /tmp/test_file.txt\n      \n      curl -s -X POST \"$BASE_URL/api/reference-files\" \\\n        -F \"file=@/tmp/test_file.txt;filename=$filename\" \\\n        -F \"project_id=$project_id\" > /dev/null\n      \n      echo \"  - Uploaded: $filename\"\n      sleep 0.2\n    done\n  fi\ndone\n\n# 上传全局文件\necho -e \"\\nCreating global attachments...\"\nfor name in \"通用模板\" \"公司Logo说明\" \"品牌指南\"; do\n  filename=\"${name}.txt\"\n  echo \"全局文件: $filename\" > /tmp/test_file.txt\n  \n  curl -s -X POST \"$BASE_URL/api/reference-files\" \\\n    -F \"file=@/tmp/test_file.txt;filename=$filename\" > /dev/null\n  \n  echo \"  - Uploaded: $filename\"\n  sleep 0.2\ndone\n\nrm -f /tmp/test_file.txt\necho -e \"\\n✅ Test data created successfully!\"\n"
  },
  {
    "path": "docker/nginx-allinone.conf",
    "content": "server {\n    listen 80;\n    server_name localhost;\n    root /usr/share/nginx/html;\n    index index.html;\n\n    client_max_body_size 50M;\n\n    gzip on;\n    gzip_vary on;\n    gzip_min_length 1024;\n    gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;\n\n    location / {\n        try_files $uri $uri/ /index.html;\n    }\n\n    location ^~ /api {\n        proxy_pass http://127.0.0.1:5000;\n        proxy_http_version 1.1;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection 'upgrade';\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_cache_bypass $http_upgrade;\n        proxy_read_timeout 300s;\n        proxy_connect_timeout 300s;\n    }\n\n    location ^~ /files {\n        proxy_pass http://127.0.0.1:5000;\n        proxy_http_version 1.1;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_read_timeout 300s;\n        proxy_connect_timeout 300s;\n        add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n    }\n\n    location /health {\n        proxy_pass http://127.0.0.1:5000/health;\n        proxy_http_version 1.1;\n        proxy_set_header Host $host;\n    }\n\n    location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {\n        expires 1y;\n        add_header Cache-Control \"public, immutable\";\n    }\n}\n"
  },
  {
    "path": "docker/start-backend.sh",
    "content": "#!/bin/sh\nset -e\n\ncd /app\nuv run --directory backend alembic upgrade head\nexec uv run --directory backend python app.py\n"
  },
  {
    "path": "docker/supervisord.conf",
    "content": "[supervisord]\nnodaemon=true\nlogfile=/dev/null\nlogfile_maxbytes=0\npidfile=/tmp/supervisord.pid\n\n[program:backend]\ncommand=/app/docker/start-backend.sh\ndirectory=/app\nautostart=true\nautorestart=true\nstdout_logfile=/dev/stdout\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/stderr\nstderr_logfile_maxbytes=0\npriority=1\n\n[program:nginx]\ncommand=nginx -g \"daemon off;\"\nautostart=true\nautorestart=true\nstdout_logfile=/dev/stdout\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/stderr\nstderr_logfile_maxbytes=0\npriority=2\n"
  },
  {
    "path": "docker-compose.allinone.yml",
    "content": "services:\n  app:\n    build:\n      context: .\n      dockerfile: Dockerfile.allinone\n      args:\n        DOCKER_REGISTRY: ${DOCKER_REGISTRY:-}\n        GHCR_REGISTRY: ${GHCR_REGISTRY:-ghcr.io/}\n        APT_MIRROR: ${APT_MIRROR:-}\n        PYPI_INDEX_URL: ${PYPI_INDEX_URL:-}\n        NPM_REGISTRY: ${NPM_REGISTRY:-}\n    container_name: banana-slides\n    ports:\n      - \"${PORT:-3000}:80\"\n    env_file:\n      - .env\n    volumes:\n      - ./backend/instance:/app/backend/instance\n      - ./uploads:/app/uploads\n    restart: unless-stopped\n    healthcheck:\n      test: [\"CMD\", \"curl\", \"-f\", \"http://localhost/health\"]\n      interval: 30s\n      timeout: 10s\n      retries: 3\n      start_period: 15s\n"
  },
  {
    "path": "docker-compose.prod.yml",
    "content": "# 此配置文件用于直接拉取 Docker Hub 上已构建好的镜像\n\nservices:\n  backend:\n    # 使用预构建的后端镜像\n    image: ${DOCKER_IMAGE_BACKEND:-anoinex/banana-slides-backend:latest}\n    container_name: banana-slides-backend\n    ports:\n      # 宿主机端口:容器内部端口\n      # 宿主机端口由 BACKEND_PORT 控制，容器内部固定 5000\n      - \"${BACKEND_PORT:-5000}:5000\"\n    # 从 .env 文件自动加载所有环境变量\n    env_file:\n      - .env\n    # Vertex AI 配置（使用 Vertex AI 时取消以下注释）\n    # environment:\n    #   - GOOGLE_APPLICATION_CREDENTIALS=/app/gcp-service-account.json\n    volumes:\n      # 持久化数据库\n      - ./backend/instance:/app/backend/instance\n      # 持久化上传的文件\n      - ./uploads:/app/uploads\n      # GCP 服务账户文件（仅 Vertex AI 用户需要取消下一行注释）\n      # - ./gcp-service-account.json:/app/gcp-service-account.json:ro\n    restart: unless-stopped\n    healthcheck:\n      test: [\"CMD\", \"curl\", \"-f\", \"http://localhost:5000/health\"]\n      interval: 30s\n      timeout: 10s\n      retries: 3\n      start_period: 10s\n    networks:\n      - banana-slides-network\n\n  frontend:\n    # 使用预构建的前端镜像\n    image: ${DOCKER_IMAGE_FRONTEND:-anoinex/banana-slides-frontend:latest}\n    container_name: banana-slides-frontend\n    ports:\n      - \"${FRONTEND_PORT:-3000}:80\"\n    depends_on:\n      - backend\n    restart: unless-stopped\n    networks:\n      - banana-slides-network\n\nnetworks:\n  banana-slides-network:\n    driver: bridge\n\nvolumes:\n  backend-data:\n  uploads-data:\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  backend:\n    build:\n      context: .\n      dockerfile: backend/Dockerfile\n      args:\n        DOCKER_REGISTRY: ${DOCKER_REGISTRY:-}\n        GHCR_REGISTRY: ${GHCR_REGISTRY:-ghcr.io/}\n        APT_MIRROR: ${APT_MIRROR:-}\n        PYPI_INDEX_URL: ${PYPI_INDEX_URL:-}\n    container_name: banana-slides-backend\n    ports:\n      # 宿主机端口:容器内部端口\n      # 宿主机端口由 BACKEND_PORT 控制，容器内部固定 5000\n      - \"${BACKEND_PORT:-5000}:5000\"\n    # 从 .env 文件自动加载所有环境变量\n    env_file:\n      - .env\n    # Uncomment below to use Vertex AI with a GCP service-account key\n    # environment:\n    #   - GOOGLE_APPLICATION_CREDENTIALS=/app/gcp-sa-key.json\n    volumes:\n      # 持久化数据库\n      - ./backend/instance:/app/backend/instance\n      # 持久化上传的文件\n      - ./uploads:/app/uploads\n      # Mount GCP service-account key (Vertex AI only — uncomment if needed)\n      # - ./gcp-service-account.json:/app/gcp-sa-key.json:ro\n    restart: unless-stopped\n    healthcheck:\n      # 健康检查使用容器内部固定端口 5000\n      test: [\"CMD\", \"curl\", \"-f\", \"http://localhost:5000/health\"]\n      interval: 30s\n      timeout: 10s\n      retries: 3\n      start_period: 10s\n    networks:\n      - banana-slides-network\n\n  frontend:\n    build:\n      context: .\n      dockerfile: frontend/Dockerfile\n      # Windows 兼容性：禁用符号链接跟随 + 前端构建参数\n      args:\n        DOCKER_BUILDKIT: 1\n        DOCKER_REGISTRY: ${DOCKER_REGISTRY:-}\n        NPM_REGISTRY: ${NPM_REGISTRY:-}\n    container_name: banana-slides-frontend\n    ports:\n      - \"${FRONTEND_PORT:-3000}:80\"\n    depends_on:\n      - backend\n    restart: unless-stopped\n    networks:\n      - banana-slides-network\n\nnetworks:\n  banana-slides-network:\n    driver: bridge\n\nvolumes:\n  backend-data:\n  uploads-data:\n\n"
  },
  {
    "path": "docs/configuration.mdx",
    "content": "---\ntitle: \"Configuration\"\ndescription: \"Environment variables and provider setup\"\n---\n\n## AI Provider\n\nSet `AI_PROVIDER_FORMAT` in `.env` to choose your provider:\n\n| Format | Description |\n|--------|-------------|\n| `gemini` | Google Gemini API (default) |\n| `openai` | OpenAI-compatible API |\n| `vertex` | Google Cloud Vertex AI |\n| `lazyllm` | Multi-vendor Chinese model routing |\n\n## Gemini (Default)\n\n```env\nAI_PROVIDER_FORMAT=gemini\nGOOGLE_API_KEY=your-api-key\nGOOGLE_API_BASE=https://generativelanguage.googleapis.com\n```\n\n<Warning>\n  The free tier of Gemini API only supports text generation, not image generation.\n</Warning>\n\n## OpenAI-Compatible\n\n```env\nAI_PROVIDER_FORMAT=openai\nOPENAI_API_KEY=your-api-key\nOPENAI_API_BASE=https://api.openai.com/v1\n```\n\n## Vertex AI\n\n```env\nAI_PROVIDER_FORMAT=vertex\nVERTEX_PROJECT_ID=your-gcp-project-id\nVERTEX_LOCATION=global\nGOOGLE_APPLICATION_CREDENTIALS=./gcp-service-account.json\n```\n\n<Tip>\n  `gemini-3-*` series models require `VERTEX_LOCATION=global`.\n</Tip>\n\n## LazyLLM (Multi-Vendor)\n\nRoutes requests to different Chinese AI vendors for text, image, and caption tasks:\n\n```env\nAI_PROVIDER_FORMAT=lazyllm\nTEXT_MODEL_SOURCE=deepseek\nIMAGE_MODEL_SOURCE=doubao\nIMAGE_CAPTION_MODEL_SOURCE=qwen\n```\n\nSet API keys for the vendors you use:\n\n```env\nDOUBAO_API_KEY=your-key       # Volcengine\nDEEPSEEK_API_KEY=your-key     # DeepSeek\nQWEN_API_KEY=your-key         # Alibaba Qwen\nGLM_API_KEY=your-key          # Zhipu GLM\nSILICONFLOW_API_KEY=your-key  # SiliconFlow\nSENSENOVA_API_KEY=your-key    # SenseNova\nMINIMAX_API_KEY=your-key      # MiniMax\n```\n\n## AIHubMix (Recommended Proxy)\n\n[AIHubMix](https://aihubmix.com/?aff=17EC) is a recommended API proxy that supports both Gemini and OpenAI API formats, with stable high-concurrency performance for text-to-image generation. [Apply for an AIHubMix API key here](https://aihubmix.com/token?aff=17EC).\n\n```env\nAI_PROVIDER_FORMAT=openai\nOPENAI_API_KEY=your-aihubmix-key\nOPENAI_API_BASE=https://aihubmix.com/v1\n```\n\n## MinerU (PDF Parsing)\n\n[MinerU](https://mineru.net) provides high-quality PDF parsing for reference file uploads. [Apply for a MinerU token here](https://mineru.net/apiManage/token).\n\n```env\nMINERU_API_BASE=https://mineru.net\nMINERU_TOKEN=your-mineru-token\n```\n\n## Baidu API Key\n\nFor enhanced editable PPTX export with OCR-based text extraction, apply for an [IAM API Key](https://console.bce.baidu.com/iam/#/iam/apikey/list) from Baidu Cloud (generous free tier available):\n\n```env\nBAIDU_API_KEY=your-baidu-api-key\n```\n\n## Runtime Settings Override\n\nAll of the above can also be configured via the web UI's Settings page. Settings configured there are stored in the database and override `.env` values. Use \"Reset to Default\" in Settings to revert to `.env` values.\n"
  },
  {
    "path": "docs/docs.json",
    "content": "{\n  \"$schema\": \"https://mintlify.com/docs.json\",\n  \"theme\": \"mint\",\n  \"name\": \"Banana Slides\",\n  \"colors\": {\n    \"primary\": \"#F59E0B\",\n    \"light\": \"#FBBF24\",\n    \"dark\": \"#D97706\"\n  },\n  \"favicon\": \"/logo/banana.svg\",\n  \"navigation\": {\n    \"languages\": [\n      {\n        \"language\": \"zh\",\n        \"groups\": [\n          {\n            \"group\": \"快速上手\",\n            \"pages\": [\"zh/index\", \"zh/quickstart\", \"zh/configuration\"]\n          },\n          {\n            \"group\": \"创作流程\",\n            \"pages\": [\n              \"zh/features/creation\",\n              \"zh/features/outline\",\n              \"zh/features/descriptions\",\n              \"zh/features/images\"\n            ]\n          },\n          {\n            \"group\": \"素材与导出\",\n            \"pages\": [\n              \"zh/features/materials\",\n              \"zh/features/export\",\n              \"zh/features/import-export\"\n            ]\n          },\n          {\n            \"group\": \"其他\",\n            \"pages\": [\n              \"zh/history\",\n              \"zh/features/overview\",\n              \"zh/faq\"\n            ]\n          }\n        ]\n      },\n      {\n        \"language\": \"en\",\n        \"groups\": [\n          {\n            \"group\": \"Getting Started\",\n            \"pages\": [\"index\", \"quickstart\", \"configuration\"]\n          },\n          {\n            \"group\": \"Creation Flow\",\n            \"pages\": [\n              \"features/creation\",\n              \"features/outline\",\n              \"features/descriptions\",\n              \"features/images\"\n            ]\n          },\n          {\n            \"group\": \"Materials & Export\",\n            \"pages\": [\n              \"features/materials\",\n              \"features/export\",\n              \"features/import-export\"\n            ]\n          },\n          {\n            \"group\": \"More\",\n            \"pages\": [\n              \"history\",\n              \"features/overview\",\n              \"faq\"\n            ]\n          }\n        ]\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "docs/faq.mdx",
    "content": "---\ntitle: \"FAQ\"\ndescription: \"Frequently asked questions\"\n---\n\n## Generated text is garbled or blurry\n\n- Check if you're on 1K low-resolution mode. Switch to 2K or 4K in **Project Settings → Global Settings** — the difference in text clarity is significant. Note: some OpenAI-format proxy services don't support higher resolutions; use Gemini format instead.\n- Include the specific text you want rendered in the page description — AI renders it more accurately when given explicit content.\n\n## Editable PPTX export has overlapping text or missing styles\n\nUsually an API configuration issue. Make sure `BAIDU_API_KEY` is set correctly — see [Configuration](/configuration#baidu-api-key).\n\nYou can also try adjusting the text extraction method in **Project Settings → Export Settings** (OCR / Vision model / Hybrid). Different methods perform differently depending on the layout.\n\n## The regular PPTX export has text I can't edit in PowerPoint\n\nRegular PPTX embeds slides as images — text inside images is not editable. Use **Export Editable PPTX (Beta)** if you need editable text.\n\n## Does the free Gemini API tier work?\n\nText generation (outlines, descriptions) works on the free tier, but image generation requires a paid tier.\n\n## Getting 503 errors or repeated retry failures\n\nUsually caused by incorrect model configuration. Run the **Service Test** at the bottom of the Settings page first to pinpoint the issue.\n\nCheck backend logs for details:\n```bash\ndocker logs --tail 200 banana-slides-backend\n```\n\n## Environment variable changes not taking effect\n\n- Docker deployments require a container restart after editing `.env`.\n- If you've configured settings via the web UI, those database values override `.env`. Click **Reset to Default** in Settings to clear them.\n\n## PPT Renovation didn't parse correctly\n\n- Upload PDF instead of PPTX for more stable results. Direct PPTX upload requires server-side LibreOffice conversion, which may fail due to missing fonts (Arial, Calibri, etc.).\n- Docker users who need PPTX support inside the container:\n  ```bash\n  docker exec -it banana-slides-backend bash -c \"apt-get update && apt-get install -y libreoffice-impress\"\n  ```\n\n## How do I change the aspect ratio?\n\nSelect a ratio on the home page before creating, or change it in **Project Settings** on the preview page. Images must be regenerated after changing the ratio.\n\nNote: some image generation models don't support certain aspect ratios. If generation fails, try switching to 16:9.\n\n## Generation is slow\n\n- Increase **Max description workers** and **Max image workers** in Settings to speed up parallel generation.\n- If you're on a free API tier, rate limits may apply. Consider upgrading or using a relay service like [AIHubMix](https://aihubmix.com/?aff=17EC).\n\n## How do I regenerate just one page?\n\nOn the Slide Preview page, click the edit icon (pencil) on that slide, update the description or add an edit instruction, then click **Generate Image**. You can also use multi-select to batch regenerate several pages.\n\n## Can I include bar charts, line charts, or tree diagrams in slides?\n\nTwo approaches:\n\n**Option 1: Describe in text + generation requirements (recommended)**\nWrite the chart content directly in the page description using plain text or Markdown, and specify the chart style in **Extra Requirements** or **Description Requirements**. AI can usually render it directly. Example:\n```\nRight side of the page shows a 5-year revenue bar chart with data: 2020: $1.2M, 2021: $1.8M, 2022: $2.4M, 2023: $3.1M, 2024: $4.2M. Bars in blue gradient, white background.\n```\n\n**Option 2: Create the chart as an image first**\nGenerate the chart with Excel, Python (matplotlib), or similar tools, then paste it (Ctrl+V) into the description card as a reference image. AI will draw in a similar style.\n\n## How do I keep chart styles consistent across all slides?\n\nUse **text-based style description** rather than uploading a reference image — tests show text gives better generation consistency.\n\nWrite explicit style rules in **Extra Requirements** or **Style Description**, for example:\n- `All charts use blue color scheme (#2563EB as primary), white background, no borders`\n- `Chart fonts are always sans-serif, data labels always shown above bars`\n- `Line charts use circular data point markers, line weight 2px`\n\nThe more specific the constraints, the more consistent the output across pages. If you care about letter spacing or font sizes, include those too.\n\n## Where is the AI input bar for editing outlines and descriptions?\n\nIt's in the **top navigation bar** of the Outline Editor and Description Editor — fill in your instruction and press **Ctrl+Enter**. Example: `Add a case study section after page 3`.\n"
  },
  {
    "path": "docs/features/creation.mdx",
    "content": "---\ntitle: \"Creating a Presentation\"\ndescription: \"Four ways to start — pick the one that fits you\"\n---\n\nOn the home page, choose a creation method, fill in your content, and click **Create New Project**.\n\n## From an Idea (Simplest)\n\nJust one sentence describing your topic — AI handles the full pipeline: outline → descriptions → images.\n\n**Best for**: No existing material, want a PPT generated quickly\n\n**Steps:**\n1. Select the **From Idea** tab\n2. Enter your topic, e.g.: `Create an 8-slide presentation about carbon neutrality`\n3. Choose a template and aspect ratio (optional)\n4. Click **Create New Project**\n\nYou'll land in the Outline Editor to review and adjust the AI-generated outline.\n\n## From an Outline\n\nYou have a structure and don't want AI to guess:\n\n**Best for**: You have an existing outline or table of contents\n\n**Steps:**\n1. Select the **From Outline** tab\n2. Paste your outline — supports title + bullet points, or titles only\n3. Click **Create New Project** — AI automatically parses it into a structured outline\n\n**Format example:**\n```\nSlide 1: The Origins of AI\n- 1956 Dartmouth Conference\n- Vision of early researchers\n\nSlide 2: The Rise of Machine Learning\nThe shift from rule-based to data-driven approaches\n```\n\n## From Descriptions\n\nYou already have detailed per-page descriptions and want to skip the outline step:\n\n**Best for**: Content is fully defined, or you've generated descriptions from another AI tool\n\n**Steps:**\n1. Select the **From Description** tab\n2. Paste your full page descriptions (including layout, color, content)\n3. Click **Create New Project** — skips outline, goes straight to image generation\n\n**Format example:**\n```\nSlide 1: Cover\nClean cover page, company logo centered, white bold title, dark blue gradient background.\n\nSlide 2: Market Overview\n5-year market growth bar chart, key figures highlighted in cards.\n```\n\n## PPT Renovation\n\nUpload an existing PDF or PPTX — AI parses the content and regenerates a visually refreshed version:\n\n**Best for**: Outdated PPT needing a visual refresh, or using an existing PPT as a starting point\n\n**Steps:**\n1. Select the **PPT Renovation** tab\n2. Upload a PDF or PPTX file\n3. Optionally check **Keep original layout** to stay closer to the original structure\n4. Click **Create New Project**\n\n<Note>\n  We recommend converting PPTX to PDF locally before uploading. Direct PPTX upload requires server-side LibreOffice conversion, which may cause layout issues due to missing fonts.\n</Note>\n\n## Aspect Ratio\n\nChoose an aspect ratio below the input area before creating the project:\n\n| Ratio | Best for |\n|-------|---------|\n| 16:9 | Standard widescreen (default) |\n| 4:3 | Traditional slides |\n| 21:9 | Ultrawide displays |\n| 1:1 | Square (social media) |\n| 9:16 | Portrait (mobile) |\n\n<Note>\n  Some image generation models don't support certain aspect ratios. If generation fails, try switching to 16:9. The ratio can also be changed in Project Settings after creation — images must be regenerated to take effect.\n</Note>\n\n## Template and Style\n\nSet the visual style before creating:\n\n**Option 1: Choose a template**\n- Pick from the preset template gallery, or upload a custom template image\n\n**Option 2: Text style description**\n- Check **Use text description for style**\n- Describe the desired visual style, e.g.: `Modern tech aesthetic, dark background, blue-purple gradient tones`\n\n## Upload Reference Files\n\nAttach reference files when creating — AI extracts the content as source material:\n\n- **Supported formats**: PDF, DOCX, PPTX, XLSX, CSV, TXT, MD\n- **Upload methods**: Click the paperclip icon, drag and drop, or paste (Ctrl+V)\n- **File size limit**: 200MB\n\nFiles parse automatically after upload. Project creation is blocked until parsing completes. See [Materials & Files](/features/materials).\n"
  },
  {
    "path": "docs/features/descriptions.mdx",
    "content": "---\ntitle: \"Write Descriptions\"\ndescription: \"Generate or write detailed visual descriptions for each slide\"\n---\n\nThe Description Editor is the third step. Each page's \"description\" tells the AI what to draw — layout, color palette, charts, text content. The more specific, the better the result.\n\n## Batch Generate All Descriptions\n\nClick **Batch Generate Descriptions** — AI generates descriptions for every page automatically. Review and edit page by page when done.\n\n**Adjust detail level before generating** (dropdown next to the button):\n\n| Level | Best for |\n|-------|---------|\n| Concise | Quick prototypes, speed priority |\n| Default | Everyday use |\n| Detailed | Fine-tuning, quality priority |\n\n<Tip>\n  Include specific text content in descriptions (e.g., headline copy, data values) — this significantly improves text rendering quality on generated slides.\n</Tip>\n\n## Refine with AI\n\nThe top input bar lets you adjust all descriptions at once. Press **Ctrl+Enter** to submit.\n\n**Common commands:**\n- `Make all descriptions more detailed with specific text content`\n- `Change everything to a dark blue tech aesthetic`\n- `Add a data comparison table to page 3's description`\n- `Remove all mentions of \"gradient background\" from every description`\n\n## Regenerate a Single Page\n\nEach description card has a **Regenerate** button (🔄) in the top-right:\n\n1. Click the regenerate icon\n2. Confirm in the dialog\n3. AI regenerates just that page's description and replaces the old one\n\n## Edit a Page Manually\n\nClick directly on the text in any description card to edit it inline. Changes save automatically when you click elsewhere.\n\n## Set Description Requirements\n\nExpand **Description Generation Requirements** in the toolbar to add overall constraints — applied on every generation.\n\n**Example requirements:**\n- `Keep each description under 100 words`\n- `Favor data and charts over plain text`\n- `Maintain a clean minimalist style`\n\n## Add a Reference Image for a Page\n\nAt the bottom of each description card:\n\n1. Find the **Upload Images** section\n2. Upload or select a reference image from the material library\n3. Add an edit instruction (optional), then click **Generate Image**\n\n## Import / Export\n\nClick **Import/Export**:\n- **Export Descriptions**: Export description content only\n- **Export Outline & Descriptions**: Full export including key points\n- **Import**: Load from file, appended to existing pages\n\n## Next Step\n\nOnce all pages have descriptions, the **Next →** button activates. Click it to go to the [Slide Preview](/features/images) and start generating images.\n"
  },
  {
    "path": "docs/features/editing.mdx",
    "content": "---\ntitle: \"Editing Slides\"\ndescription: \"Modify presentations with natural language\"\n---\n\n## Natural Language Editing\n\nInstead of navigating complex menus, describe your changes in plain language:\n\n- \"Change the third page to a case study\"\n- \"Replace this chart with a pie chart\"\n- \"Make the background darker\"\n- \"Add a summary bullet list\"\n\n## Outline Editing\n\nIn the Outline Editor, refine the structure by typing instructions like:\n\n- \"Add a section about competitive analysis after the introduction\"\n- \"Merge the last two pages\"\n- \"Move the conclusion before the Q&A page\"\n\nYou can also manually drag and drop pages to reorder them, or edit chapter assignments.\n\n## Region-Based Editing\n\nSelect a specific area of a slide in the preview and describe what you want changed. The AI regenerates only that region while keeping the rest intact.\n\nSteps:\n1. Go to the Slide Preview page and click **Region Select**\n2. Drag to select the area you want to modify on the slide\n3. Describe the change in the input box\n4. Click **Generate Image** — AI redraws only the selected region\n\n## Description Detail Level\n\nNext to the **Batch Generate Descriptions** button in the Detail Editor, you can choose a detail level:\n\n| Level | Description |\n|-------|-------------|\n| Concise | Brief descriptions, faster generation, good for quick prototypes |\n| Default | Balanced between detail and speed |\n| Detailed | Richer descriptions give AI more compositional reference, ideal for fine-tuning |\n\n## Version History\n\nEvery time you regenerate a slide image, the previous version is automatically saved. Click **History Versions** on the preview page to view and restore any previous version — no need to worry about accidentally overwriting a result you liked.\n"
  },
  {
    "path": "docs/features/export.mdx",
    "content": "---\ntitle: \"Export Options\"\ndescription: \"Export presentations as PPTX, PDF, or images\"\n---\n\n## Formats\n\n| Format | Description |\n|--------|-------------|\n| PPTX | Slides are embedded as images in the PPT file. You can play and reorder pages in PowerPoint, but the text inside images cannot be directly edited |\n| Editable PPTX (Beta) | Extracts text from slide images and rebuilds it as native PowerPoint text boxes, enabling direct text editing in PowerPoint |\n| PDF | Pixel-perfect output, ready for presentation |\n| Images | Each page exported as an individual image file |\n\n## Aspect Ratio\n\nDefaults to 16:9. You can change the ratio when creating a project or in **Project Settings** on the preview page. Supports 16:9, 4:3, 21:9, 1:1, 9:16, and more. Images must be regenerated after changing the ratio.\n\n## Editable PPTX (Beta)\n\nThe extraction process preserves font size, color, bold styling, text positioning, and table content. However, due to OCR accuracy limits and current model capabilities, complex layouts may have discrepancies from the original slide appearance.\n\n<Note>\n  For best extraction results, configure `BAIDU_API_KEY` in your environment. See [Configuration](/configuration#baidu-api-key).\n</Note>\n\n## Page Selection\n\nYou can select specific pages to export rather than exporting the entire presentation. Enable **Multi-select** on the Slide Preview page, check the pages you want, then click **Export**.\n"
  },
  {
    "path": "docs/features/images.mdx",
    "content": "---\ntitle: \"Generate & Refine Images\"\ndescription: \"Generate slide images and fine-tune them page by page\"\n---\n\nThe Slide Preview is the final editing step. Generate images, refine details, browse version history, then export.\n\n## Batch Generate All Images\n\nClick **Batch Generate Images** — AI generates all pages in parallel. Each card shows a progress animation; previews appear as they complete.\n\n<Note>\n  If the current resolution is 1K, a warning dialog recommends switching to 2K or 4K — the difference in text clarity is significant. Change this in **Project Settings → Global Settings**.\n</Note>\n\n## Single-Page Refinement\n\nWhen one slide doesn't look right, no need to regenerate everything:\n\n1. Click the **Edit** icon (pencil) on the slide card\n2. Update the **page description** or add an **edit instruction** in the right panel\n3. Click **Generate Image** — only this page regenerates\n\n**Edit instruction examples:**\n- `Change the background to a dark gradient`\n- `Move the title to the top and increase font size`\n- `Replace the right side with a bar chart, keep the data`\n- `Change the overall style to hand-drawn illustration`\n\n<Tip>\n  To save description changes without regenerating the image, click **Save Outline/Description Only**.\n</Tip>\n\n## Region Editing: Change Only Part of a Slide\n\nWant to adjust a specific area without touching the rest:\n\n1. Enter single-page edit mode, then click **Region Select** above the image\n2. **Click and drag** to select the area you want to modify\n3. The selected region is cropped and added as a reference image\n4. Describe the change in the edit instruction box, then click **Generate Image**\n\nAI redraws the full slide with extra focus on the selected region.\n\n## Version History\n\nEvery regeneration automatically saves the previous version — nothing is lost.\n\n1. Enter single-page edit mode\n2. Click **History Versions** in the bottom-left of the image\n3. Select any historical version to restore it\n\n<Tip>\n  Experiment freely with different prompts — you can always roll back to a version you liked.\n</Tip>\n\n## Batch Regenerate Selected Pages\n\nTo regenerate only a subset of pages:\n\n1. Click **Multi-select** at the top\n2. Check the pages you want to regenerate\n3. Click **Generate Selected (N)**\n\n## Project Settings\n\nClick the gear icon **Project Settings** in the top-right — three tabs:\n\n### Project Settings Tab\n\n| Setting | Notes |\n|---------|-------|\n| Aspect Ratio | Changes take effect after regenerating images |\n| Extra Requirements | Applied to all image generation, e.g. \"avoid real human photos\" |\n| Style Description | Visual constraints, e.g. \"minimalist, white background, thin lines\" |\n| Change Template | Affects only future generations, not existing images |\n\n### Export Settings Tab\n\nConfigure **Editable PPTX** extraction behavior:\n- **Text Extractor**: OCR / Vision model / Hybrid — each has quality vs. speed tradeoffs\n- **Image Inpainting**: Pixel-based / AI-based / Hybrid\n- **Error Handling**: Enable **Allow Partial Results** to get output even when errors occur\n\n### Global Settings Tab\n\nChange API provider, model names, image resolution (1K / 2K / 4K), max concurrent workers. Run **Service Test** here to verify your configuration is working.\n"
  },
  {
    "path": "docs/features/import-export.mdx",
    "content": "---\ntitle: \"Import & Export Format\"\ndescription: \"Markdown format for importing and exporting outlines and descriptions\"\n---\n\nBanana Slides uses Markdown files (`.md` / `.txt`) to import and export outlines and page descriptions. The format is human-readable and easy to edit in any text editor.\n\n## Format Structure\n\n```markdown\n# Project Title\n\n> 生成时间: 2025/1/1 12:00:00\n\n---\n\n## 第 1 页: Cover Page\n\n> 章节: Introduction\n\n**大纲要点：**\n- Company name and logo\n- Presentation date\n\n**页面描述：**\nA clean cover page with the company logo centered, title in bold white text on a dark blue gradient background.\n\n---\n\n## 第 2 页: Market Overview\n\n> 章节: Analysis\n\n**大纲要点：**\n- Industry growth trends\n- Key market segments\n\n**页面描述：**\nA data-driven page showing a bar chart of market growth over the past 5 years, with key statistics highlighted in callout boxes.\n\n---\n```\n\n## Field Reference\n\n| Field | Syntax | Required |\n|-------|--------|----------|\n| Page header | `## 第 N 页: Title` | Yes |\n| Chapter | `> 章节: Name` | No |\n| Key points | `**大纲要点：**` followed by `- point` lines | No |\n| Description | `**页面描述：**` followed by free text | No |\n\nPages are separated by `---`.\n\n## Export Options\n\nYou can export from both the Outline Editor and Detail Editor:\n\n| Source | Content |\n|--------|---------|\n| Outline Editor | Key points only (no descriptions) |\n| Detail Editor | Both key points and descriptions |\n\n## Import Behavior\n\n- Imported pages are **appended** to the existing project — they do not replace current pages.\n- Both `**大纲要点：**` and `**页面描述：**` markers are optional. If omitted, bullet lines (`- ...`) are treated as key points.\n- HTML tags in imported content are automatically stripped.\n- Accepts `.md` and `.txt` files.\n\n## Minimal Example\n\nA valid import file can be as simple as:\n\n```markdown\n## 第 1 页: Introduction\n- Welcome and agenda\n- Speaker introduction\n\n## 第 2 页: Summary\n- Key takeaways\n```\n\n## Generate with AI\n\nCopy the prompt below into any AI assistant (ChatGPT, Claude, etc.), replace the placeholders, and import the output directly into Banana Slides.\n\n<Tip>\n  For outline-only import, remove the `**页面描述：**` requirement from the prompt.\n</Tip>\n\n<Accordion title=\"AI Prompt Template\">\n\n```text\nGenerate a presentation outline in the following Markdown format.\n\nTopic: [YOUR TOPIC]\nNumber of pages: [NUMBER, e.g. 8]\nLanguage: [Chinese / English]\nStyle notes: [e.g. \"professional and data-driven\" or \"playful and visual\"]\n\nRules:\n1. Each page starts with \"## 第 N 页: Title\"\n2. Group pages into chapters using \"> 章节: Chapter Name\"\n3. Under \"**大纲要点：**\", list 2-4 key points as \"- point\"\n4. Under \"**页面描述：**\", write 1-2 sentences describing the visual layout,\n   colors, charts, or imagery for that slide\n5. Separate pages with \"---\"\n6. First page should be a cover, last page should be a closing/thank-you page\n\nOutput ONLY the Markdown, no extra explanation.\n```\n\n</Accordion>\n"
  },
  {
    "path": "docs/features/materials.mdx",
    "content": "---\ntitle: \"Materials & Files\"\ndescription: \"Upload reference files, paste images, or generate custom materials with AI\"\n---\n\n## Reference Files\n\nUpload documents as source material — AI references the extracted content when generating outlines, descriptions, and images.\n\n### Supported Formats\n\n| Format | Extracted Content |\n|--------|-----------------|\n| PDF | Text + embedded images |\n| DOCX / DOC | Document content and structure |\n| PPTX / PPT | Slide content |\n| XLSX / XLS / CSV | Spreadsheet data |\n| Markdown / TXT | Plain text |\n\n<Tip>\n  PDF parsing uses [MinerU](https://mineru.net) for high-quality extraction. Configure a MinerU token for better results — see [Configuration](/configuration#mineru-pdf-parsing).\n</Tip>\n\n### Upload Methods\n\n- Click the **paperclip icon** next to the input area to open a file picker\n- **Drag and drop** files into the input area\n- **Paste** (Ctrl+V) files or images from clipboard\n- Click **Material Center** to select from previously uploaded files\n\nFile size limit: **200MB**. Files parse automatically after upload and show \"Parsing\" status until complete.\n\n### Where to Upload\n\n| Location | Notes |\n|----------|-------|\n| Home page | Upload before creating a project — applies to the whole project |\n| Outline Editor | Manage files in the left panel |\n| Description Editor | View and manage files in the top section |\n| Slide Preview (single-page edit) | Attach a reference image to one specific page |\n\n## Pasting Images\n\nPaste an image anywhere (Ctrl+V) in the home page, Outline Editor, or Description Cards:\n\n1. Copy an image (screenshot, copy from web, etc.)\n2. Press **Ctrl+V** in the input area\n3. The image uploads automatically — AI recognizes its content and inserts it as a Markdown image reference at the cursor position\n\n## Style References\n\nUpload an image as a visual style reference — AI will match its color palette, layout patterns, and design language.\n\nUpload in the **Select Style Template** section on the home page, or in the **Upload Images** section of single-page editing.\n\n## Material Generator\n\nClick **Generate Material** in the top navigation bar to create custom AI-generated images:\n\n1. Describe the image you want, e.g.: `Blue-purple gradient background with geometric shapes and tech-style lines`\n2. Select an aspect ratio\n3. Optionally upload a reference image to guide the style\n4. Click **Generate Material**\n\nGenerated images are automatically saved to the material library and available for reuse in projects.\n\n## Material Center\n\nClick **Material Center** in the top navigation bar to manage all materials:\n\n- **Filter**: By project, or view all / unassociated materials\n- **Preview**: Click the eye icon for fullscreen preview\n- **Download**: Select items and download — multiple files are packaged as ZIP\n- **Delete**: Individual or batch delete\n- **Upload**: Add new images directly from Material Center\n"
  },
  {
    "path": "docs/features/outline.mdx",
    "content": "---\ntitle: \"Edit the Outline\"\ndescription: \"Review and adjust the AI-generated outline, or build one from scratch\"\n---\n\nThe Outline Editor is the second step. Each slide is a card with a **title** and **key points** — no visuals yet.\n\n## Refine with AI\n\nThe top input bar is the fastest way to edit the outline. Describe what you want in plain language and press **Ctrl+Enter**.\n\n**Common commands:**\n- `Add a competitive analysis section after the introduction`\n- `Merge pages 4 and 5, keep the key points`\n- `Move the conclusion before the Q&A page`\n- `Change the title of page 2 to \"Core Advantages\"`\n- `Delete page 6`\n\n<Tip>\n  You can chain multiple instructions — AI remembers context. For example: \"Add a case study section\", then \"Move it to page 3\".\n</Tip>\n\n## Edit Cards Manually\n\nClick any card to edit its title or key points inline:\n\n1. Click the title or bullet text on a card\n2. Edit directly\n3. Click elsewhere to auto-save\n\nThe delete button is in the top-right corner of each card — requires confirmation.\n\n## Add a New Page\n\nClick **Add Page** in the toolbar to insert a blank page at the end of the list.\n\n## Reorder by Dragging\n\nGrab the drag handle (⠿ icon) on the left side of a card and drag it to the target position — the order updates immediately.\n\n## Regenerate the Outline\n\nNot happy with the current outline? Click **Regenerate Outline** to have AI start fresh.\n\n<Warning>\n  Regenerating overwrites all existing outline content. A confirmation dialog appears before proceeding. This cannot be undone.\n</Warning>\n\n## Set Generation Requirements\n\nExpand **Outline Generation Requirements** in the toolbar to add overall constraints. These apply to every generation and regeneration.\n\n**Example requirements:**\n- `No more than 4 bullet points per page`\n- `Use a timeline structure ordered by year`\n- `Emphasize data and case studies`\n\n## Import / Export\n\nClick the **Import/Export** button:\n\n- **Export Outline**: Save the current outline as a `.md` file — edit it in any text editor and reimport\n- **Import**: Load an outline from a `.md` or `.txt` file, **appended** to the end of existing pages\n\nSee [Import/Export Format](/features/import-export) for the file format spec.\n\n## Next Step\n\nOnce the outline looks good, click **Next →** in the top-right to go to the [Description Editor](/features/descriptions).\n"
  },
  {
    "path": "docs/features/overview.mdx",
    "content": "---\ntitle: \"Features Overview\"\ndescription: \"What Banana Slides can do\"\n---\n\n## The Workflow\n\n```\nCreate Project → Edit Outline → Write Descriptions → Generate Images → Export\n```\n\nEvery step supports AI assistance and manual fine-tuning.\n\n## Four Creation Paths\n\n| Path | Best for |\n|------|---------|\n| From an idea | Just a topic — let AI generate everything |\n| From an outline | You have an existing structure |\n| From descriptions | Content is already clear, generate images directly |\n| PPT Renovation | Upload an old PPT, AI regenerates a fresh version |\n\nSee [Creating a Presentation](/features/creation).\n\n## AI-Assisted Editing\n\nEvery editing step has a top input bar — tell AI what to do in plain language:\n\n- **Outline Editor**: `Add a competitive analysis section after page 3`\n- **Description Editor**: `Make all descriptions more detailed with specific text content`\n- **Slide Preview** (single page): `Change the background to dark blue, increase title font size`\n\nSee [Edit Outline](/features/outline), [Write Descriptions](/features/descriptions), [Generate & Refine Images](/features/images).\n\n## Material Support\n\n- Upload reference files (PDF, DOCX, PPTX, spreadsheets, etc.) — AI extracts content to inform generation\n- Paste images — AI recognizes content and inserts them\n- Use the **Material Generator** to create custom backgrounds or illustrations with AI\n- **Material Center** manages all your materials for easy reuse\n\nSee [Materials & Files](/features/materials).\n\n## Export\n\n- **PPTX**: Slides embedded as images, playable and reorderable in PowerPoint\n- **Editable PPTX (Beta)**: Text extracted as native text boxes, editable in PowerPoint\n- **PDF**: Pixel-perfect output\n- **Images**: Each page as an individual file\n\nMultiple aspect ratios supported, with selective page export. See [Export Options](/features/export).\n"
  },
  {
    "path": "docs/history.mdx",
    "content": "---\ntitle: \"Manage Projects\"\ndescription: \"View, rename, and delete past projects\"\n---\n\nClick **History** in the top navigation bar to see all your presentations.\n\n## Continue Editing a Project\n\nClick any project card to jump back into that project and continue where you left off.\n\n## Rename a Project\n\nHover over the project title — when the edit icon appears, click it, type the new name, then press **Ctrl+Enter** to save or **Esc** to cancel.\n\n## Delete Projects\n\n- **Delete one**: Click the delete icon on the card and confirm\n- **Delete multiple**: Check several projects and click **Batch Delete**\n\n<Warning>\n  Deletion is permanent and cannot be undone — all pages, descriptions, and generated images are removed.\n</Warning>\n"
  },
  {
    "path": "docs/index.mdx",
    "content": "---\ntitle: \"Banana Slides\"\ndescription: \"AI-native presentation generation — Vibe your slides like vibe coding\"\n---\n\nBanana Slides is an AI-native PPT generation app. Enter an idea or upload an existing file, and AI automatically generates the outline, descriptions, and slide images — with natural language refinement at every step.\n\n## Get Started\n\n<CardGroup cols={2}>\n  <Card title=\"First Time Setup\" icon=\"rocket\" href=\"/quickstart\">\n    Deploy and generate your first PPT in 5 minutes\n  </Card>\n  <Card title=\"Configure API\" icon=\"key\" href=\"/configuration\">\n    Set up your AI provider and API keys\n  </Card>\n</CardGroup>\n\n## Core Features\n\n<CardGroup cols={2}>\n  <Card title=\"Create a Presentation\" icon=\"plus\" href=\"/features/creation\">\n    Four creation paths: from idea, outline, descriptions, or renovate an existing PPT\n  </Card>\n  <Card title=\"Edit the Outline\" icon=\"list\" href=\"/features/outline\">\n    Refine structure with natural language or drag-and-drop reordering\n  </Card>\n  <Card title=\"Write Descriptions\" icon=\"pencil\" href=\"/features/descriptions\">\n    Batch generate, refine with AI, or manually edit page by page\n  </Card>\n  <Card title=\"Generate & Refine Images\" icon=\"image\" href=\"/features/images\">\n    Batch generate, single-page editing, region editing, version history\n  </Card>\n  <Card title=\"Materials & Files\" icon=\"paperclip\" href=\"/features/materials\">\n    Upload reference files, generate materials with AI, manage your library\n  </Card>\n  <Card title=\"Export\" icon=\"download\" href=\"/features/export\">\n    Export as PPTX, PDF, or images — multiple aspect ratios supported\n  </Card>\n</CardGroup>\n"
  },
  {
    "path": "docs/logo/.gitkeep",
    "content": ""
  },
  {
    "path": "docs/quickstart.mdx",
    "content": "---\ntitle: \"Quick Start\"\ndescription: \"Try the demo or deploy Banana Slides locally\"\n---\n\n## Online Demo\n\nNo installation needed — try it instantly: [bananaslides.online](https://bananaslides.online/)\n\n---\n\n## Self-Hosting\n\n### Step 1: Install Docker\n\n<Tabs>\n  <Tab title=\"Windows / macOS\">\n    Download and install [Docker Desktop](https://www.docker.com/products/docker-desktop/). After installation, launch it and confirm the Docker icon appears in the system tray (Windows) or menu bar (macOS).\n  </Tab>\n  <Tab title=\"Linux\">\n    ```bash\n    curl -fsSL https://get.docker.com | sh\n    ```\n  </Tab>\n</Tabs>\n\n### Step 2: Download and Start\n\nCreate a new directory, enter it, and run the following command to download the config files and start the service:\n\n```bash\nmkdir banana-slides && cd banana-slides && \\\ncurl -O https://raw.githubusercontent.com/Anionex/banana-slides/main/docker-compose.prod.yml && \\\ncurl -O https://raw.githubusercontent.com/Anionex/banana-slides/main/.env.example && \\\ncp .env.example .env\n```\n\nEdit the `.env` file to fill in your API key (see [Configuration](/configuration)), then start:\n\n```bash\ndocker compose -f docker-compose.prod.yml up -d\n```\n\n<Tip>\n  [AIHubMix](https://aihubmix.com/?aff=17EC) is recommended for API keys — it supports both Gemini and OpenAI formats and handles high-concurrency image generation reliably.\n</Tip>\n\n### Step 3: Open the App\n\nVisit [http://localhost:3000](http://localhost:3000) in your browser.\n\n<Note>\n  Default ports are 3000 (frontend) and 5000 (backend). To change them, set `FRONTEND_PORT` and `BACKEND_PORT` in `.env`.\n</Note>\n\n---\n\n## Update\n\n```bash\ndocker compose -f docker-compose.prod.yml pull && \\\ndocker compose -f docker-compose.prod.yml up -d\n```\n\n## View Logs\n\n```bash\ndocker logs -f --tail 100 banana-slides-backend\n```\n\n---\n\n## Deploy from Source\n\n<Accordion title=\"Expand for source deployment steps\">\n\n### Requirements\n\n- Python 3.10+\n- [uv](https://github.com/astral-sh/uv) package manager\n- Node.js 16+ and npm\n\n### Backend\n\n```bash\ngit clone https://github.com/Anionex/banana-slides\ncd banana-slides\ncp .env.example .env  # Edit .env and add your API key\ncd backend\nuv run alembic upgrade head && uv run python app.py\n```\n\n### Frontend\n\n```bash\ncd frontend\nnpm install\nnpm run dev\n```\n\n</Accordion>\n"
  },
  {
    "path": "docs/zh/configuration.mdx",
    "content": "---\ntitle: \"配置\"\ndescription: \"环境变量与服务商设置\"\n---\n\n## AI 服务商\n\n在 `.env` 中设置 `AI_PROVIDER_FORMAT` 选择服务商：\n\n| 格式 | 说明 |\n|------|------|\n| `gemini` | Google Gemini API（默认） |\n| `openai` | OpenAI 兼容 API |\n| `vertex` | Google Cloud Vertex AI |\n| `lazyllm` | 多厂商国产模型路由 |\n\n## Gemini（默认）\n\n```env\nAI_PROVIDER_FORMAT=gemini\nGOOGLE_API_KEY=your-api-key\nGOOGLE_API_BASE=https://generativelanguage.googleapis.com\n```\n\n<Warning>\n  Gemini API 免费层仅支持文本生成，不支持图片生成。\n</Warning>\n\n## OpenAI 兼容\n\n```env\nAI_PROVIDER_FORMAT=openai\nOPENAI_API_KEY=your-api-key\nOPENAI_API_BASE=https://api.openai.com/v1\n```\n\n## Vertex AI\n\n```env\nAI_PROVIDER_FORMAT=vertex\nVERTEX_PROJECT_ID=your-gcp-project-id\nVERTEX_LOCATION=global\nGOOGLE_APPLICATION_CREDENTIALS=./gcp-service-account.json\n```\n\n<Tip>\n  `gemini-3-*` 系列模型需要 `VERTEX_LOCATION=global`。\n</Tip>\n\n## LazyLLM（多厂商路由）\n\n将请求路由到不同国产 AI 厂商：\n\n```env\nAI_PROVIDER_FORMAT=lazyllm\nTEXT_MODEL_SOURCE=deepseek\nIMAGE_MODEL_SOURCE=doubao\nIMAGE_CAPTION_MODEL_SOURCE=qwen\n```\n\n设置对应厂商的 API Key：\n\n```env\nDOUBAO_API_KEY=your-key       # 火山引擎/豆包\nDEEPSEEK_API_KEY=your-key     # DeepSeek\nQWEN_API_KEY=your-key         # 阿里云/通义千问\nGLM_API_KEY=your-key          # 智谱 GLM\nSILICONFLOW_API_KEY=your-key  # 硅基流动\nSENSENOVA_API_KEY=your-key    # 商汤日日新\nMINIMAX_API_KEY=your-key      # MiniMax\n```\n\n## AIHubMix（推荐中转）\n\n[AIHubMix](https://aihubmix.com/?aff=17EC) 是推荐的 API 中转服务，同时支持 Gemini 和 OpenAI 两种接口格式，且能稳定进行高并发文生图操作。[点击此处申请 AIHubMix API Key](https://aihubmix.com/token?aff=17EC)。\n\n```env\nAI_PROVIDER_FORMAT=openai\nOPENAI_API_KEY=your-aihubmix-key\nOPENAI_API_BASE=https://aihubmix.com/v1\n```\n\n## MinerU（PDF 解析）\n\n[MinerU](https://mineru.net) 提供高质量的 PDF 解析服务，用于参考文件上传时的内容提取。[点击此处申请 MinerU Token](https://mineru.net/apiManage/token)。\n\n```env\nMINERU_API_BASE=https://mineru.net\nMINERU_TOKEN=your-mineru-token\n```\n\n## 百度 API Key\n\n配置百度 API Key 以获得更好的可编辑 PPTX 导出效果（有充足免费额度）：\n\n```env\nBAIDU_API_KEY=your-baidu-api-key\n```\n\n从百度智能云[申请 IAM API Key](https://console.bce.baidu.com/iam/#/iam/apikey/list)。\n\n## 运行时设置覆盖\n\n以上所有配置也可通过网页设置页面进行配置。通过设置页面配置的参数会存储在数据库中，优先级高于 `.env`。点击\"还原默认设置\"可恢复为 `.env` 中的值。\n"
  },
  {
    "path": "docs/zh/faq.mdx",
    "content": "---\ntitle: \"常见问题\"\ndescription: \"常见问题解答\"\n---\n\n## 生成的文字有乱码或模糊\n\n- 检查是否处于 1K 低分辨率模式。在「**项目设置 → 全局设置**」中切换到 2K 或 4K 分辨率，文字清晰度差异明显。注意：部分 OpenAI 格式中转服务不支持调高分辨率，建议改用 Gemini 格式。\n- 在页面描述中包含具体要渲染的文字内容，AI 更容易正确渲染。\n\n## 可编辑 PPTX 导出效果不好，文字错位或缺失\n\n通常是 API 配置问题。确保 `BAIDU_API_KEY` 设置正确，详见[配置说明](/zh/configuration#百度-api-key)。\n\n也可以在「**项目设置 → 导出设置**」中调整文本提取方法（OCR / 视觉模型 / 混合），不同模型对不同排版效果差异较大。\n\n## 普通 PPTX 导出后文字无法在 PowerPoint 中编辑\n\n普通 PPTX 是将幻灯片图片嵌入 PPT，图片内的文字不可编辑。需要可编辑文字请使用「**导出可编辑 PPTX（Beta）**」。\n\n## 免费版 Gemini API 能用吗？\n\n文本生成（大纲、描述）可以用，但图片生成需要付费层级。\n\n## 出现 503 错误或持续重试\n\n通常是模型配置错误。建议先在设置页底部点击「**服务测试**」验证配置，再排查具体问题。\n\n也可以查看后端日志：\n```bash\ndocker logs --tail 200 banana-slides-backend\n```\n\n## 修改 .env 后没有生效\n\n- Docker 部署需重启容器才生效。\n- 如果在网页设置页配置过参数，数据库中的值优先级高于 `.env`，点击「**还原默认设置**」可以清除。\n\n## PPT 翻新上传后解析结果不对\n\n- 推荐先在本地将 PPTX 转为 PDF 再上传，效果更稳定。\n- 直接上传 PPTX 需要服务端 LibreOffice 转换，可能因缺少字体（微软雅黑、Calibri 等）导致排版偏差。\n- Docker 用户需要在容器内安装 LibreOffice 才支持 PPTX 上传：\n  ```bash\n  docker exec -it banana-slides-backend bash -c \"apt-get update && apt-get install -y libreoffice-impress\"\n  ```\n\n## 如何更改画面比例？\n\n在首页创建项目前选择比例，或在幻灯片预览页的「**项目设置**」中修改。修改后需要重新生成图片才会生效。\n\n注意：部分图像生成模型不支持某些比例，若生成报错可尝试切换到 16:9。\n\n## 生成速度很慢\n\n- 在「**设置**」中调大「**描述生成最大并发数**」和「**图像生成最大并发数**」，提高并行度。\n- 如果使用免费 API 额度，可能有速率限制，考虑使用付费额度或 [AIHubMix](https://aihubmix.com/?aff=17EC) 等中转服务。\n\n## 如何只重新生成某一页？\n\n在幻灯片预览页，点击该页的编辑图标（铅笔），在右侧修改描述或填写修改指令，点击「**生成图片**」，只重新生成这一页。\n\n## 幻灯片里能放柱状图、折线图、树状图这些图表吗？\n\n有两种方案：\n\n**方案一：描述 + 生成要求（推荐）**\n在页面描述中直接用 Markdown 或文字描述图表内容，并在「描述生成要求」或「额外要求」中注明图表风格，AI 大概率能直接生成出来。示例描述：\n```\n页面右侧展示过去5年营收柱状图，数据为：2020: 120万、2021: 180万、2022: 240万、2023: 310万、2024: 420万，柱子为蓝色渐变，背景白色。\n```\n\n**方案二：先做成图片再粘贴**\n用 Excel、Python（matplotlib）等工具提前生成图表图片，然后直接粘贴（Ctrl+V）到描述卡片中作为参考图，AI 会参考图片风格进行绘制。\n\n## 如何保证每页图表风格统一？\n\n推荐使用**文字描述风格**而非上传参考图片——根据测试，文字方式对生成一致性的控制效果更好。\n\n在「额外要求」或「风格描述」中明确写下统一规范，例如：\n- `所有图表使用蓝色系配色（#2563EB 为主色），白色背景，无边框`\n- `图表字体统一为无衬线字体，数据标签始终显示在柱子顶部`\n- `折线图使用圆形数据点标记，线条粗细 2px`\n\n风格约束写得越具体，各页一致性越高。如果对字间距、字号这类细节有要求，也可以直接写进去。\n\n## AI 修改大纲/描述的输入框在哪里？\n\n在**大纲编辑器**和**描述编辑器**的顶部导航栏中，有一个输入框，填写指令后按 **Ctrl+Enter** 提交。例如：`在第三页后加一页案例分析`。\n"
  },
  {
    "path": "docs/zh/features/creation.mdx",
    "content": "---\ntitle: \"创建演示文稿\"\ndescription: \"四种方式开始创作，选一个最适合你的\"\n---\n\n在首页选择创建方式，填写内容，点击「**创建新项目**」。\n\n## 从想法开始（最简单）\n\n只需一句话描述主题，AI 自动完成大纲→描述→图片的全流程。\n\n**适合**：没有现成材料，想快速生成一份 PPT\n\n**步骤：**\n1. 选择「**一句话生成**」标签\n2. 输入主题，例如：`生成一份关于碳中和的汇报 PPT，8 页`\n3. 选择模板和画面比例（可选）\n4. 点击「**创建新项目**」\n\n之后会进入大纲编辑器，可以检查和修改 AI 生成的大纲。\n\n## 从大纲开始\n\n已有结构思路，不想让 AI 凭空猜测：\n\n**适合**：有现成大纲或目录，想让 AI 在你的框架内填充内容\n\n**步骤：**\n1. 选择「**从大纲生成**」标签\n2. 粘贴大纲内容，支持标题 + 要点格式，也可以只写标题\n3. 点击「**创建新项目**」，AI 自动切分为结构化大纲\n\n**格式示例：**\n```\n第一页：AI 的起源\n- 1956 年达特茅斯会议\n- 早期研究者的愿景\n\n第二页：机器学习的发展\n从规则驱动到数据驱动的转变\n```\n\n## 从描述开始\n\n已经有详细的每页描述，想跳过大纲步骤直接生图：\n\n**适合**：内容已经很明确，或从其他 AI 工具生成了完整描述\n\n**步骤：**\n1. 选择「**从描述生成**」标签\n2. 粘贴每页的完整描述（包含布局、配色、内容等）\n3. 点击「**创建新项目**」，直接进入图片生成\n\n**格式示例：**\n```\n第一页：封面\n简洁封面，公司 Logo 居中，标题白色粗体，深蓝渐变背景。\n\n第二页：市场概况\n展示过去 5 年市场增长柱状图，关键数据用高亮卡片突出。\n```\n\n## PPT 翻新\n\n上传已有 PDF 或 PPTX，AI 解析内容并重新生成风格焕新的版本：\n\n**适合**：老旧 PPT 需要视觉翻新，或想以已有 PPT 为基础重新创作\n\n**步骤：**\n1. 选择「**PPT 翻新**」标签\n2. 上传 PDF 或 PPTX 文件\n3. 可选勾选「**保留原始排版布局**」，让 AI 更贴近原版结构\n4. 点击「**创建新项目**」\n\n<Note>\n  推荐先在本地将 PPTX 转为 PDF 再上传。直接上传 PPTX 需要服务端 LibreOffice 转换，可能因缺少字体导致排版偏差。\n</Note>\n\n## 画面比例\n\n创建项目前，在输入区域下方选择画面比例：\n\n| 比例 | 适用场景 |\n|------|---------|\n| 16:9 | 标准宽屏（默认） |\n| 4:3 | 传统幻灯片 |\n| 21:9 | 超宽屏展示 |\n| 1:1 | 正方形（社交媒体） |\n| 9:16 | 竖屏（手机展示） |\n\n<Note>\n  部分图像生成模型不支持某些比例，若生成报错可尝试切换到 16:9。项目创建后也可在「项目设置」中修改，修改后需重新生成图片才生效。\n</Note>\n\n## 选择模板和风格\n\n创建前可以设定视觉风格，有两种方式：\n\n**方式一：选择模板**\n- 从预设模板库中点选一个，或上传自定义模板图片\n\n**方式二：文字描述风格**\n- 勾选「**使用文字描述风格**」\n- 在文本框中描述期望的视觉风格，例如：`现代科技风，深色背景，蓝紫渐变色调`\n\n## 上传参考文件\n\n创建时可以附加参考文件，AI 会提取内容作为创作素材：\n\n- **支持格式**：PDF、DOCX、PPTX、XLSX、CSV、TXT、MD\n- **上传方式**：点击回形针图标、直接拖拽、或粘贴（Ctrl+V）\n- **文件大小**：最大 200MB\n\n文件上传后会自动解析，解析完成前无法创建项目。详见[素材与文件](/zh/features/materials)。\n"
  },
  {
    "path": "docs/zh/features/descriptions.mdx",
    "content": "---\ntitle: \"完善描述\"\ndescription: \"为每页幻灯片生成或编写详细的视觉描述\"\n---\n\n描述编辑器是第三步。每页的「描述」告诉 AI 这张幻灯片画什么——包括布局、配色、图表、文字内容。描述越具体，生成效果越好。\n\n## 批量生成所有描述\n\n点击「**批量生成描述**」，AI 自动为每页生成描述，全部完成后可逐页查看和修改。\n\n**生成前可调整详细程度**（按钮旁边的下拉）：\n\n| 级别 | 适用场景 |\n|------|---------|\n| 精简 | 快速原型，速度优先 |\n| 默认 | 日常使用 |\n| 详细 | 精细打磨，效果优先 |\n\n<Tip>\n  描述中加入具体文字内容（如标题文案、数据），可以显著提升幻灯片上的文字渲染质量。\n</Tip>\n\n## 用 AI 批量修改描述\n\n顶部输入栏支持对所有页面的描述批量调整，按 **Ctrl+Enter** 提交。\n\n**常用指令示例：**\n- `让所有描述更详细，加入具体的文字内容`\n- `统一改成深蓝色科技风格`\n- `第 3 页的描述加上一个数据对比表格`\n- `删除所有描述中提到\"渐变背景\"的部分`\n\n## 单独重新生成某一页\n\n每张描述卡片右上角有「**重新生成**」按钮：\n\n1. 点击重新生成图标（🔄）\n2. 弹出确认框，确认后 AI 单独重新生成这一页的描述\n3. 生成完成后自动替换原内容\n\n## 手动编辑单页描述\n\n直接点击描述卡片中的文字区域，即可手动编辑该页描述，点击其他地方自动保存。\n\n## 设置描述生成要求\n\n操作栏展开「**描述生成要求**」，填写对描述的整体约束，生成时 AI 会遵守。\n\n**示例要求：**\n- `每页描述控制在 100 字以内`\n- `多使用数据和图表，少用纯文字`\n- `保持简洁的极简主义风格`\n\n## 为某页指定参考图片\n\n在描述卡片底部的编辑区域，可以上传该页的参考图片：\n\n1. 滚动到描述卡片底部，找到「**上传图片**」区域\n2. 上传或从素材库选择参考图片\n3. 填写修改指令（可选），点击「**生成图片**」\n\n## 导入 / 导出描述\n\n点击「**导入/导出**」：\n- **导出描述**：仅导出描述内容\n- **导出大纲和描述**：导出完整内容（含要点）\n- **导入**：从文件加载描述，追加到现有页面\n\n## 完成后\n\n所有页面有描述后，右上角「**下一步 →**」会激活，点击进入[幻灯片预览](/zh/features/images)，开始生成图片。\n"
  },
  {
    "path": "docs/zh/features/editing.mdx",
    "content": "---\ntitle: \"编辑幻灯片\"\ndescription: \"用自然语言修改演示文稿\"\n---\n\n## 自然语言编辑\n\n直接用口头描述修改，无需菜单操作：\n\n- \"把第三页改成案例分析\"\n- \"把这个图换成饼图\"\n- \"背景调暗一点\"\n- \"加一个要点列表\"\n\n## 大纲编辑\n\n在大纲编辑器中输入指令调整结构：\n\n- \"在引言后面加一节竞品分析\"\n- \"合并最后两页\"\n- \"把总结移到问答页前面\"\n\n也可以手动拖拽页面重新排序，或编辑章节归属。\n\n## 区域编辑\n\n在幻灯片预览中框选特定区域，描述修改内容。AI 只重新生成选中区域，其余部分保持不变。\n\n操作步骤：\n1. 进入幻灯片预览页，点击「区域选图」按钮\n2. 在幻灯片上拖拽框选要修改的区域\n3. 在输入框中描述修改内容\n4. 点击「生成图片」，AI 只重绘选中区域\n\n## 描述详细程度\n\n在详细编辑器的「批量生成描述」按钮旁，可选择描述的详细程度：\n\n| 级别 | 说明 |\n|------|------|\n| 精简 | 简洁描述，生成速度更快，适合快速原型 |\n| 默认 | 平衡详细程度与生成速度 |\n| 详细 | 更丰富的描述，AI 有更多构图参考，适合精细打磨 |\n\n## 历史版本\n\n每次重新生成幻灯片图片时，旧版本会自动保留。在预览页点击「历史版本」可查看和切换到任意历史版本，不必担心误操作覆盖满意的效果。\n"
  },
  {
    "path": "docs/zh/features/export.mdx",
    "content": "---\ntitle: \"导出选项\"\ndescription: \"导出为 PPTX、PDF 或图片\"\n---\n\n## 格式\n\n| 格式 | 说明 |\n|------|------|\n| PPTX | 幻灯片以图片形式嵌入 PPT，可在 PowerPoint 中播放和调整页面顺序，但图片内的文字无法直接编辑 |\n| 可编辑 PPTX（Beta） | 从图片中提取文字重建为原生文本框，可在 PowerPoint 中直接编辑文字内容 |\n| PDF | 像素级精确输出，可直接演示 |\n| 图片 | 每页导出为独立图片文件 |\n\n## 画面比例\n\n默认 16:9，也可在创建项目时或项目预览页的「项目设置」中更改。支持 16:9、4:3、21:9、1:1、9:16 等多种比例。比例变更后需重新生成图片才会生效。\n\n## 可编辑 PPTX（Beta）\n\n提取过程保留字号、颜色、加粗样式、文字定位和表格内容，但受限于 OCR 精度和当前模型能力，复杂排版可能存在偏差，与原图效果有出入。\n\n<Note>\n  配置 `BAIDU_API_KEY` 可获得最佳提取效果，详见[配置说明](/zh/configuration#百度-api-key)。\n</Note>\n\n## 选页导出\n\n可选择特定页面导出，无需导出整个演示文稿。在幻灯片预览页开启「多选」，勾选要导出的页面后点击「导出」即可。\n"
  },
  {
    "path": "docs/zh/features/images.mdx",
    "content": "---\ntitle: \"生成与精修图片\"\ndescription: \"生成幻灯片图片，并对单页进行精细调整\"\n---\n\n幻灯片预览是最后一个编辑步骤。在这里生成图片、精修细节、查看历史版本，最后导出。\n\n## 批量生成所有图片\n\n点击「**批量生成图片**」，AI 并行生成所有页面的图片。\n\n生成时每张卡片显示进度动画，完成后立即预览。\n\n<Note>\n  如果当前分辨率为 1K，系统会弹出提示建议切换到 2K 或 4K，文字清晰度差异明显。在「项目设置 → 全局设置」中调整。\n</Note>\n\n## 单页精修\n\n对某张幻灯片不满意时，无需重新生成全部：\n\n1. 点击幻灯片卡片上的「**编辑**」图标（铅笔）\n2. 在右侧面板修改**页面描述**或填写**修改指令**\n3. 点击「**生成图片**」，AI 只重新生成这一页\n\n**修改指令示例：**\n- `把背景改成深蓝色渐变`\n- `标题字体加大，移到画面上方`\n- `右侧换成柱状图，数据不变`\n- `整体风格改成手绘插画风`\n\n<Tip>\n  如果只想保存描述的修改而不重新生图，点击「**仅保存大纲/描述**」。\n</Tip>\n\n## 区域编辑：只改幻灯片的某个部分\n\n不想改整页，只想调整某个局部区域：\n\n1. 进入单页编辑模式后，点击左侧图片上方的「**区域选图**」\n2. 在幻灯片预览上**拖拽框选**要修改的区域\n3. 选中区域会被裁剪并添加为参考图\n4. 在修改指令中描述要做什么，点击「**生成图片**」\n\nAI 会重绘整张幻灯片，但以选中区域的内容作为重点参考。\n\n## 查看和恢复历史版本\n\n每次重新生成，旧版本自动保留，不会丢失。\n\n1. 进入单页编辑模式\n2. 图片左下角点击「**历史版本**」\n3. 选择任意历史版本，点击切换\n\n<Tip>\n  大胆尝试不同提示词，不满意随时回到之前版本。\n</Tip>\n\n## 多选批量生成\n\n只想重新生成其中几页：\n\n1. 点击顶部「**多选**」\n2. 勾选要重新生成的页面\n3. 点击「**生成选中页面 (N)**」\n\n## 项目设置\n\n点击右上角齿轮图标「**项目设置**」，进入三个配置标签：\n\n### 项目设置标签\n\n| 配置项 | 说明 |\n|--------|------|\n| 画面比例 | 修改后重新生成图片才生效 |\n| 额外要求 | 所有图片生成都会遵守，如\"避免使用真人照片\" |\n| 风格描述 | 视觉风格约束，如\"极简主义、白色背景、细线条\" |\n| 更换模板 | 切换参考模板，只影响后续新生成的页面 |\n\n### 导出设置标签\n\n配置「**可编辑 PPTX**」的提取方式：\n- **文本提取方法**：OCR / 视觉模型 / 混合（效果和速度各有权衡）\n- **图像修复方法**：像素级 / AI / 混合\n- **错误处理**：开启「返回半成品」可在报错时仍尝试输出结果\n\n### 全局设置标签\n\n修改 API 提供商、模型名称、图像分辨率（1K / 2K / 4K）、并发数等。也可以在这里运行**服务测试**验证配置是否正常。\n"
  },
  {
    "path": "docs/zh/features/import-export.mdx",
    "content": "---\ntitle: \"导入导出格式\"\ndescription: \"大纲和描述的 Markdown 导入导出格式说明\"\n---\n\nBanana Slides 使用 Markdown 文件（`.md` / `.txt`）导入导出大纲和页面描述，格式可读性强，可用任意文本编辑器编辑。\n\n## 格式结构\n\n```markdown\n# 项目标题\n\n> 生成时间: 2025/1/1 12:00:00\n\n---\n\n## 第 1 页: 封面\n\n> 章节: 引言\n\n**大纲要点：**\n- 公司名称和 Logo\n- 演示日期\n\n**页面描述：**\n简洁的封面页，公司 Logo 居中，标题使用白色粗体字，深蓝渐变背景。\n\n---\n\n## 第 2 页: 市场概况\n\n> 章节: 分析\n\n**大纲要点：**\n- 行业增长趋势\n- 主要市场细分\n\n**页面描述：**\n数据驱动的页面，展示过去 5 年市场增长柱状图，关键数据用高亮卡片突出。\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- `**大纲要点：**` 和 `**页面描述：**` 标记可省略，省略时列表行（`- ...`）自动识别为要点。\n- 导入内容中的 HTML 标签会被自动清除。\n- 支持 `.md` 和 `.txt` 文件。\n\n## 最简示例\n\n一个有效的导入文件可以很简单：\n\n```markdown\n## 第 1 页: 开场介绍\n- 欢迎与议程\n- 演讲者介绍\n\n## 第 2 页: 总结\n- 核心要点回顾\n```\n\n## 用 AI 生成\n\n将下面的提示词复制到任意 AI 助手（ChatGPT、Claude、Kimi 等），替换占位符，输出结果可直接导入 Banana Slides。\n\n<Tip>\n  如果只需要大纲（不含描述），从提示词中删除 `**页面描述：**` 相关要求即可。\n</Tip>\n\n<Accordion title=\"AI 提示词模板\">\n\n```text\n请按以下 Markdown 格式生成一份演示文稿大纲。\n\n主题：[你的主题]\n页数：[数量，如 8]\n语言：[中文 / 英文]\n风格备注：[如\"专业数据风\"或\"活泼插画风\"]\n\n格式要求：\n1. 每页以 \"## 第 N 页: 标题\" 开头\n2. 用 \"> 章节: 章节名\" 对页面分组\n3. 在 \"**大纲要点：**\" 下用 \"- 要点\" 列出 2-4 个要点\n4. 在 \"**页面描述：**\" 下用 1-2 句话描述该页的视觉布局、配色、图表或画面\n5. 页面之间用 \"---\" 分隔\n6. 第一页为封面，最后一页为结束/致谢页\n\n只输出 Markdown，不要额外解释。\n```\n\n</Accordion>\n"
  },
  {
    "path": "docs/zh/features/materials.mdx",
    "content": "---\ntitle: \"素材与文件\"\ndescription: \"上传参考文件、粘贴图片，或用 AI 生成专属素材\"\n---\n\n## 上传参考文件\n\n上传文档后，AI 在生成大纲、描述和图片时会参考其中的内容。\n\n### 支持的格式\n\n| 格式 | 提取内容 |\n|------|---------|\n| PDF | 文本 + 嵌入图片 |\n| DOCX / DOC | 文档内容和结构 |\n| PPTX / PPT | 幻灯片内容 |\n| XLSX / XLS / CSV | 表格数据 |\n| Markdown / TXT | 纯文本 |\n\n<Tip>\n  PDF 解析使用 [MinerU](https://mineru.net) 提供高质量内容提取。配置 MinerU Token 后效果更好，详见[配置说明](/zh/configuration#mineru-pdf-解析)。\n</Tip>\n\n### 上传方式\n\n- 点击输入区域旁的**回形针图标**，打开文件选择器\n- 直接**拖拽文件**到输入区域\n- **粘贴**（Ctrl+V）文件或图片\n- 点击「**素材中心**」从历史上传的文件中选择\n\n文件大小上限：**200MB**。上传后自动开始解析，解析期间显示「解析中」状态。\n\n### 在哪里上传\n\n| 位置 | 说明 |\n|------|------|\n| 首页 | 创建项目前上传，作为整个项目的参考素材 |\n| 大纲编辑器 | 左侧面板可管理已上传的文件 |\n| 描述编辑器 | 顶部区域可查看和管理参考文件 |\n| 幻灯片预览（单页编辑） | 可为单独一页指定参考图片 |\n\n## 粘贴图片\n\n在首页、大纲编辑器或描述卡片中，直接粘贴图片（Ctrl+V）：\n\n1. 复制图片（截图、从网页复制等）\n2. 在输入框中按 **Ctrl+V**\n3. 图片自动上传，AI 识别内容后以 Markdown 格式插入到光标位置\n\n识别出的图片内容会作为上下文参考。\n\n## 风格参考图\n\n上传图片作为视觉风格参考，AI 会匹配其配色、布局和设计语言。\n\n在首页「选择风格模板」区域或单页编辑的「上传图片」中上传参考图。\n\n## 素材生成工具\n\n点击顶部导航栏的「**素材生成**」，用 AI 生成自定义图片：\n\n1. 在提示词框中描述想要的图片，例如：`蓝紫渐变背景，带几何图形和科技感线条`\n2. 选择画面比例\n3. 可选上传参考图引导风格\n4. 点击「**生成素材**」\n\n生成完成后自动保存到素材库，可在项目中复用。\n\n## 素材中心\n\n点击顶部导航栏的「**素材中心**」，查看和管理所有历史素材：\n\n- **筛选**：按项目筛选，或查看全部/未关联项目的素材\n- **预览**：点击眼睛图标全屏预览\n- **下载**：选中后点击下载，多个文件自动打包为 ZIP\n- **删除**：单个删除或批量删除\n- **上传**：直接在素材中心上传新图片\n"
  },
  {
    "path": "docs/zh/features/outline.mdx",
    "content": "---\ntitle: \"编辑大纲\"\ndescription: \"检查、调整 AI 生成的大纲，或从头手动搭建\"\n---\n\n大纲编辑器是第二步。每张幻灯片对应一张卡片，卡片包含**标题**和**要点**，不涉及视觉效果。\n\n## 用 AI 修改大纲\n\n顶部输入栏是最高效的大纲编辑方式。用自然语言告诉 AI 要做什么，按 **Ctrl+Enter** 提交。\n\n**常用指令示例：**\n- `在引言后面加一节竞品分析`\n- `合并第 4、5 页，保留关键要点`\n- `把总结移到问答页前面`\n- `第 2 页标题改为\"核心优势\"`\n- `删除第 6 页`\n\n<Tip>\n  可以连续发多条指令，AI 会记住上下文。比如先说\"加一节案例分析\"，再说\"把它移到第三页\"。\n</Tip>\n\n## 手动编辑页面\n\n点击任意卡片可以直接编辑标题和要点：\n\n1. 点击卡片上的标题或要点文字\n2. 直接修改内容\n3. 点击其他地方自动保存\n\n每张卡片右上角有**删除**按钮，点击后需确认。\n\n## 添加新页面\n\n点击操作栏的「**添加页面**」，在列表末尾插入一张空白页。\n\n## 拖拽调整顺序\n\n拖住卡片左侧的拖拽把手（⠿ 图标），上下拖动到目标位置后松手，顺序立即生效。\n\n## 重新生成大纲\n\n如果对当前大纲不满意，点击「**重新生成大纲**」让 AI 重新生成。\n\n<Warning>\n  重新生成会覆盖所有现有大纲内容，操作前会弹出确认框。已有内容无法恢复。\n</Warning>\n\n## 设置生成要求\n\n在操作栏展开「**大纲生成要求**」，填写对大纲的整体要求，之后每次生成/重新生成都会遵守。\n\n**示例要求：**\n- `每页要点不超过 4 条`\n- `使用时间线结构，按年份排列`\n- `重点突出数据和案例`\n\n## 导入 / 导出大纲\n\n点击「**导入/导出**」按钮：\n\n- **导出大纲**：将当前大纲保存为 `.md` 文件，可用文本编辑器修改后再导入\n- **导入**：从 `.md` 或 `.txt` 文件加载大纲，**追加**到现有页面末尾\n\n导入的格式要求见[导入导出格式](/zh/features/import-export)。\n\n## 完成后\n\n大纲确认后，点击右上角「**下一步 →**」进入[描述编辑器](/zh/features/descriptions)。\n"
  },
  {
    "path": "docs/zh/features/overview.mdx",
    "content": "---\ntitle: \"功能总览\"\ndescription: \"Banana Slides 能做什么\"\n---\n\n## 完整工作流程\n\n```\n创建项目 → 编辑大纲 → 完善描述 → 生成图片 → 导出\n```\n\n每步都可以用 AI 辅助，也可以手动精细调整。\n\n## 四种创作路径\n\n| 路径 | 适合场景 |\n|------|---------|\n| 从想法开始 | 只有主题，让 AI 全部生成 |\n| 从大纲开始 | 有现成目录结构 |\n| 从描述开始 | 每页内容已很明确，直接生图 |\n| PPT 翻新 | 上传旧 PPT，AI 重新生成新版 |\n\n详见[创建演示文稿](/zh/features/creation)。\n\n## AI 辅助编辑\n\n每个编辑步骤都有顶部 AI 输入栏，用自然语言告诉 AI 要做什么：\n\n- **大纲编辑器**：`加一节竞品分析，放在第三页后面`\n- **描述编辑器**：`让所有描述更详细，加入具体文字内容`\n- **幻灯片预览**（单页）：`把背景改成深色，标题字号加大`\n\n详见[编辑大纲](/zh/features/outline)、[完善描述](/zh/features/descriptions)、[生成与精修图片](/zh/features/images)。\n\n## 素材支持\n\n- 上传参考文件（PDF、DOCX、PPTX、表格等），AI 提取内容辅助生成\n- 粘贴图片，AI 识别内容后插入\n- 用「素材生成」工具 AI 生成专属背景图或插图\n- 「素材中心」管理所有历史素材，方便复用\n\n详见[素材与文件](/zh/features/materials)。\n\n## 导出\n\n- **PPTX**：图片嵌入格式，可播放，可调整顺序\n- **可编辑 PPTX（Beta）**：提取文字为原生文本框，可直接在 PowerPoint 编辑\n- **PDF**：像素级精确输出\n- **图片**：每页单独导出\n\n支持多种画面比例，支持选页导出。详见[导出选项](/zh/features/export)。\n"
  },
  {
    "path": "docs/zh/history.mdx",
    "content": "---\ntitle: \"管理项目\"\ndescription: \"查看、重命名和删除历史项目\"\n---\n\n点击顶部导航栏的「**历史项目**」，查看所有已创建的演示文稿。\n\n## 继续编辑项目\n\n点击任意项目卡片，直接跳转到该项目的编辑页面继续工作。\n\n## 重命名项目\n\n将鼠标悬停在项目标题上，出现编辑图标后点击，直接修改标题，按 **Ctrl+Enter** 保存，按 **Esc** 取消。\n\n## 删除项目\n\n- **删除单个**：点击卡片上的删除图标，确认后删除\n- **批量删除**：勾选多个项目，点击「**批量删除**」\n\n<Warning>\n  删除操作不可恢复，包括所有页面、描述和生成的图片。\n</Warning>\n"
  },
  {
    "path": "docs/zh/index.mdx",
    "content": "---\ntitle: \"Banana Slides\"\ndescription: \"AI 原生演示文稿生成 — Vibe your slides like vibe coding\"\n---\n\nBanana Slides 是一款 AI 原生 PPT 生成应用。输入想法或上传已有文件，AI 自动生成大纲、描述和幻灯片图片，全程可用自然语言精修。\n\n## 快速开始\n\n<CardGroup cols={2}>\n  <Card title=\"第一次使用\" icon=\"rocket\" href=\"/zh/quickstart\">\n    5 分钟部署并生成第一份 PPT\n  </Card>\n  <Card title=\"配置 API\" icon=\"key\" href=\"/zh/configuration\">\n    配置 AI 服务商和 API 密钥\n  </Card>\n</CardGroup>\n\n## 核心功能\n\n<CardGroup cols={2}>\n  <Card title=\"创建演示文稿\" icon=\"plus\" href=\"/zh/features/creation\">\n    四种创作路径：从想法、大纲、描述，或翻新已有 PPT\n  </Card>\n  <Card title=\"编辑大纲\" icon=\"list\" href=\"/zh/features/outline\">\n    用自然语言调整结构，或手动拖拽排序\n  </Card>\n  <Card title=\"完善描述\" icon=\"pencil\" href=\"/zh/features/descriptions\">\n    批量生成描述，用 AI 口头修改，手动精调\n  </Card>\n  <Card title=\"生成与精修图片\" icon=\"image\" href=\"/zh/features/images\">\n    批量生图、单页精修、区域编辑、历史版本\n  </Card>\n  <Card title=\"素材与文件\" icon=\"paperclip\" href=\"/zh/features/materials\">\n    上传参考文件，AI 生成素材，管理素材库\n  </Card>\n  <Card title=\"导出\" icon=\"download\" href=\"/zh/features/export\">\n    导出为 PPTX、PDF 或图片，支持多种比例\n  </Card>\n</CardGroup>\n"
  },
  {
    "path": "docs/zh/quickstart.mdx",
    "content": "---\ntitle: \"快速开始\"\ndescription: \"立即体验或在本地部署 Banana Slides\"\n---\n\n## 在线 Demo\n\n无需安装，直接体验：[bananaslides.online](https://bananaslides.online/)\n\n---\n\n## 自托管部署\n\n### 第一步：安装 Docker\n\n<Tabs>\n  <Tab title=\"Windows / macOS\">\n    下载并安装 [Docker Desktop](https://www.docker.com/products/docker-desktop/)，安装完成后启动它，确保系统托盘（Windows）或菜单栏（macOS）中出现 Docker 图标。\n  </Tab>\n  <Tab title=\"Linux\">\n    ```bash\n    curl -fsSL https://get.docker.com | sh\n    ```\n  </Tab>\n</Tabs>\n\n### 第二步：一键下载并启动\n\n新建一个目录，进入后执行以下命令，自动下载配置文件并启动服务：\n\n```bash\nmkdir banana-slides && cd banana-slides && \\\ncurl -O https://raw.githubusercontent.com/Anionex/banana-slides/main/docker-compose.prod.yml && \\\ncurl -O https://raw.githubusercontent.com/Anionex/banana-slides/main/.env.example && \\\ncp .env.example .env\n```\n\n然后编辑 `.env` 文件，填入你的 API 密钥（详见[配置说明](/zh/configuration)），再启动：\n\n```bash\ndocker compose -f docker-compose.prod.yml up -d\n```\n\n<Tip>\n  推荐使用 [AIHubMix](https://aihubmix.com/?aff=17EC) 获取 API 密钥，同时支持 Gemini 和 OpenAI 格式，且能稳定进行高并发生图。\n</Tip>\n\n### 第三步：访问应用\n\n浏览器打开 [http://localhost:3000](http://localhost:3000)，即可开始使用。\n\n<Note>\n  默认端口为 3000（前端）和 5000（后端）。如需修改，在 `.env` 中设置 `FRONTEND_PORT` 和 `BACKEND_PORT`。\n</Note>\n\n---\n\n## 更新\n\n```bash\ndocker compose -f docker-compose.prod.yml pull && \\\ndocker compose -f docker-compose.prod.yml up -d\n```\n\n## 查看日志\n\n```bash\ndocker logs -f --tail 100 banana-slides-backend\n```\n\n---\n\n## 从源码部署\n\n<Accordion title=\"展开查看源码部署步骤\">\n\n### 环境要求\n\n- Python 3.10+\n- [uv](https://github.com/astral-sh/uv) 包管理器\n- Node.js 16+ 和 npm\n\n### 后端\n\n```bash\ngit clone https://github.com/Anionex/banana-slides\ncd banana-slides\ncp .env.example .env  # 编辑 .env 填入 API 密钥\ncd backend\nuv run alembic upgrade head && uv run python app.py\n```\n\n### 前端\n\n```bash\ncd frontend\nnpm install\nnpm run dev\n```\n\n</Accordion>\n"
  },
  {
    "path": "frontend/.eslintrc.cjs",
    "content": "module.exports = {\n  root: true,\n  env: { browser: true, es2020: true },\n  extends: [\n    'eslint:recommended',\n    'plugin:@typescript-eslint/recommended',\n    'plugin:react-hooks/recommended',\n  ],\n  ignorePatterns: ['dist', '.eslintrc.cjs'],\n  parser: '@typescript-eslint/parser',\n  plugins: ['react-refresh'],\n  rules: {\n    'react-refresh/only-export-components': 'off',\n    'react-hooks/exhaustive-deps': 'off',\n    '@typescript-eslint/no-explicit-any': 'off',\n    '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],\n  },\n}\n\n"
  },
  {
    "path": "frontend/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n# Environment variables\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\n# E2E testing (Playwright)\ntest-results/\nplaywright-report/\nplaywright/.cache/\n*.trace.zip\n"
  },
  {
    "path": "frontend/Dockerfile",
    "content": "# 镜像源配置参数（可通过 build args 覆盖）\nARG DOCKER_REGISTRY=\nARG NPM_REGISTRY=\n\n# 构建阶段\n# 如果指定了 DOCKER_REGISTRY，使用镜像源；否则使用官方源\nFROM ${DOCKER_REGISTRY:-}node:18-alpine AS builder\n\n# 重新声明ARG（FROM之后ARG作用域失效，需要重新声明）\nARG NPM_REGISTRY=\n\nWORKDIR /app\n\n# 复制 package.json\nCOPY frontend/package.json ./\n\n# 安装依赖（如果配置了 NPM_REGISTRY，先设置镜像源）\nCOPY frontend/package-lock.json* ./\nRUN if [ -n \"$NPM_REGISTRY\" ]; then \\\n        npm config set registry \"$NPM_REGISTRY\"; \\\n    fi && \\\n    (npm install --frozen-lockfile || npm install)\n\n# 复制前端源代码\nCOPY frontend/ ./\n\n# 构建应用\nRUN npm run build\n\n# 生产阶段\n# 如果指定了 DOCKER_REGISTRY，使用镜像源；否则使用官方源\nFROM ${DOCKER_REGISTRY:-}nginx:alpine\n\n# 复制构建产物到 nginx\nCOPY --from=builder /app/dist /usr/share/nginx/html\n\n# 复制 nginx 配置文件\nCOPY frontend/nginx.conf /etc/nginx/conf.d/default.conf\n\n# 暴露端口\nEXPOSE 80\n\n# 启动 nginx\nCMD [\"nginx\", \"-g\", \"daemon off;\"]\n\n"
  },
  {
    "path": "frontend/README.md",
    "content": "# 蕉幻 (Banana Slides) 前端\n\n这是蕉幻 AI PPT 生成器的前端应用。\n\n## 技术栈\n\n- **框架**: React 18 + TypeScript\n- **构建工具**: Vite\n- **状态管理**: Zustand\n- **样式**: TailwindCSS\n- **路由**: React Router\n- **拖拽**: @dnd-kit\n- **图标**: Lucide React\n\n## 开始开发\n\n### 1. 安装依赖\n\n```bash\nnpm install\n```\n\n### 2. 配置环境变量\n\n**注意**：现在不再需要配置 `VITE_API_BASE_URL`！\n\n前端使用相对路径，通过代理自动转发到后端：\n- **开发环境**：通过 Vite proxy 自动转发到后端\n- **生产环境**：通过 nginx proxy 自动转发到后端服务\n\n**一键修改后端端口**：\n只需在项目根目录的 `.env` 文件中修改 `BACKEND_PORT` 环境变量（默认 5000），前端和后端都会自动使用新端口：\n\n```env\nBACKEND_PORT=8080  # 修改为 8080 或其他端口\n```\n\n这样无论后端运行在什么地址（localhost、IP 或域名），前端都能自动适配，无需手动配置。\n\n### 3. 启动开发服务器\n\n```bash\nnpm run dev\n```\n\n应用将在 http://localhost:3000 启动\n\n### 4. 构建生产版本\n\n```bash\nnpm run build\n```\n\n## 项目结构\n\n```\nsrc/\n├── api/              # API 封装\n│   ├── client.ts     # Axios 实例配置\n│   └── endpoints.ts  # API 端点\n├── components/       # 组件\n│   ├── shared/       # 通用组件\n│   ├── outline/      # 大纲编辑组件\n│   └── preview/      # 预览组件\n├── pages/            # 页面\n│   ├── Home.tsx      # 首页\n│   ├── OutlineEditor.tsx    # 大纲编辑页\n│   ├── DetailEditor.tsx     # 详细描述编辑页\n│   └── SlidePreview.tsx     # 预览页\n├── store/            # 状态管理\n│   └── useProjectStore.ts\n├── types/            # TypeScript 类型\n│   └── index.ts\n├── utils/            # 工具函数\n│   └── index.ts\n├── App.tsx           # 应用入口\n├── main.tsx          # React 挂载点\n└── index.css         # 全局样式\n```\n\n## 主要功能\n\n### 1. 首页 (/)\n- 三种创建方式：一句话生成、从大纲生成、从描述生成\n- 风格模板选择和上传\n\n### 2. 大纲编辑页 (/project/:id/outline)\n- 拖拽排序页面\n- 编辑大纲内容\n- 自动生成大纲\n\n### 3. 详细描述编辑页 (/project/:id/detail)\n- 批量生成页面描述\n- 编辑单页描述\n- 网格展示所有页面\n\n### 4. 预览页 (/project/:id/preview)\n- 查看生成的图片\n- 编辑单页（自然语言修改）\n- 导出为 PPTX/PDF\n\n## 开发注意事项\n\n### 状态管理\n- 使用 Zustand 进行全局状态管理\n- 关键状态会同步到 localStorage\n- 页面刷新后自动恢复项目\n\n### 异步任务\n- 使用轮询机制监控长时间任务\n- 显示实时进度\n- 完成后自动刷新数据\n\n### 图片处理\n- 所有图片路径需通过 `getImageUrl()` 处理\n- 支持相对路径和绝对路径\n\n### 拖拽功能\n- 使用 @dnd-kit 实现\n- 支持键盘操作\n- 乐观更新 UI\n\n## 与后端集成\n\n确保后端服务运行在配置的端口（默认 5000）：\n\n```bash\ncd ../backend\npython app.py\n```\n\n## 浏览器支持\n\n- Chrome (推荐)\n- Firefox\n- Safari\n- Edge\n\n"
  },
  {
    "path": "frontend/e2e/README.md",
    "content": "# E2E 测试说明\n\n## 📋 测试策略\n\n本项目采用**单一真正的 E2E 测试**策略，避免\"伪 E2E\"测试造成混淆。\n\n### 测试金字塔\n\n```\n        ┌──────────────────┐\n        │   E2E 测试        │  ← 少量，测试完整流程，需要真实 API\n        │  (api-full-flow)  │\n        └──────────────────┘\n              ▲\n              │\n      ┌───────────────────┐\n      │   集成测试         │  ← 中等，测试 API 端点，使用 mock\n      │  (backend/tests/)  │\n      └───────────────────┘\n            ▲\n            │\n    ┌─────────────────────┐\n    │   单元测试           │  ← 大量，快速，独立\n    │ (前端 + 后端)        │\n    └─────────────────────┘\n```\n\n---\n\n## 🎯 E2E 测试文件\n\n### 1. **api-full-flow.spec.ts** ⭐ 主要 E2E 测试\n\n**特点**：\n- ✅ 真正的端到端测试（完整流程）\n- ✅ 使用真实的 AI API（Google Gemini）\n- ✅ 测试从创建到导出的完整链路\n- ✅ 在 CI 中自动运行（如果配置了 API key）\n\n**测试流程**：\n```\n1. 创建项目（从想法/大纲/描述）\n   ↓\n2. 等待 AI 生成大纲\n   ↓\n3. 生成页面描述\n   ↓\n4. 生成页面图片\n   ↓\n5. 导出 PPT 文件\n```\n\n**运行条件**：\n- ⚠️ 需要真实的 `GOOGLE_API_KEY`\n- ⚠️ 需要约 10-15 分钟\n- ⚠️ 会消耗 API 配额（约 $0.01-0.05/次）\n\n**本地运行**：\n```bash\n# 1. 确保 .env 中配置了真实的 GOOGLE_API_KEY\n# 2. 启动服务\ndocker compose up -d\n\n# 3. 等待服务就绪（使用智能等待脚本）\n./scripts/wait-for-health.sh http://localhost:5000/health 60 2\n./scripts/wait-for-health.sh http://localhost:3000 60 2\n\n# 4. 运行测试\nnpx playwright test api-full-flow.spec.ts --workers=1\n```\n\n**CI 运行**：\n- 自动运行：在 `docker-test` job 中\n- 条件：`GOOGLE_API_KEY` 已在 GitHub Secrets 中配置\n- 跳过：如果没有配置 API key，会跳过并显示说明\n\n---\n\n### 2. **ui-full-flow.spec.ts** 🎨 UI 驱动的完整测试\n\n**特点**：\n- ✅ 从浏览器 UI 开始操作（模拟真实用户）\n- ✅ 测试完整的用户交互流程\n- ✅ 需要真实的 AI API（Google Gemini）\n- ⚠️ 运行时间更长（15-20 分钟）\n- ✅ 在 CI 中自动运行（如果有 API key）\n\n**用途**：\n- 发布前的最终验证\n- 验证真实用户体验\n- CI/CD 完整流程测试\n\n**本地运行**：\n```bash\n# 1. 确保 .env 中配置了真实的 GOOGLE_API_KEY\n# 2. 启动服务\ndocker compose up -d\n\n# 3. 等待服务就绪\n./scripts/wait-for-health.sh http://localhost:5000/health 60 2\n./scripts/wait-for-health.sh http://localhost:3000 60 2\n\n# 4. 运行测试\nnpx playwright test ui-full-flow.spec.ts --workers=1\n```\n\n**CI 运行**：\n- 自动运行：在 `docker-test` job 中\n- 条件：`GOOGLE_API_KEY` 已在 GitHub Secrets 中配置\n- 跳过：如果没有配置 API key 或是 Fork PR，会跳过并显示说明\n\n---\n\n## 🚫 已删除的测试\n\n以下测试文件已被删除（避免混淆）：\n\n- ~~`home.spec.ts`~~ - 基础 UI 测试（不是真正的 E2E）\n- ~~`create-ppt.spec.ts`~~ - API 集成测试（不是真正的 E2E）\n\n**原因**：\n- 它们不调用真实 AI API，不是真正的端到端测试\n- 测试的内容已被其他测试覆盖：\n  - UI 交互 → 前端单元测试\n  - API 端点 → 后端集成测试\n  - 完整流程 → `api-full-flow.spec.ts`\n\n---\n\n## 🔧 CI 配置\n\n### 在 GitHub Actions 中的运行逻辑\n\n```yaml\n# .github/workflows/ci-test.yml\n\ndocker-test job:\n  ├─ 构建 Docker 镜像\n  ├─ 启动服务\n  ├─ 健康检查\n  ├─ Docker 环境测试\n  └─ E2E 测试 (api-full-flow.spec.ts)\n      ├─ 如果有 GOOGLE_API_KEY → 运行完整 E2E\n      └─ 如果没有 API key → 跳过，显示说明\n```\n\n### 配置 GitHub Secrets\n\n要在 CI 中运行 E2E 测试，需要配置：\n\n1. 进入仓库 → **Settings** → **Secrets and variables** → **Actions**\n2. 添加 Secret：\n   - Name: `GOOGLE_API_KEY`\n   - Value: 你的 Google Gemini API 密钥\n   - 获取地址：https://aistudio.google.com/app/apikey\n\n### 如果没有配置 API key\n\nCI 会跳过 E2E 测试，并显示：\n\n```\n⚠️  Skipping E2E tests\n\nReason: GOOGLE_API_KEY not configured or using mock key\n\nNote: Other tests already passed:\n  ✅ Backend unit tests\n  ✅ Backend integration tests (with mock AI)\n  ✅ Frontend unit tests\n  ✅ Docker environment tests\n\nE2E tests require a real Google API key to test the complete AI generation workflow.\n```\n\n**这是正常的！** 其他测试已经覆盖了大部分功能。\n\n---\n\n## 📊 测试覆盖范围\n\n| 测试层级 | 测试内容 | 需要真实 API | 运行时间 | CI 运行 |\n|---------|---------|-------------|---------|---------|\n| **前端单元测试** | React 组件、hooks、工具函数 | ❌ | < 1 分钟 | ✅ 总是 |\n| **后端单元测试** | Services、Utils、Models | ❌ | < 2 分钟 | ✅ 总是 |\n| **后端集成测试** | API 端点（mock AI） | ❌ | < 3 分钟 | ✅ 总是 |\n| **Docker 环境测试** | 容器启动、健康检查 | ❌ | < 5 分钟 | ✅ 总是 |\n| **E2E 测试** | 完整 AI 生成流程 | ✅ | 10-15 分钟 | ⚠️ 有 API key 时 |\n\n---\n\n## 🎯 最佳实践\n\n### 开发时\n\n1. **日常开发**：运行单元测试和集成测试\n   ```bash\n   # 后端\n   cd backend && uv run pytest tests/\n   \n   # 前端\n   cd frontend && npm test\n   ```\n\n2. **提交 PR 前**：确保 CI 的所有测试通过\n   - Light Check（自动运行）\n   - Full Test（添加 `ready-for-test` 标签触发）\n\n3. **大功能完成后**：本地运行一次 E2E 测试\n   ```bash\n   # 确保 .env 配置了真实 API key\n   npx playwright test api-full-flow.spec.ts\n   ```\n\n### 发布前\n\n1. **最终验证**：运行完整的 UI E2E 测试\n   ```bash\n   npx playwright test ui-full-flow.spec.ts\n   ```\n\n2. **检查 CI**：确保所有测试（包括 E2E）都通过\n\n---\n\n## 🐛 调试失败的测试\n\n### 查看测试报告\n\n```bash\n# 运行测试后，打开 HTML 报告\nnpx playwright show-report\n```\n\n### 查看失败截图和视频\n\n测试失败时，Playwright 会自动保存：\n- 截图：`test-results/**/test-failed-*.png`\n- 视频：`test-results/**/video.webm`\n- 追踪：`test-results/**/trace.zip`\n\n### 查看追踪\n\n```bash\nnpx playwright show-trace test-results/**/trace.zip\n```\n\n### UI 模式调试\n\n```bash\n# 在 UI 模式下运行测试（可以看到浏览器操作过程）\nnpx playwright test --ui\n```\n\n---\n\n## 📚 相关文档\n\n- [Playwright 文档](https://playwright.dev)\n- [CI 配置说明](../.github/CI_SETUP.md)\n- [项目 README](../README.md)\n\n---\n\n**最后更新**: 2025-12-22  \n**测试策略**: 单一真正的 E2E 测试\n"
  },
  {
    "path": "frontend/e2e/access-code.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\n\n// ===== Mock Tests =====\n\ntest.describe('Access Code Guard (mocked)', () => {\n  test('shows app directly when access code is disabled', async ({ page }) => {\n    await page.route('**/api/access-code/check', route =>\n      route.fulfill({ json: { data: { enabled: false } } })\n    );\n    await page.goto('/');\n    // Verify app loaded (no access code prompt)\n    await expect(page.getByText('请输入访问口令')).not.toBeVisible({ timeout: 5000 });\n  });\n\n  test('shows access code prompt when enabled and no saved code', async ({ page }) => {\n    await page.route('**/api/access-code/check', route =>\n      route.fulfill({ json: { data: { enabled: true } } })\n    );\n    await page.goto('/');\n    await expect(page.getByText('请输入访问口令')).toBeVisible({ timeout: 10000 });\n    await expect(page.locator('input[type=\"password\"]')).toBeVisible();\n  });\n\n  test('grants access after correct code submission', async ({ page }) => {\n    await page.route('**/api/access-code/check', route =>\n      route.fulfill({ json: { data: { enabled: true } } })\n    );\n    await page.route('**/api/access-code/verify', route =>\n      route.fulfill({ json: { data: { valid: true } } })\n    );\n    await page.goto('/');\n    await page.locator('input[type=\"password\"]').fill('test123');\n    await page.getByRole('button', { name: '确认' }).click();\n    await expect(page.getByText('请输入访问口令')).not.toBeVisible({ timeout: 10000 });\n  });\n\n  test('shows error on wrong code', async ({ page }) => {\n    await page.route('**/api/access-code/check', route =>\n      route.fulfill({ json: { data: { enabled: true } } })\n    );\n    await page.route('**/api/access-code/verify', route =>\n      route.fulfill({ status: 403, json: { error: 'Invalid access code' } })\n    );\n    await page.goto('/');\n    await page.locator('input[type=\"password\"]').fill('wrong');\n    await page.getByRole('button', { name: '确认' }).click();\n    await expect(page.getByText('口令错误')).toBeVisible({ timeout: 5000 });\n  });\n\n  test('shows connection error with retry when backend is unreachable', async ({ page }) => {\n    await page.route('**/api/access-code/check', route =>\n      route.abort('connectionrefused')\n    );\n    await page.goto('/');\n    await expect(page.getByText('无法连接到后端服务')).toBeVisible({ timeout: 10000 });\n    await expect(page.getByText('请检查后端服务是否正常运行')).toBeVisible();\n    await expect(page.getByRole('button', { name: '重试' })).toBeVisible();\n    // No access code input should be shown\n    await expect(page.locator('input[type=\"password\"]')).not.toBeVisible();\n  });\n\n  test('retry button re-checks access after connection error', async ({ page }) => {\n    let shouldSucceed = false;\n    await page.route('**/api/access-code/check', route => {\n      if (!shouldSucceed) return route.abort('connectionrefused');\n      return route.fulfill({ json: { data: { enabled: false } } });\n    });\n    await page.goto('/');\n    await expect(page.getByText('无法连接到后端服务')).toBeVisible({ timeout: 10000 });\n    shouldSucceed = true;\n    await page.getByRole('button', { name: '重试' }).click();\n    await expect(page.getByText('无法连接到后端服务')).not.toBeVisible({ timeout: 10000 });\n  });\n\n  test('auto-verifies saved code from localStorage', async ({ page }) => {\n    let verified = false;\n    await page.route('**/api/access-code/check', route =>\n      route.fulfill({ json: { data: { enabled: true } } })\n    );\n    await page.route('**/api/access-code/verify', route => {\n      verified = true;\n      return route.fulfill({ json: { data: { valid: true } } });\n    });\n    // Set localStorage before navigating\n    await page.goto('/');\n    await page.evaluate(() => localStorage.setItem('banana-access-code', 'saved-code'));\n    await page.reload();\n    await expect(page.getByText('请输入访问口令')).not.toBeVisible({ timeout: 10000 });\n    expect(verified).toBe(true);\n  });\n});\n"
  },
  {
    "path": "frontend/e2e/aspect-ratio-lock-integration.spec.ts",
    "content": "/**\n * Aspect Ratio Lock - Integration E2E Test\n *\n * Uses real backend to verify aspect ratio lock when project has generated images.\n */\nimport { test, expect } from '@playwright/test'\nimport { seedProjectWithImages } from './helpers/seed-project'\n\nconst BASE = process.env.BASE_URL || 'http://localhost:3000'\nconst API = `http://localhost:${Number(new URL(BASE).port) + 2000}`\n\ntest.describe('Aspect ratio lock (integration)', () => {\n  test.setTimeout(30_000)\n\n  let projectId: string\n\n  test('aspect ratio locked after images generated', async ({ page }) => {\n    // Seed project with 1 page that has a real image\n    const seeded = await seedProjectWithImages(API, 1)\n    projectId = seeded.projectId\n\n    await page.goto(`/project/${projectId}/preview`)\n    await page.waitForLoadState('networkidle')\n\n    // Open project settings\n    const settingsBtn = page.locator('button').filter({ hasText: /设置|Settings/ }).first()\n    await settingsBtn.click()\n\n    // Verify locked state\n    await expect(page.getByText(/已生成图片的项目无法调整|Cannot change aspect ratio/)).toBeVisible()\n\n    // All ratio buttons should be disabled\n    for (const ratio of ['16:9', '4:3', '1:1', '9:16', '3:2']) {\n      await expect(page.locator(`button:has-text(\"${ratio}\")`).first()).toBeDisabled()\n    }\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/aspect-ratio-lock.spec.ts",
    "content": "/**\n * Aspect Ratio Lock & Help Tooltip - Mock E2E Tests\n *\n * Covers:\n * 1. Aspect ratio buttons disabled when project has generated images\n * 2. Help icon (?) tooltip visible next to aspect ratio title\n * 3. Locked description text shown when images exist\n * 4. Buttons clickable when no images exist\n */\nimport { test, expect } from '@playwright/test'\n\nconst PROJECT_ID = 'mock-aspect-lock'\n\nconst baseMockProject = {\n  project_id: PROJECT_ID,\n  status: 'COMPLETED',\n  idea_prompt: 'Test aspect ratio lock',\n  image_aspect_ratio: '16:9',\n  created_at: '2025-01-01T00:00:00',\n  updated_at: '2025-01-01T00:00:00',\n}\n\nconst pageWithImage = {\n  page_id: 'page-1',\n  order_index: 0,\n  outline_content: { title: 'Page 1', points: ['Point'] },\n  description_content: { text: 'desc' },\n  generated_image_url: '/files/mock/pages/test.png',\n  status: 'COMPLETED',\n}\n\nconst pageWithoutImage = {\n  page_id: 'page-1',\n  order_index: 0,\n  outline_content: { title: 'Page 1', points: ['Point'] },\n  description_content: { text: 'desc' },\n  generated_image_url: null,\n  status: 'DRAFT',\n}\n\nfunction mockRoutes(page: any, pages: any[]) {\n  return page.route('**/api/projects/' + PROJECT_ID, async (route: any) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({\n        success: true,\n        data: { ...baseMockProject, pages },\n      }),\n    })\n  })\n}\n\ntest.describe('Aspect ratio lock (mock)', () => {\n  test('buttons disabled & locked text when project has images', async ({ page }) => {\n    await mockRoutes(page, [pageWithImage])\n    await page.goto(`/project/${PROJECT_ID}/preview`)\n    await page.waitForLoadState('networkidle')\n\n    // Open project settings modal\n    const settingsBtn = page.locator('button').filter({ hasText: /设置|Settings/ }).first()\n    await settingsBtn.click()\n\n    // Verify locked description text\n    await expect(page.getByText(/已生成图片的项目无法调整|Cannot change aspect ratio/)).toBeVisible()\n\n    // Verify buttons are disabled\n    for (const ratio of ['16:9', '4:3', '1:1', '9:16', '3:2']) {\n      await expect(page.locator(`button:has-text(\"${ratio}\")`).first()).toBeDisabled()\n    }\n\n    // Save button should not be visible\n    await expect(page.locator('button').filter({ hasText: /^保存$|^Save$/ }).first()).not.toBeVisible()\n  })\n\n  test('help icon tooltip visible', async ({ page }) => {\n    await mockRoutes(page, [pageWithImage])\n    await page.goto(`/project/${PROJECT_ID}/preview`)\n    await page.waitForLoadState('networkidle')\n\n    const settingsBtn = page.locator('button').filter({ hasText: /设置|Settings/ }).first()\n    await settingsBtn.click()\n\n    // Help icon should exist\n    const helpIcon = page.locator('.lucide-help-circle').first()\n    await expect(helpIcon).toBeVisible()\n\n    // Hover to show tooltip\n    await helpIcon.hover()\n    await expect(page.getByText(/部分模型仅支持特定|Some models only support/)).toBeVisible()\n  })\n\n  test('buttons enabled when no images exist', async ({ page }) => {\n    await mockRoutes(page, [pageWithoutImage])\n    await page.goto(`/project/${PROJECT_ID}/preview`)\n    await page.waitForLoadState('networkidle')\n\n    const settingsBtn = page.locator('button').filter({ hasText: /设置|Settings/ }).first()\n    await settingsBtn.click()\n\n    // Normal description text\n    await expect(page.getByText(/设置生成幻灯片|Set the aspect ratio/)).toBeVisible()\n\n    // Buttons should be enabled\n    const btn43 = page.locator('button:has-text(\"4:3\")').first()\n    await expect(btn43).toBeEnabled()\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/attachment-sort-filter.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\n\ntest.describe('Attachment Sorting and Filtering', () => {\n  const BASE_URL = process.env.BASE_URL || 'http://localhost:3401';\n\n  test('should sort attachments by newest first (default)', async ({ page }) => {\n    await page.route('**/api/reference-files?project_id=all', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          data: {\n            files: [\n              { id: '1', filename: 'old.pdf', created_at: '2024-01-01T00:00:00Z', file_size: 1000, parse_status: 'completed' },\n              { id: '2', filename: 'new.pdf', created_at: '2024-12-01T00:00:00Z', file_size: 2000, parse_status: 'completed' },\n              { id: '3', filename: 'middle.pdf', created_at: '2024-06-01T00:00:00Z', file_size: 1500, parse_status: 'completed' }\n            ]\n          }\n        })\n      });\n    });\n\n    await page.goto(`${BASE_URL}`);\n    await page.click('button:has-text(\"上传文件\")');\n\n    const fileItems = page.locator('.divide-y > div');\n    await expect(fileItems.nth(0)).toContainText('new.pdf');\n    await expect(fileItems.nth(1)).toContainText('middle.pdf');\n    await expect(fileItems.nth(2)).toContainText('old.pdf');\n  });\n\n  test('should sort attachments by oldest first', async ({ page }) => {\n    await page.route('**/api/reference-files?project_id=all', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          data: {\n            files: [\n              { id: '1', filename: 'old.pdf', created_at: '2024-01-01T00:00:00Z', file_size: 1000, parse_status: 'completed' },\n              { id: '2', filename: 'new.pdf', created_at: '2024-12-01T00:00:00Z', file_size: 2000, parse_status: 'completed' }\n            ]\n          }\n        })\n      });\n    });\n\n    await page.goto(`${BASE_URL}`);\n    await page.click('button:has-text(\"上传文件\")');\n    await page.selectOption('select >> nth=1', 'oldest');\n\n    const fileItems = page.locator('.divide-y > div');\n    await expect(fileItems.nth(0)).toContainText('old.pdf');\n    await expect(fileItems.nth(1)).toContainText('new.pdf');\n  });\n\n  test('should sort attachments by name A-Z', async ({ page }) => {\n    await page.route('**/api/reference-files?project_id=all', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          data: {\n            files: [\n              { id: '1', filename: 'zebra.pdf', created_at: '2024-01-01T00:00:00Z', file_size: 1000, parse_status: 'completed' },\n              { id: '2', filename: 'apple.pdf', created_at: '2024-01-01T00:00:00Z', file_size: 2000, parse_status: 'completed' },\n              { id: '3', filename: 'banana.pdf', created_at: '2024-01-01T00:00:00Z', file_size: 1500, parse_status: 'completed' }\n            ]\n          }\n        })\n      });\n    });\n\n    await page.goto(`${BASE_URL}`);\n    await page.click('button:has-text(\"上传文件\")');\n    await page.selectOption('select >> nth=1', 'name-asc');\n\n    const fileItems = page.locator('.divide-y > div');\n    await expect(fileItems.nth(0)).toContainText('apple.pdf');\n    await expect(fileItems.nth(1)).toContainText('banana.pdf');\n    await expect(fileItems.nth(2)).toContainText('zebra.pdf');\n  });\n\n  test('should sort attachments by name Z-A', async ({ page }) => {\n    await page.route('**/api/reference-files?project_id=all', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          data: {\n            files: [\n              { id: '1', filename: 'apple.pdf', created_at: '2024-01-01T00:00:00Z', file_size: 1000, parse_status: 'completed' },\n              { id: '2', filename: 'zebra.pdf', created_at: '2024-01-01T00:00:00Z', file_size: 2000, parse_status: 'completed' }\n            ]\n          }\n        })\n      });\n    });\n\n    await page.goto(`${BASE_URL}`);\n    await page.click('button:has-text(\"上传文件\")');\n    await page.selectOption('select >> nth=1', 'name-desc');\n\n    const fileItems = page.locator('.divide-y > div');\n    await expect(fileItems.nth(0)).toContainText('zebra.pdf');\n    await expect(fileItems.nth(1)).toContainText('apple.pdf');\n  });\n\n  test('should show all projects in filter dropdown', async ({ page }) => {\n    await page.route('**/api/projects*', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          data: {\n            projects: [\n              { id: 'proj1', title: 'Project Alpha' },\n              { id: 'proj2', title: 'Project Beta' },\n              { id: 'proj3', title: 'Project Gamma' }\n            ],\n            total: 3\n          }\n        })\n      });\n    });\n\n    await page.route('**/api/reference-files?project_id=all', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ data: { files: [] } })\n      });\n    });\n\n    await page.goto(`${BASE_URL}`);\n    await page.click('button:has-text(\"上传文件\")');\n\n    const filterSelect = page.locator('select').first();\n    await expect(filterSelect.locator('option')).toHaveCount(5);\n    await expect(filterSelect).toContainText('Project Alpha');\n    await expect(filterSelect).toContainText('Project Beta');\n    await expect(filterSelect).toContainText('Project Gamma');\n  });\n\n  test('should filter by specific project with one click', async ({ page }) => {\n    let requestedProjectId = '';\n\n    await page.route('**/api/projects*', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          data: {\n            projects: [\n              { id: 'proj1', title: 'Project Alpha' },\n              { id: 'proj2', title: 'Project Beta' }\n            ],\n            total: 2\n          }\n        })\n      });\n    });\n\n    await page.route('**/api/reference-files**', async (route) => {\n      const url = route.request().url();\n      const match = url.match(/project_id=([^&]+)/);\n      requestedProjectId = match ? match[1] : '';\n\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          data: {\n            files: requestedProjectId === 'proj2' ? [\n              { id: 'f1', filename: 'beta-file.pdf', created_at: '2024-01-01T00:00:00Z', file_size: 1000, parse_status: 'completed' }\n            ] : []\n          }\n        })\n      });\n    });\n\n    await page.goto(`${BASE_URL}`);\n    await page.click('button:has-text(\"上传文件\")');\n\n    const filterSelect = page.locator('select').first();\n    await filterSelect.selectOption('proj2');\n\n    await page.waitForTimeout(500);\n    expect(requestedProjectId).toBe('proj2');\n    await expect(page.locator('.divide-y > div')).toContainText('beta-file.pdf');\n  });\n});\n"
  },
  {
    "path": "frontend/e2e/badge-status-after-generation.spec.ts",
    "content": "import { test, expect, Page, Route } from '@playwright/test'\nimport { seedProjectWithImages } from './helpers/seed-project'\n\nconst PROJECT_ID = 'badge-race-mock'\nconst PAGE_IDS = ['p-1', 'p-2', 'p-3']\n\nfunction makePage(id: string, idx: number, status: string, hasImage: boolean) {\n  return {\n    page_id: id,\n    order_index: idx,\n    outline_content: { title: `Slide ${idx + 1}`, points: ['pt'] },\n    description_content: { text: `Desc ${idx + 1}` },\n    generated_image_url: hasImage ? `/files/${PROJECT_ID}/pages/${id}_v1.jpg` : null,\n    status,\n    created_at: '2026-01-01T00:00:00',\n    updated_at: '2026-01-01T00:00:00',\n  }\n}\n\nfunction projectJson(pages: ReturnType<typeof makePage>[], projectStatus = 'COMPLETED') {\n  return {\n    success: true,\n    data: {\n      id: PROJECT_ID,\n      creation_type: 'idea',\n      idea_prompt: 'test',\n      status: projectStatus,\n      template_style: 'default',\n      image_aspect_ratio: '16:9',\n      pages,\n      created_at: '2026-01-01T00:00:00',\n      updated_at: '2026-01-01T00:00:00',\n    },\n  }\n}\n\nasync function mockCommonRoutes(page: Page) {\n  await page.route('**/api/access-code/check', (r) =>\n    r.fulfill({ status: 200, contentType: 'application/json', body: '{\"success\":true,\"data\":{\"enabled\":false}}' }))\n  await page.route('**/api/user-templates', (r) =>\n    r.fulfill({ status: 200, contentType: 'application/json', body: '{\"success\":true,\"data\":{\"templates\":[]}}' }))\n  await page.route('**/api/projects/*/pages/*/image-versions', (r) =>\n    r.fulfill({ status: 200, contentType: 'application/json', body: '{\"success\":true,\"data\":{\"versions\":[]}}' }))\n  await page.route('**/files/**', (r) =>\n    r.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from([0xff, 0xd8, 0xff, 0xe0]) }))\n}\n\n// ─── Mock tests ───\n\ntest.describe('Badge status after image generation (mock)', () => {\n  test.beforeEach(async ({ page }) => {\n    await page.addInitScript(() => localStorage.setItem('hasSeenHelpModal', 'true'))\n  })\n\n  test('all badges show COMPLETED when project pages are COMPLETED', async ({ page }) => {\n    await mockCommonRoutes(page)\n\n    const completedPages = PAGE_IDS.map((id, i) => makePage(id, i, 'COMPLETED', true))\n    await page.route(`**/api/projects/${PROJECT_ID}`, (r) => {\n      if (r.request().method() === 'GET') {\n        return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(projectJson(completedPages)) })\n      }\n      return r.continue()\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/preview`)\n    const badges = page.locator('[data-testid=\"status-badge\"]')\n    await expect(badges.first()).toBeVisible({ timeout: 10000 })\n\n    const count = await badges.count()\n    expect(count).toBe(3)\n    for (let i = 0; i < count; i++) {\n      await expect(badges.nth(i)).toHaveAttribute('data-status', 'COMPLETED')\n    }\n  })\n\n  test('badges transition from GENERATING to COMPLETED after sync', async ({ page }) => {\n    await mockCommonRoutes(page)\n\n    // Phase 1: pages are GENERATING\n    let phase: 'generating' | 'completed' = 'generating'\n\n    await page.route(`**/api/projects/${PROJECT_ID}`, (r) => {\n      if (r.request().method() !== 'GET') return r.continue()\n      if (phase === 'generating') {\n        const pages = PAGE_IDS.map((id, i) => makePage(id, i, 'GENERATING', false))\n        return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(projectJson(pages, 'GENERATING_IMAGES')) })\n      }\n      const pages = PAGE_IDS.map((id, i) => makePage(id, i, 'COMPLETED', true))\n      return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(projectJson(pages)) })\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/preview`)\n    const badges = page.locator('[data-testid=\"status-badge\"]')\n    await expect(badges.first()).toBeVisible({ timeout: 10000 })\n\n    // Verify initial state: GENERATING\n    for (let i = 0; i < 3; i++) {\n      await expect(badges.nth(i)).toHaveAttribute('data-status', 'GENERATING')\n    }\n\n    // Switch to completed phase and trigger a re-sync via navigation\n    phase = 'completed'\n    await page.evaluate(() => location.reload())\n\n    // Verify final state: COMPLETED\n    await expect(badges.first()).toBeVisible({ timeout: 10000 })\n    for (let i = 0; i < 3; i++) {\n      await expect(badges.nth(i)).toHaveAttribute('data-status', 'COMPLETED')\n    }\n  })\n})\n\n// ─── Integration test (real backend) ───\n\ntest.describe('Badge status (integration)', () => {\n  test.beforeEach(async ({ page }) => {\n    await page.addInitScript(() => localStorage.setItem('hasSeenHelpModal', 'true'))\n  })\n\n  test('seeded project shows COMPLETED badges on preview page', async ({ page, baseURL }) => {\n    const { projectId } = await seedProjectWithImages(baseURL!, 3)\n\n    await page.goto(`/project/${projectId}/preview`)\n    const badges = page.locator('[data-testid=\"status-badge\"]')\n    await expect(badges.first()).toBeVisible({ timeout: 10000 })\n\n    const count = await badges.count()\n    expect(count).toBe(3)\n    for (let i = 0; i < count; i++) {\n      await expect(badges.nth(i)).toHaveAttribute('data-status', 'COMPLETED')\n    }\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/desc-regeneration-skeleton.spec.ts",
    "content": "/**\n * Mock E2E test: Skeleton stays visible during batch description RE-generation.\n *\n * When re-generating descriptions, the backend sets page.status to GENERATING_DESCRIPTION.\n * The skeleton must stay until the status changes to DESCRIPTION_GENERATED with new content.\n */\nimport { test, expect } from '@playwright/test'\n\nconst PROJECT_ID = 'mock-proj-regen-skeleton'\n\nfunction makePage(id: string, index: number, title: string, opts?: { description?: string, status?: string }) {\n  return {\n    id,\n    page_id: id,\n    title,\n    sort_order: index,\n    order_index: index,\n    status: opts?.status || (opts?.description ? 'DESCRIPTION_GENERATED' : 'DRAFT'),\n    outline_content: { title, points: [`Point for ${title}`] },\n    description_content: opts?.description ? { text: opts.description } : null,\n    generated_image_path: null,\n  }\n}\n\nconst OLD_DESC_1 = 'Old description for page one'\nconst OLD_DESC_2 = 'Old description for page two'\nconst NEW_DESC_1 = 'Brand new description for page one'\nconst NEW_DESC_2 = 'Brand new description for page two'\n\ntest.describe('Skeleton during description re-generation', () => {\n  test('skeleton stays visible until page status changes from GENERATING_DESCRIPTION', async ({ page }) => {\n    let regenerationStarted = false\n    let syncCountAfterRegen = 0\n\n    // Mock GET project\n    await page.route(`**/api/projects/${PROJECT_ID}`, async (route) => {\n      if (route.request().method() !== 'GET') { await route.continue(); return }\n\n      let pages\n      if (!regenerationStarted) {\n        // Before re-generation: all pages already have descriptions\n        pages = [\n          makePage('p1', 0, 'Page One', { description: OLD_DESC_1 }),\n          makePage('p2', 1, 'Page Two', { description: OLD_DESC_2 }),\n        ]\n      } else {\n        syncCountAfterRegen++\n        if (syncCountAfterRegen <= 2) {\n          // Still processing: backend has set status to GENERATING_DESCRIPTION\n          pages = [\n            makePage('p1', 0, 'Page One', { description: OLD_DESC_1, status: 'GENERATING_DESCRIPTION' }),\n            makePage('p2', 1, 'Page Two', { description: OLD_DESC_2, status: 'GENERATING_DESCRIPTION' }),\n          ]\n        } else if (syncCountAfterRegen <= 4) {\n          // Page 1 done (status changed + new content), page 2 still generating\n          pages = [\n            makePage('p1', 0, 'Page One', { description: NEW_DESC_1 }),\n            makePage('p2', 1, 'Page Two', { description: OLD_DESC_2, status: 'GENERATING_DESCRIPTION' }),\n          ]\n        } else {\n          // All done\n          pages = [\n            makePage('p1', 0, 'Page One', { description: NEW_DESC_1 }),\n            makePage('p2', 1, 'Page Two', { description: NEW_DESC_2 }),\n          ]\n        }\n      }\n\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: {\n            project_id: PROJECT_ID, id: PROJECT_ID,\n            status: 'DESCRIPTIONS_GENERATED', creation_type: 'idea',\n            pages,\n          },\n        }),\n      })\n    })\n\n    // Mock POST generate descriptions\n    await page.route('**/api/projects/*/generate/descriptions', async (route) => {\n      regenerationStarted = true\n      await route.fulfill({\n        status: 202,\n        contentType: 'application/json',\n        body: JSON.stringify({ success: true, data: { task_id: 'mock-regen-task' } }),\n      })\n    })\n\n    // Mock task polling\n    let taskCallCount = 0\n    await page.route(`**/api/projects/${PROJECT_ID}/tasks/*`, async (route) => {\n      taskCallCount++\n      const completed = taskCallCount >= 4\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: {\n            task_id: 'mock-regen-task',\n            status: completed ? 'COMPLETED' : 'PROCESSING',\n            progress: { total: 2, completed: Math.min(taskCallCount, 2) },\n          },\n        }),\n      })\n    })\n\n    // Mock reference files\n    await page.route('**/api/projects/*/files*', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ success: true, data: [] }),\n      })\n    })\n\n    // Navigate to detail editor\n    const baseUrl = process.env.BASE_URL || 'http://localhost:3000'\n    await page.goto(`${baseUrl}/project/${PROJECT_ID}/detail`)\n\n    // Verify old descriptions are visible before re-generation\n    await expect(page.getByText(OLD_DESC_1)).toBeVisible({ timeout: 10000 })\n    await expect(page.getByText(OLD_DESC_2)).toBeVisible()\n\n    // Click batch generate (triggers re-generation confirmation dialog)\n    await page.getByRole('button', { name: /批量生成描述|Batch Generate/i }).click()\n\n    // Confirm the regeneration dialog (may or may not appear)\n    try {\n      await page.getByRole('button', { name: /确认|确定/ }).click({ timeout: 2000 })\n    } catch {\n      // Dialog may not appear, which is expected\n    }\n\n    // After clicking generate, skeleton should appear — old descriptions should NOT be visible\n    await expect(page.getByText(/生成中|Generating/).first()).toBeVisible({ timeout: 5000 })\n\n    // Old descriptions should be hidden while skeleton is showing\n    await expect(page.getByText(OLD_DESC_1)).not.toBeVisible()\n    await expect(page.getByText(OLD_DESC_2)).not.toBeVisible()\n\n    // Wait for page 1 new description to appear (status changed to DESCRIPTION_GENERATED)\n    await expect(page.getByText(NEW_DESC_1)).toBeVisible({ timeout: 15000 })\n\n    // Wait for page 2 new description\n    await expect(page.getByText(NEW_DESC_2)).toBeVisible({ timeout: 15000 })\n\n    // Verify final state: both new descriptions visible, old ones gone\n    await expect(page.getByText(OLD_DESC_1)).not.toBeVisible()\n    await expect(page.getByText(OLD_DESC_2)).not.toBeVisible()\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/description-detail-level.spec.ts",
    "content": "/**\n * E2E tests for description detail level selector.\n *\n * Mock tests: verify UI rendering, default selection, click behavior,\n * and that the correct detail_level is sent in API requests.\n *\n * Integration test: verify selector works with real backend.\n */\nimport { test, expect } from '@playwright/test'\n\nconst PROJECT_ID = 'mock-proj-detail-level'\n\nfunction makePage(id: string, index: number, title: string, description?: string) {\n  return {\n    id,\n    page_id: id,\n    title,\n    sort_order: index,\n    order_index: index,\n    status: description ? 'DESCRIPTION_GENERATED' : 'DRAFT',\n    outline_content: { title, points: [`Point for ${title}`] },\n    description_content: description ? { text: description } : null,\n    generated_image_path: null,\n  }\n}\n\nconst pages = [\n  makePage('p1', 0, 'Title Page'),\n  makePage('p2', 1, 'Introduction'),\n  makePage('p3', 2, 'Conclusion'),\n]\n\nasync function setupMockRoutes(page: import('@playwright/test').Page) {\n  // Mock access code check (required — AccessCodeGuard blocks rendering)\n  await page.route('**/api/access-code/check', async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({ success: true, data: { enabled: false } }),\n    })\n  })\n\n  // Mock project GET\n  await page.route(`**/api/projects/${PROJECT_ID}`, async (route) => {\n    if (route.request().method() !== 'GET') { await route.continue(); return }\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({\n        success: true,\n        data: {\n          project_id: PROJECT_ID, id: PROJECT_ID,\n          status: 'OUTLINE_GENERATED', creation_type: 'idea',\n          pages,\n        },\n      }),\n    })\n  })\n\n  // Mock reference files\n  await page.route('**/api/projects/*/files*', async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({ success: true, data: [] }),\n    })\n  })\n}\n\ntest.describe('Detail level selector — mock tests', () => {\n  test('renders selector with \"standard\" selected by default', async ({ page }) => {\n    await setupMockRoutes(page)\n\n    await page.goto(`/project/${PROJECT_ID}/detail`)\n    await page.waitForSelector('text=批量生成描述')\n\n    // The selector should be visible with 3 buttons\n    const buttons = page.locator('button', { hasText: /精简|标准|详细/ })\n    await expect(buttons).toHaveCount(3)\n\n    // \"标准\" should have the active style (bg-banana-500)\n    const standardBtn = page.locator('button', { hasText: '标准' })\n    await expect(standardBtn).toHaveClass(/bg-banana-500/)\n  })\n\n  test('clicking a level option changes the selection', async ({ page }) => {\n    await setupMockRoutes(page)\n\n    await page.goto(`/project/${PROJECT_ID}/detail`)\n    await page.waitForSelector('text=批量生成描述')\n\n    // Click \"精简\"\n    const conciseBtn = page.locator('button', { hasText: '精简' })\n    await conciseBtn.click()\n    await expect(conciseBtn).toHaveClass(/bg-banana-500/)\n\n    // \"标准\" should no longer be active\n    const standardBtn = page.locator('button', { hasText: '标准' })\n    await expect(standardBtn).not.toHaveClass(/bg-banana-500/)\n\n    // Click \"详细\"\n    const detailedBtn = page.locator('button', { hasText: '详细' })\n    await detailedBtn.click()\n    await expect(detailedBtn).toHaveClass(/bg-banana-500/)\n    await expect(conciseBtn).not.toHaveClass(/bg-banana-500/)\n  })\n\n  test('batch generate sends correct detail_level in request', async ({ page }) => {\n    await setupMockRoutes(page)\n\n    // Capture the POST body\n    let capturedBody: any = null\n    await page.route('**/api/projects/*/generate/descriptions', async (route) => {\n      capturedBody = JSON.parse(route.request().postData() || '{}')\n      await route.fulfill({\n        status: 202,\n        contentType: 'application/json',\n        body: JSON.stringify({ success: true, data: { task_id: 'mock-task-1' } }),\n      })\n    })\n\n    // Mock task polling — immediately complete\n    await page.route(`**/api/projects/${PROJECT_ID}/tasks/*`, async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: { task_id: 'mock-task-1', status: 'COMPLETED', progress: { total: 3, completed: 3 } },\n        }),\n      })\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/detail`)\n    await page.waitForSelector('text=批量生成描述')\n\n    // Select \"详细\" then click batch generate\n    await page.locator('button', { hasText: '详细' }).click()\n    await page.locator('button', { hasText: '批量生成描述' }).click()\n\n    // Wait for the request to be captured\n    await expect.poll(() => capturedBody).toBeTruthy()\n    expect(capturedBody.detail_level).toBe('detailed')\n  })\n\n  test('default detail_level is \"default\" when not changed', async ({ page }) => {\n    await setupMockRoutes(page)\n\n    let capturedBody: any = null\n    await page.route('**/api/projects/*/generate/descriptions', async (route) => {\n      capturedBody = JSON.parse(route.request().postData() || '{}')\n      await route.fulfill({\n        status: 202,\n        contentType: 'application/json',\n        body: JSON.stringify({ success: true, data: { task_id: 'mock-task-2' } }),\n      })\n    })\n\n    await page.route(`**/api/projects/${PROJECT_ID}/tasks/*`, async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: { task_id: 'mock-task-2', status: 'COMPLETED', progress: { total: 3, completed: 3 } },\n        }),\n      })\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/detail`)\n    await page.waitForSelector('text=批量生成描述')\n\n    await page.locator('button', { hasText: '批量生成描述' }).click()\n\n    await expect.poll(() => capturedBody).toBeTruthy()\n    expect(capturedBody.detail_level).toBe('default')\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/description-no-flicker.spec.ts",
    "content": "/**\n * Mock E2E test: Description cards should not flicker during batch generation.\n *\n * Simulates incremental description generation via polling.\n * Verifies that already-completed cards keep their content stable\n * while other pages are still generating.\n */\nimport { test, expect } from '@playwright/test'\n\nconst PROJECT_ID = 'mock-proj-flicker'\n\nfunction makePage(id: string, index: number, title: string, description?: string) {\n  return {\n    id,\n    page_id: id,\n    title,\n    sort_order: index,\n    order_index: index,\n    status: description ? 'COMPLETED' : 'DRAFT',\n    outline_content: { title, points: [`Point for ${title}`] },\n    description_content: description ? { text: description } : null,\n    generated_image_path: null,\n  }\n}\n\ntest.describe('Description cards stability during generation', () => {\n  test('already-completed cards stay stable while others generate', async ({ page }) => {\n    // Flag: set to true after generation starts, controls which stage to return\n    let generationStarted = false\n    let syncCountAfterGen = 0\n\n    // Mock GET project\n    await page.route(`**/api/projects/${PROJECT_ID}`, async (route) => {\n      if (route.request().method() !== 'GET') { await route.continue(); return }\n\n      let pages\n      if (!generationStarted) {\n        // Before generation: all pages have no description\n        pages = [\n          makePage('p1', 0, 'Page One'),\n          makePage('p2', 1, 'Page Two'),\n          makePage('p3', 2, 'Page Three'),\n        ]\n      } else {\n        syncCountAfterGen++\n        if (syncCountAfterGen <= 2) {\n          // Stage 1: page 1 done\n          pages = [\n            makePage('p1', 0, 'Page One', '# Description for Page One\\n\\nThis is page one content.'),\n            makePage('p2', 1, 'Page Two'),\n            makePage('p3', 2, 'Page Three'),\n          ]\n        } else if (syncCountAfterGen <= 4) {\n          // Stage 2: pages 1+2 done\n          pages = [\n            makePage('p1', 0, 'Page One', '# Description for Page One\\n\\nThis is page one content.'),\n            makePage('p2', 1, 'Page Two', '# Description for Page Two\\n\\nThis is page two content.'),\n            makePage('p3', 2, 'Page Three'),\n          ]\n        } else {\n          // Stage 3: all done\n          pages = [\n            makePage('p1', 0, 'Page One', '# Description for Page One\\n\\nThis is page one content.'),\n            makePage('p2', 1, 'Page Two', '# Description for Page Two\\n\\nThis is page two content.'),\n            makePage('p3', 2, 'Page Three', '# Description for Page Three\\n\\nThis is page three content.'),\n          ]\n        }\n      }\n\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: {\n            project_id: PROJECT_ID, id: PROJECT_ID,\n            status: 'OUTLINE_GENERATED', creation_type: 'idea',\n            pages,\n          },\n        }),\n      })\n    })\n\n    // Mock POST generate descriptions\n    await page.route('**/api/projects/*/generate/descriptions', async (route) => {\n      generationStarted = true\n      await route.fulfill({\n        status: 202,\n        contentType: 'application/json',\n        body: JSON.stringify({ success: true, data: { task_id: 'mock-desc-task' } }),\n      })\n    })\n\n    // Mock task polling\n    let taskCallCount = 0\n    await page.route(`**/api/projects/${PROJECT_ID}/tasks/*`, async (route) => {\n      taskCallCount++\n      const completed = taskCallCount >= 4\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: {\n            task_id: 'mock-desc-task',\n            status: completed ? 'COMPLETED' : 'PROCESSING',\n            progress: { total: 3, completed: Math.min(taskCallCount, 3) },\n          },\n        }),\n      })\n    })\n\n    // Mock reference files\n    await page.route('**/api/projects/*/files*', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ success: true, data: [] }),\n      })\n    })\n\n    // Navigate to detail editor\n    const baseUrl = process.env.BASE_URL || 'http://localhost:3000'\n    await page.goto(`${baseUrl}/project/${PROJECT_ID}/detail`)\n\n    // Wait for page cards to appear (no descriptions yet)\n    await expect(page.locator('text=第 1 页')).toBeVisible({ timeout: 10000 })\n    await expect(page.locator('text=第 3 页')).toBeVisible()\n\n    // Click batch generate\n    await page.locator('button:has-text(\"批量生成描述\")').click()\n\n    // Wait for page 1 description to appear\n    await expect(page.locator('text=This is page one content')).toBeVisible({ timeout: 15000 })\n\n    // Wait for page 2 description\n    await expect(page.locator('text=This is page two content')).toBeVisible({ timeout: 15000 })\n\n    // Verify page 1 is STILL visible (not flickered away)\n    await expect(page.locator('text=This is page one content')).toBeVisible()\n\n    // Wait for page 3 (all done)\n    await expect(page.locator('text=This is page three content')).toBeVisible({ timeout: 15000 })\n\n    // Final: all three descriptions visible simultaneously\n    await expect(page.locator('text=This is page one content')).toBeVisible()\n    await expect(page.locator('text=This is page two content')).toBeVisible()\n    await expect(page.locator('text=This is page three content')).toBeVisible()\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/editable-export-failure.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\n\ntest.describe('Editable export failure UI', () => {\n  test('shows toast and task panel error when style extraction fails', async ({ page }) => {\n    const projectId = 'mock-editable-export-failure';\n    let pollCount = 0;\n\n    await page.addInitScript(() => localStorage.setItem('hasSeenHelpModal', 'true'));\n    page.on('pageerror', error => console.log('pageerror:', error.message));\n    page.on('console', message => {\n      if (message.type() === 'error') {\n        console.log('console-error:', message.text());\n      }\n    });\n\n    await page.route(url => new URL(url).pathname.startsWith('/api/'), async route => {\n      const url = new URL(route.request().url());\n\n      if (url.pathname === '/api/access-code/check') {\n        return route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ success: true, data: { enabled: false } }),\n        });\n      }\n\n      if (url.pathname === `/api/projects/${projectId}`) {\n        return route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({\n            success: true,\n            data: {\n              project_id: projectId,\n              id: projectId,\n              status: 'COMPLETED',\n              template_style: 'default',\n              export_allow_partial: false,\n              pages: [\n                {\n                  id: 'p1',\n                  page_id: 'p1',\n                  order_index: 0,\n                  generated_image_path: '/files/mock/slide-1.png',\n                  outline_content: { title: 'Slide 1', points: [] },\n                  description_content: { text: 'desc' },\n                  status: 'COMPLETED',\n                },\n              ],\n            },\n          }),\n        });\n      }\n\n      if (url.pathname === `/api/projects/${projectId}/export/editable-pptx`) {\n        return route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({\n            success: true,\n            data: { task_id: 'editable-export-task-1' },\n          }),\n        });\n      }\n\n      if (url.pathname === `/api/projects/${projectId}/tasks/editable-export-task-1`) {\n        pollCount += 1;\n        return route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({\n            success: true,\n            data: {\n              task_id: 'editable-export-task-1',\n              task_type: 'EXPORT_EDITABLE_PPTX',\n              status: 'FAILED',\n              error_message: '文本样式提取失败: 当前图片样式提取模型不支持图片输入: caption_provider 不支持图片输入',\n              progress: {\n                total: 100,\n                completed: 0,\n                failed: 1,\n                percent: 0,\n                help_text: '当前用于图片样式提取的 caption/image_caption 模型不支持图片输入。',\n              },\n            },\n          }),\n        });\n      }\n\n      if (url.pathname === '/api/settings') {\n        return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: {} }) });\n      }\n\n      if (url.pathname === '/api/output-language') {\n        return route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ success: true, data: { language: 'zh' } }),\n        });\n      }\n\n      if (url.pathname === '/api/user-templates') {\n        return route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ success: true, data: { templates: [] } }),\n        });\n      }\n\n      if (url.pathname.includes('/image-versions')) {\n        return route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ success: true, data: { versions: [] } }),\n        });\n      }\n\n      return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: {} }) });\n    });\n\n    await page.route('**/files/**', async route => {\n      await route.fulfill({ status: 200, contentType: 'image/png', body: Buffer.alloc(256) });\n    });\n\n    await page.goto(`/project/${projectId}/preview`);\n    await page.waitForFunction(() => document.body.innerText.length > 50, { timeout: 15000 });\n\n    await page.locator('button:has-text(\"导出\")').first().click();\n    await page.getByRole('button', { name: /导出可编辑 PPTX/ }).click();\n\n    await expect\n      .poll(() => pollCount, { timeout: 10000 })\n      .toBeGreaterThan(0);\n\n    await expect(page.getByText('当前图片样式提取模型不支持图片输入')).toBeVisible({ timeout: 10000 });\n    await expect(page.getByRole('button', { name: /^1$/ })).toBeVisible({ timeout: 10000 });\n  });\n});\n"
  },
  {
    "path": "frontend/e2e/export-aspect-ratio.spec.ts",
    "content": "/**\n * Export Aspect Ratio - Integration E2E Test\n *\n * Verifies that PDF and PPTX exports use the project's aspect ratio\n * instead of hardcoding 16:9.\n */\nimport { test, expect } from '@playwright/test'\nimport { execSync } from 'child_process'\nimport * as fs from 'fs'\nimport * as path from 'path'\nimport { fileURLToPath } from 'url'\n\nconst BASE = process.env.BASE_URL || 'http://localhost:3000'\n// Derive backend URL from frontend URL (frontend 3xxx → backend 5xxx, same offset)\nconst API = `http://localhost:${Number(new URL(BASE).port) + 2000}`\n\n// Minimal 1x1 red PNG (68 bytes)\nconst TINY_PNG = Buffer.from(\n  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',\n  'base64'\n)\n\n// Worktree root (two levels up from frontend/e2e/)\nconst __filename = fileURLToPath(import.meta.url)\nconst __dirname = path.dirname(__filename)\nconst WORKTREE_ROOT = path.resolve(__dirname, '..', '..')\nconst UPLOADS_DIR = path.join(WORKTREE_ROOT, 'uploads')\nconst DB_PATH = path.join(WORKTREE_ROOT, 'backend', 'instance', 'database.db')\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction assertUUID(val: string, label: string) {\n  if (!UUID_RE.test(val)) throw new Error(`Invalid ${label}: ${val}`)\n}\n\ninterface ProjectData {\n  projectId: string\n  pageId: string\n  imagePath: string\n}\n\nasync function setupProject(\n  request: any,\n  aspectRatio: string\n): Promise<ProjectData> {\n  // Create project\n  const projRes = await request.post(`${API}/api/projects`, {\n    data: {\n      creation_type: 'idea',\n      idea_prompt: 'test export aspect ratio',\n      image_aspect_ratio: aspectRatio,\n    },\n  })\n  expect(projRes.ok()).toBeTruthy()\n  const proj = await projRes.json()\n  const projectId = proj.data.project_id\n\n  // Create page\n  const pageRes = await request.post(`${API}/api/projects/${projectId}/pages`, {\n    data: { order_index: 0 },\n  })\n  expect(pageRes.ok()).toBeTruthy()\n  const page = await pageRes.json()\n  const pageId = page.data.page_id\n\n  // Place test image on disk\n  const pagesDir = path.join(UPLOADS_DIR, projectId, 'pages')\n  fs.mkdirSync(pagesDir, { recursive: true })\n  const imgFile = `test_${pageId}.png`\n  const imgAbsPath = path.join(pagesDir, imgFile)\n  fs.writeFileSync(imgAbsPath, TINY_PNG)\n\n  // Update DB to set generated_image_path (validate UUIDs to prevent injection)\n  assertUUID(projectId, 'projectId')\n  assertUUID(pageId, 'pageId')\n  const relPath = `${projectId}/pages/${imgFile}`\n  execSync(\n    `sqlite3 \"${DB_PATH}\" \"UPDATE pages SET generated_image_path='${relPath}', status='IMAGE_GENERATED' WHERE id='${pageId}';\"`\n  )\n\n  return { projectId, pageId, imagePath: imgAbsPath }\n}\n\nfunction cleanup(projectId: string) {\n  assertUUID(projectId, 'projectId')\n  const dir = path.join(UPLOADS_DIR, projectId)\n  if (fs.existsSync(dir)) {\n    fs.rmSync(dir, { recursive: true, force: true })\n  }\n  try {\n    execSync(\n      `sqlite3 \"${DB_PATH}\" \"DELETE FROM pages WHERE project_id='${projectId}'; DELETE FROM projects WHERE id='${projectId}';\"`\n    )\n  } catch { /* best effort */ }\n}\n\ntest.describe.serial('Export aspect ratio', () => {\n  test.setTimeout(30_000)\n\n  const createdProjects: string[] = []\n\n  test.afterAll(async () => {\n    for (const id of createdProjects) cleanup(id)\n  })\n\n  test('PDF export uses 4:3 page dimensions', async ({ request }) => {\n    const { projectId } = await setupProject(request, '4:3')\n    createdProjects.push(projectId)\n\n    const res = await request.get(\n      `${API}/api/projects/${projectId}/export/pdf`\n    )\n    expect(res.ok()).toBeTruthy()\n    const body = await res.json()\n    const downloadUrl = body.data.download_url_absolute\n\n    // Download the PDF\n    const pdfRes = await request.get(downloadUrl)\n    expect(pdfRes.ok()).toBeTruthy()\n    const pdfBuf = Buffer.from(await pdfRes.body())\n\n    // Parse PDF MediaBox to verify aspect ratio\n    // MediaBox format: [0 0 width height] in points (1 inch = 72 pt)\n    const pdfStr = pdfBuf.toString('latin1')\n    const match = pdfStr.match(/\\/MediaBox\\s*\\[\\s*([\\d.]+)\\s+([\\d.]+)\\s+([\\d.]+)\\s+([\\d.]+)\\s*\\]/)\n    expect(match).not.toBeNull()\n\n    const pdfW = parseFloat(match![3])\n    const pdfH = parseFloat(match![4])\n    const ratio = pdfW / pdfH\n\n    // 4:3 = 1.333...\n    expect(ratio).toBeCloseTo(4 / 3, 1)\n    // Should NOT be 16:9 (1.778)\n    expect(Math.abs(ratio - 16 / 9)).toBeGreaterThan(0.1)\n  })\n\n  test('PPTX export uses 4:3 slide dimensions', async ({ request }) => {\n    const { projectId } = await setupProject(request, '4:3')\n    createdProjects.push(projectId)\n\n    const res = await request.get(\n      `${API}/api/projects/${projectId}/export/pptx`\n    )\n    expect(res.ok()).toBeTruthy()\n    const body = await res.json()\n    const downloadUrl = body.data.download_url_absolute\n\n    // Extract slide dimensions from PPTX (ZIP containing XML)\n    // The download_url is like /files/{id}/exports/file.pptx\n    const pptxPath = path.join(UPLOADS_DIR, body.data.download_url.replace('/files/', ''))\n    if (!pptxPath.startsWith(UPLOADS_DIR)) throw new Error('Invalid pptx path')\n    const xml = execSync(`unzip -p \"${pptxPath}\" ppt/presentation.xml`).toString()\n    const sldSzMatch = xml.match(/sldSz\\s+cx=\"(\\d+)\"\\s+cy=\"(\\d+)\"/)\n    expect(sldSzMatch).not.toBeNull()\n\n    const cx = parseInt(sldSzMatch![1])\n    const cy = parseInt(sldSzMatch![2])\n    const ratio = cx / cy\n\n    // 4:3 = 1.333...\n    expect(ratio).toBeCloseTo(4 / 3, 1)\n    expect(Math.abs(ratio - 16 / 9)).toBeGreaterThan(0.1)\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/export-images.spec.ts",
    "content": "/**\n * E2E tests for image export feature.\n *\n * 1. Backend API tests: error case + happy path (single & multi-image export)\n * 2. Mock UI tests: verify the export menu renders the image export option\n */\n\nimport { test, expect } from '@playwright/test'\nimport { seedProjectWithImages } from './helpers/seed-project'\n\ntest.describe('Export Images - Backend API', () => {\n  test('returns 400 when project has no images', async ({ request }) => {\n    // Create a project\n    const createResp = await request.post('/api/projects', {\n      data: { creation_type: 'idea', idea_prompt: 'test', template_style: 'default' },\n    })\n    if (!createResp.ok()) { test.skip(true, 'Backend unavailable'); return }\n\n    const projectId = (await createResp.json()).data?.project_id\n    if (!projectId) { test.skip(true, 'No project_id'); return }\n\n    const resp = await request.get(`/api/projects/${projectId}/export/images`)\n    expect(resp.ok()).toBe(false)\n    expect(resp.status()).toBe(400)\n  })\n\n  test('exports single image successfully', async ({ request, baseURL }) => {\n    const { projectId } = await seedProjectWithImages(baseURL!, 1)\n\n    const resp = await request.get(`/api/projects/${projectId}/export/images`)\n    expect(resp.ok()).toBe(true)\n    const data = (await resp.json()).data\n    expect(data.download_url).toContain(`/files/${projectId}/exports/`)\n    expect(data.download_url).toContain('.jpg')\n\n    // Verify the file is downloadable\n    const fileResp = await request.get(data.download_url)\n    expect(fileResp.ok()).toBe(true)\n    expect(fileResp.headers()['content-type']).toContain('image/jpeg')\n  })\n\n  test('exports multiple images as ZIP', async ({ request, baseURL }) => {\n    const { projectId } = await seedProjectWithImages(baseURL!, 2)\n\n    const resp = await request.get(`/api/projects/${projectId}/export/images`)\n    expect(resp.ok()).toBe(true)\n    const data = (await resp.json()).data\n    expect(data.download_url).toContain('.zip')\n\n    // Verify the ZIP is downloadable\n    const fileResp = await request.get(data.download_url)\n    expect(fileResp.ok()).toBe(true)\n  })\n})\n\ntest.describe('Export Images - UI Mock', () => {\n  test.setTimeout(60_000)\n\n  test('export dropdown contains image export option', async ({ page }) => {\n    const PID = 'mock-img-export'\n\n    // Intercept API requests (use function matcher to avoid catching Vite source files like /src/api/...)\n    await page.route(url => new URL(url).pathname.startsWith('/api/'), async (route) => {\n      const url = new URL(route.request().url())\n\n      if (url.pathname === `/api/projects/${PID}`) {\n        return route.fulfill({\n          status: 200, contentType: 'application/json',\n          body: JSON.stringify({\n            success: true,\n            data: {\n              project_id: PID, id: PID, status: 'IMAGES_GENERATED',\n              template_style: 'default',\n              pages: [\n                { id: 'p1', page_id: 'p1', title: 'Slide 1', order_index: 0, generated_image_path: '/files/x/1.png', page_number: 1, outline_content: { title: 'Slide 1' }, status: 'COMPLETED' },\n              ],\n            },\n          }),\n        })\n      }\n\n      if (url.pathname === '/api/settings') {\n        return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: {} }) })\n      }\n      if (url.pathname === '/api/output-language') {\n        return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { language: 'zh' } }) })\n      }\n      if (url.pathname === '/api/user-templates') {\n        return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { templates: [] } }) })\n      }\n\n      // Default: 200 empty\n      return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: {} }) })\n    })\n\n    // Mock image files\n    await page.route('**/files/**', async (route) => {\n      await route.fulfill({ status: 200, contentType: 'image/png', body: Buffer.alloc(100) })\n    })\n\n    await page.goto(`/project/${PID}/preview`)\n\n    // Wait for page content to render (the preview title or page count text)\n    await page.waitForFunction(() => document.body.innerText.length > 50, { timeout: 15000 })\n\n    // Find and click the export button using text content\n    const exportBtn = page.locator('button:has-text(\"导出\")').first()\n    await expect(exportBtn).toBeVisible({ timeout: 10000 })\n    await exportBtn.click()\n\n    // Verify image export option appears in the dropdown\n    const imgExportBtn = page.locator('button:has-text(\"导出为图片\")')\n    await expect(imgExportBtn).toBeVisible({ timeout: 5000 })\n  })\n\n  test('image export calls correct API endpoint', async ({ page }) => {\n    const PID = 'mock-img-export2'\n    let imageExportCalled = false\n\n    await page.route(url => new URL(url).pathname.startsWith('/api/'), async (route) => {\n      const url = new URL(route.request().url())\n\n      if (url.pathname === `/api/projects/${PID}/export/images`) {\n        imageExportCalled = true\n        return route.fulfill({\n          status: 200, contentType: 'application/json',\n          body: JSON.stringify({ success: true, data: { download_url: '/files/x/slides.zip', download_url_absolute: 'http://localhost/files/x/slides.zip' } }),\n        })\n      }\n\n      if (url.pathname === `/api/projects/${PID}`) {\n        return route.fulfill({\n          status: 200, contentType: 'application/json',\n          body: JSON.stringify({\n            success: true,\n            data: {\n              project_id: PID, id: PID, status: 'IMAGES_GENERATED',\n              template_style: 'default',\n              pages: [\n                { id: 'p1', page_id: 'p1', title: 'S1', order_index: 0, generated_image_path: '/files/x/1.png', page_number: 1, outline_content: { title: 'S1' }, status: 'COMPLETED' },\n              ],\n            },\n          }),\n        })\n      }\n\n      if (url.pathname === '/api/settings') {\n        return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: {} }) })\n      }\n      if (url.pathname === '/api/output-language') {\n        return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { language: 'zh' } }) })\n      }\n      if (url.pathname === '/api/user-templates') {\n        return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { templates: [] } }) })\n      }\n\n      return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: {} }) })\n    })\n\n    await page.route('**/files/**', async (route) => {\n      await route.fulfill({ status: 200, contentType: 'image/png', body: Buffer.alloc(100) })\n    })\n\n    await page.goto(`/project/${PID}/preview`)\n    await page.waitForFunction(() => document.body.innerText.length > 50, { timeout: 15000 })\n\n    await page.locator('button:has-text(\"导出\")').first().click()\n    await page.locator('button:has-text(\"导出为图片\")').click()\n\n    await expect.poll(() => imageExportCalled).toBe(true)\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/extract-style-caption.spec.ts",
    "content": "/**\n * E2E tests for extract-style using caption_provider.\n *\n * Mock test: verify frontend handles the extract-style API correctly.\n */\n\nimport { test, expect } from '@playwright/test'\n\nconst BASE_URL = process.env.BASE_URL || 'http://localhost:3000'\n\nconst TINY_PNG = Buffer.from(\n  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',\n  'base64',\n)\n\n/** Click the extract-style button and provide a fake image via filechooser. */\nasync function triggerStyleExtract(page: import('@playwright/test').Page) {\n  // Enable text style mode first (checkbox defaults to off)\n  const toggle = page.getByText(/使用文字描述风格|Use text description for style/)\n  await toggle.scrollIntoViewIfNeeded()\n  await toggle.click()\n\n  // Wait for the extract button to appear, then find the sibling hidden input\n  const btn = page.getByText(/从图片提取风格|Extract from image/)\n  await expect(btn).toBeVisible()\n\n  // The hidden file input is right after the button, not multiple, not disabled\n  const fileInput = page.locator('input[type=\"file\"][accept=\"image/*\"]:not([multiple]):not([disabled])')\n  await fileInput.setInputFiles({ name: 'style.png', mimeType: 'image/png', buffer: TINY_PNG })\n}\n\ntest.describe('Extract style - Mock tests', () => {\n  test.beforeEach(async ({ page }) => {\n    await page.addInitScript(() => localStorage.setItem('hasSeenHelpModal', 'true'))\n  })\n\n  test('should extract style and show success toast', async ({ page }) => {\n    const mockStyle = 'Modern minimalist blue gradient'\n\n    await page.route('**/api/extract-style', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ success: true, data: { style_description: mockStyle } }),\n      })\n    })\n\n    await page.goto(BASE_URL)\n    await triggerStyleExtract(page)\n\n    await expect(page.getByText(/风格提取成功|Style extracted successfully/)).toBeVisible({ timeout: 5000 })\n  })\n\n  test('should show error toast when extract-style fails', async ({ page }) => {\n    await page.route('**/api/extract-style', async (route) => {\n      await route.fulfill({\n        status: 503,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: false,\n          error: { code: 'AI_SERVICE_ERROR', message: 'caption_provider error' },\n        }),\n      })\n    })\n\n    await page.goto(BASE_URL)\n    await triggerStyleExtract(page)\n\n    await expect(page.getByText(/风格提取失败|Style extraction failed/)).toBeVisible({ timeout: 5000 })\n  })\n\n  test('should send multipart POST to /api/extract-style', async ({ page }) => {\n    let requestOk = false\n\n    await page.route('**/api/extract-style', async (route) => {\n      const req = route.request()\n      expect(req.method()).toBe('POST')\n      expect(req.headers()['content-type'] || '').toContain('multipart/form-data')\n      requestOk = true\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ success: true, data: { style_description: 'ok' } }),\n      })\n    })\n\n    await page.goto(BASE_URL)\n    await triggerStyleExtract(page)\n\n    await expect(page.getByText(/风格提取成功|Style extracted successfully/)).toBeVisible({ timeout: 5000 })\n    expect(requestOk).toBe(true)\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/failed-file-reselect.spec.ts",
    "content": "/**\n * E2E test: Failed files can be re-selected in selector and re-parsed from card\n */\nimport { test, expect } from '@playwright/test'\n\ntest.use({ baseURL: process.env.BASE_URL || 'http://localhost:3000' })\n\nconst FILE_FAILED = 'file-failed-001'\nconst FILE_COMPLETED = 'file-completed-002'\n\nconst mockFileList = () => ({\n  success: true,\n  data: {\n    files: [\n      { id: FILE_FAILED, filename: 'broken.pdf', file_size: 1000, file_type: 'application/pdf', parse_status: 'failed', error_message: 'MinerU timeout' },\n      { id: FILE_COMPLETED, filename: 'good.pdf', file_size: 2000, file_type: 'application/pdf', parse_status: 'completed' },\n    ]\n  }\n})\n\nconst mockSettings = () => ({\n  success: true,\n  data: { ai_provider_format: 'gemini', google_api_key: 'fake' }\n})\n\ntest.describe('Failed file re-selection (mocked)', () => {\n  test.setTimeout(60_000)\n\n  test('selecting a failed file in selector triggers re-parse on confirm', async ({ page }) => {\n    let parseCalled = false\n\n    await page.route('**/api/settings', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockSettings()) }))\n    await page.route('**/api/reference-files/project/**', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockFileList()) }))\n    await page.route(`**/api/reference-files/${FILE_FAILED}/parse`, r => {\n      parseCalled = true\n      r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { file: { id: FILE_FAILED, filename: 'broken.pdf', file_size: 1000, file_type: 'application/pdf', parse_status: 'parsing' }, message: 'ok' } }) })\n    })\n\n    await page.addInitScript(() => localStorage.setItem('hasSeenHelpModal', 'true'))\n    await page.goto('/')\n\n    // Click paperclip button to open file selector\n    const paperclip = page.locator('button[title]').filter({ has: page.locator('svg.lucide-paperclip') })\n    await paperclip.click()\n\n    // Wait for file selector modal (by title)\n    const modal = page.getByRole('dialog', { name: '选择参考文件' })\n    await expect(modal).toBeVisible({ timeout: 5_000 })\n\n    // Click the failed file row to select it\n    await modal.locator('text=broken.pdf').first().click()\n\n    // Click confirm button\n    await modal.getByRole('button', { name: /确定/ }).click()\n\n    // Verify parse was triggered for the failed file\n    expect(parseCalled).toBe(true)\n  })\n\n  test('failed file card shows reparse button', async ({ page }) => {\n    const PROJECT_ID = 'mock-proj-reparse'\n    let parseCalled = false\n\n    await page.route('**/api/settings', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockSettings()) }))\n    await page.route(`**/api/projects/${PROJECT_ID}`, r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({\n      success: true,\n      data: {\n        id: PROJECT_ID, project_id: PROJECT_ID, title: 'Test', status: 'OUTLINE_GENERATED', creation_type: 'idea',\n        pages: [{ id: 'p1', page_id: 'p1', title: 'Page 1', order_index: 0, outline_content: { title: 'Page 1', points: ['p'] } }],\n        reference_files: [{ id: FILE_FAILED, filename: 'broken.pdf', file_size: 1000, file_type: 'application/pdf', parse_status: 'failed', error_message: 'MinerU timeout' }]\n      }\n    }) }))\n    await page.route(`**/api/projects/${PROJECT_ID}/pages`, r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { pages: [] } }) }))\n    await page.route(`**/api/reference-files/project/${PROJECT_ID}`, r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { files: [{ id: FILE_FAILED, filename: 'broken.pdf', file_size: 1000, file_type: 'application/pdf', parse_status: 'failed', error_message: 'MinerU timeout' }] } }) }))\n    await page.route(`**/api/reference-files/${FILE_FAILED}`, r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { file: { id: FILE_FAILED, filename: 'broken.pdf', file_size: 1000, file_type: 'application/pdf', parse_status: 'failed', error_message: 'MinerU timeout' } } }) }))\n    await page.route(`**/api/reference-files/${FILE_FAILED}/parse`, r => {\n      parseCalled = true\n      r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { file: { id: FILE_FAILED, filename: 'broken.pdf', file_size: 1000, file_type: 'application/pdf', parse_status: 'parsing' }, message: 'ok' } }) })\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n\n    // Find the failed file card\n    const card = page.locator('text=broken.pdf').first()\n    await card.waitFor({ state: 'visible', timeout: 10_000 })\n\n    // The reparse button (RefreshCw icon) should be visible on the card\n    const cardContainer = card.locator('xpath=ancestor::div[contains(@class,\"w-72\")]')\n    const reparseBtn = cardContainer.locator('button').filter({ has: page.locator('svg.lucide-refresh-cw') })\n    await expect(reparseBtn).toBeVisible({ timeout: 3_000 })\n\n    // Click reparse\n    await reparseBtn.click()\n    expect(parseCalled).toBe(true)\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/file-preview-scrollbar.spec.ts",
    "content": "/**\n * E2E test: FilePreviewModal scrollbar fix\n *\n * Verifies that the PDF/file preview modal does not have nested scroll containers\n * (which caused double vertical scrollbars and a horizontal scrollbar).\n */\n\nimport { test, expect } from '@playwright/test'\n\ntest.use({ baseURL: process.env.BASE_URL || 'http://localhost:3000' })\n\nconst LONG_MARKDOWN = '# Test Document\\n\\n' + 'Lorem ipsum dolor sit amet. '.repeat(200) +\n  '\\n\\n```\\n' + 'const x = 1; // a very long code line '.repeat(5) + '\\n```\\n'\n\nconst PROJECT_ID = 'mock-proj-001'\nconst FILE_ID = 'mock-file-001'\n\ntest.describe('FilePreviewModal scrollbar fix (mocked)', () => {\n  test.setTimeout(60_000)\n\n  test('modal should not have nested scroll containers', async ({ page }) => {\n    // Mock settings API (prevents help modal from blocking)\n    await page.route('**/api/settings', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: { ai_provider_format: 'gemini', google_api_key: 'fake' }\n        })\n      })\n    })\n\n    // Mock project API\n    await page.route(`**/api/projects/${PROJECT_ID}`, async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: {\n            id: PROJECT_ID,\n            project_id: PROJECT_ID,\n            title: 'Test Project',\n            status: 'OUTLINE_GENERATED',\n            creation_type: 'idea',\n            pages: [{ id: 'p1', page_id: 'p1', title: 'Page 1', order_index: 0, outline_content: { title: 'Page 1', points: ['point 1'] } }]\n          }\n        })\n      })\n    })\n\n    // Mock pages API\n    await page.route(`**/api/projects/${PROJECT_ID}/pages`, async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: { pages: [{ id: 'p1', title: 'Page 1', order_index: 0 }] }\n        })\n      })\n    })\n\n    // Mock reference files list (actual endpoint: /api/reference-files/project/:id)\n    await page.route(`**/api/reference-files/project/${PROJECT_ID}`, async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: {\n            files: [{\n              id: FILE_ID,\n              filename: 'test-document.pdf',\n              file_size: 12345,\n              file_type: 'application/pdf',\n              parse_status: 'completed',\n            }]\n          }\n        })\n      })\n    })\n\n    // Mock single file detail (for preview)\n    await page.route(`**/api/reference-files/${FILE_ID}`, async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: {\n            file: {\n              id: FILE_ID,\n              filename: 'test-document.pdf',\n              file_size: 12345,\n              file_type: 'application/pdf',\n              parse_status: 'completed',\n              markdown_content: LONG_MARKDOWN,\n            }\n          }\n        })\n      })\n    })\n\n    // Navigate to outline editor (correct route: /project/:id/outline)\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n\n    // Click the file card to open preview\n    const fileCard = page.locator('text=test-document.pdf').first()\n    await fileCard.waitFor({ state: 'visible', timeout: 10_000 })\n    await fileCard.click()\n\n    // Wait for the file preview modal (second dialog, after any help modal)\n    const modal = page.locator('[role=\"dialog\"]').last()\n    await expect(modal).toBeVisible({ timeout: 5_000 })\n\n    // KEY ASSERTION: between the dialog and the prose, there should be only ONE\n    // scrollable ancestor (the Modal's own content area), not two (which was the bug).\n    const proseDiv = modal.locator('.prose')\n    await expect(proseDiv).toBeVisible()\n\n    const scrollableAncestorCount = await proseDiv.evaluate(el => {\n      let count = 0\n      let node = el.parentElement\n      while (node && !node.hasAttribute('role')) {\n        const ov = getComputedStyle(node).overflowY\n        if (ov === 'auto' || ov === 'scroll') count++\n        node = node.parentElement\n      }\n      return count\n    })\n    expect(scrollableAncestorCount).toBe(1)\n\n    // Prose itself should hide horizontal overflow\n    await expect(proseDiv).toHaveCSS('overflow-x', 'hidden')\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/generation-fail.spec.ts",
    "content": "import { test, expect, Page } from '@playwright/test'\n\nasync function setupFailureMocks(page: Page, projectId: string, failUrl: string) {\n  // Routes don't overlap in practice; order doesn't matter here\n  await page.route(`**/api/projects/${projectId}`, async (route) => {\n    if (route.request().method() === 'DELETE') {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ success: true }),\n      })\n    } else {\n      await route.continue()\n    }\n  })\n\n  await page.route(failUrl, async (route) => {\n    await route.fulfill({\n      status: 503,\n      contentType: 'application/json',\n      body: JSON.stringify({ error: { message: 'AI service unavailable' } }),\n    })\n  })\n\n  await page.route('**/api/projects', async (route) => {\n    if (route.request().method() === 'POST') {\n      await route.fulfill({\n        status: 201,\n        contentType: 'application/json',\n        body: JSON.stringify({ success: true, data: { project_id: projectId } }),\n      })\n    } else {\n      await route.continue()\n    }\n  })\n}\n\ntest.describe('Generation failure handling', () => {\n  test.beforeEach(async ({ page }) => {\n    await page.addInitScript(() => localStorage.setItem('hasSeenHelpModal', 'true'))\n    await page.goto('/')\n  })\n\n  test('outline: stays on Home when generateOutline fails', async ({ page }) => {\n    await setupFailureMocks(page, 'test-outline-fail', '**/api/projects/*/generate/outline')\n\n    await page.locator('button').filter({ hasText: /从大纲生成|From Outline/i }).click()\n\n    const editor = page.locator('[role=\"textbox\"][contenteditable=\"true\"]').first()\n    await editor.click()\n    await editor.pressSequentially('Slide 1: Intro\\nSlide 2: Content\\nSlide 3: Summary', { delay: 10 })\n\n    await page.locator('button').filter({ hasText: /下一步|Next/i }).click()\n\n    await expect(page.getByText(/AI service unavailable/i)).toBeVisible({ timeout: 15000 })\n    expect(page.url()).not.toContain('/outline')\n    expect(page.url()).not.toContain('/detail')\n  })\n\n  test('description: stays on Home when generateFromDescription fails', async ({ page }) => {\n    await setupFailureMocks(page, 'test-desc-fail', '**/api/projects/*/generate/from-description')\n\n    await page.locator('button').filter({ hasText: /从描述生成|From Description/i }).click()\n\n    const editor = page.locator('[role=\"textbox\"][contenteditable=\"true\"]').first()\n    await editor.click()\n    await editor.pressSequentially('page1 intro page2 content page3 summary', { delay: 10 })\n\n    await page.locator('button').filter({ hasText: /下一步|Next/i }).click()\n\n    await expect(page.getByText(/AI service unavailable/i)).toBeVisible({ timeout: 15000 })\n    expect(page.url()).not.toContain('/detail')\n    expect(page.url()).not.toContain('/outline')\n  })\n})\n\n// --- Integration tests (real backend) ---\n\ntest.describe('Generation failure rollback (integration)', () => {\n  test.beforeEach(async ({ page }) => {\n    await page.addInitScript(() => localStorage.setItem('hasSeenHelpModal', 'true'))\n    await page.goto('/')\n  })\n\n  test('outline: failed generation deletes project and stays on Home', async ({ page }) => {\n    // Capture project_id from creation response (no mock — real backend)\n    let projectId: string | null = null\n    page.on('response', async (resp) => {\n      if (resp.url().includes('/api/projects') && resp.request().method() === 'POST' && resp.status() === 201) {\n        const body = await resp.json().catch(() => null)\n        projectId = body?.data?.project_id ?? null\n      }\n    })\n\n    await page.locator('button').filter({ hasText: /从大纲生成|From Outline/i }).click()\n    const editor = page.locator('[role=\"textbox\"][contenteditable=\"true\"]').first()\n    await editor.click()\n    await editor.pressSequentially('Slide 1: Intro\\nSlide 2: Body\\nSlide 3: End', { delay: 10 })\n\n    await page.locator('button').filter({ hasText: /下一步|Next/i }).click()\n\n    // Wait for either error toast (failure) or navigation (success)\n    const errorLocator = page.locator('[class*=\"error\"], [class*=\"toast\"], [role=\"alert\"]').first()\n    const navigated = page.waitForURL(/\\/(outline|detail)/, { timeout: 60000 }).then(() => 'navigated' as const)\n    const errored = errorLocator.waitFor({ timeout: 60000 }).then(() => 'errored' as const)\n    const result = await Promise.race([navigated, errored])\n\n    if (result === 'errored') {\n      // Generation failed → should stay on Home\n      expect(page.url()).not.toContain('/outline')\n      expect(page.url()).not.toContain('/detail')\n\n      // Verify project was rolled back (deleted) — fetch inside browser goes through Vite proxy\n      if (projectId) {\n        const status = await page.evaluate(async (id) => {\n          const r = await fetch(`/api/projects/${id}`)\n          return r.status\n        }, projectId)\n        expect(status).toBe(404)\n      }\n    } else {\n      expect(page.url()).toMatch(/\\/(outline|detail)/)\n    }\n  })\n\n  test('description: failed generation deletes project and stays on Home', async ({ page }) => {\n    let projectId: string | null = null\n    page.on('response', async (resp) => {\n      if (resp.url().includes('/api/projects') && resp.request().method() === 'POST' && resp.status() === 201) {\n        const body = await resp.json().catch(() => null)\n        projectId = body?.data?.project_id ?? null\n      }\n    })\n\n    await page.locator('button').filter({ hasText: /从描述生成|From Description/i }).click()\n    const editor = page.locator('[role=\"textbox\"][contenteditable=\"true\"]').first()\n    await editor.click()\n    await editor.pressSequentially('page1 intro page2 content page3 summary', { delay: 10 })\n\n    await page.locator('button').filter({ hasText: /下一步|Next/i }).click()\n\n    const errorLocator = page.locator('[class*=\"error\"], [class*=\"toast\"], [role=\"alert\"]').first()\n    const navigated = page.waitForURL(/\\/(outline|detail)/, { timeout: 60000 }).then(() => 'navigated' as const)\n    const errored = errorLocator.waitFor({ timeout: 60000 }).then(() => 'errored' as const)\n    const result = await Promise.race([navigated, errored])\n\n    if (result === 'errored') {\n      expect(page.url()).not.toContain('/outline')\n      expect(page.url()).not.toContain('/detail')\n\n      if (projectId) {\n        const status = await page.evaluate(async (id) => {\n          const r = await fetch(`/api/projects/${id}`)\n          return r.status\n        }, projectId)\n        expect(status).toBe(404)\n      }\n    } else {\n      expect(page.url()).toMatch(/\\/(outline|detail)/)\n    }\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/generation-requirements.spec.ts",
    "content": "import { test, expect } from '@playwright/test'\n\nconst PROJECT_ID = 'mock-gen-req-project'\n\nconst mockProject = (overrides: Record<string, unknown> = {}) => ({\n  project_id: PROJECT_ID,\n  status: 'OUTLINE_GENERATED',\n  idea_prompt: 'Test idea',\n  creation_type: 'idea',\n  outline_requirements: '',\n  description_requirements: '',\n  pages: [\n    {\n      page_id: 'page-1',\n      order_index: 0,\n      outline_content: { title: 'Page One', points: ['Point A'] },\n      description_content: { text: 'Page one description', generated_at: '2025-01-01' },\n      status: 'DESCRIPTION_GENERATED',\n    },\n  ],\n  created_at: '2025-01-01T00:00:00',\n  updated_at: '2025-01-01T00:00:00',\n  ...overrides,\n})\n\n/** Locate the outline requirements editor (contentEditable inside data-testid wrapper) */\nconst outlineReqEditor = (page: import('@playwright/test').Page) =>\n  page.locator('[data-testid=\"outline-requirements-textarea\"] [contenteditable=\"true\"]').first()\n\n/** Locate the description requirements editor */\nconst descReqEditor = (page: import('@playwright/test').Page) =>\n  page.locator('[data-testid=\"desc-requirements-textarea\"] [contenteditable=\"true\"]').first()\n\n/** Locate the outline requirements toggle button */\nconst outlineReqToggle = (page: import('@playwright/test').Page) =>\n  page.locator('[data-testid=\"outline-requirements-toggle\"]').first()\n\n/** Locate the description requirements toggle button */\nconst descReqToggle = (page: import('@playwright/test').Page) =>\n  page.locator('[data-testid=\"desc-requirements-toggle\"]').first()\n\n/** Clear and type into a contentEditable element */\nasync function clearAndType(editor: import('@playwright/test').Locator, text: string) {\n  await editor.focus()\n  await editor.press('Control+a')\n  if (text) {\n    await editor.page().keyboard.insertText(text)\n  } else {\n    await editor.press('Backspace')\n  }\n}\n\n// ── Mock tests ──────────────────────────────────────────────────────\n\ntest.describe('Generation requirements - OutlineEditor (mock)', () => {\n  test('shows collapsible requirements section that auto-saves', async ({ page }) => {\n    let savedPayload: Record<string, unknown> | null = null\n\n    await page.route(`**/api/projects/${PROJECT_ID}`, async (route) => {\n      if (route.request().method() === 'PUT') {\n        savedPayload = route.request().postDataJSON()\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ success: true, data: mockProject(savedPayload) }),\n        })\n      } else {\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ success: true, data: mockProject() }),\n        })\n      }\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n    await page.waitForLoadState('networkidle')\n\n    // Requirements toggle should exist\n    const toggle = outlineReqToggle(page)\n    await expect(toggle).toBeVisible()\n\n    // Expand it\n    await toggle.click()\n\n    // Editor should now be visible\n    const editor = outlineReqEditor(page)\n    await expect(editor).toBeVisible()\n\n    // Type requirements\n    await clearAndType(editor, '限制在5页以内')\n\n    // Blur to trigger save\n    await page.locator('header').first().click()\n\n    // Wait for save\n    await expect.poll(() => savedPayload, { timeout: 5000 }).not.toBeNull()\n    expect(savedPayload).toHaveProperty('outline_requirements', '限制在5页以内')\n  })\n\n  test('auto-expands when requirements exist', async ({ page }) => {\n    await page.route(`**/api/projects/${PROJECT_ID}`, async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: mockProject({ outline_requirements: 'Some requirement' }),\n        }),\n      })\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n    await page.waitForLoadState('networkidle')\n\n    // Should auto-expand when there are existing requirements\n    const editor = outlineReqEditor(page)\n    await expect(editor).toBeVisible()\n    await expect(editor).toContainText('Some requirement')\n  })\n})\n\ntest.describe('Generation requirements - DetailEditor (mock)', () => {\n  test('shows collapsible requirements section that auto-saves', async ({ page }) => {\n    let savedPayload: Record<string, unknown> | null = null\n\n    await page.route(`**/api/projects/${PROJECT_ID}`, async (route) => {\n      if (route.request().method() === 'PUT') {\n        savedPayload = route.request().postDataJSON()\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ success: true, data: mockProject(savedPayload) }),\n        })\n      } else {\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ success: true, data: mockProject() }),\n        })\n      }\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/detail`)\n    await page.waitForLoadState('networkidle')\n\n    // Requirements toggle should exist\n    const toggle = descReqToggle(page)\n    await expect(toggle).toBeVisible()\n\n    // Expand it\n    await toggle.click()\n\n    // Editor should be visible\n    const editor = descReqEditor(page)\n    await expect(editor).toBeVisible()\n\n    // Type requirements\n    await clearAndType(editor, '每页不超过50字')\n\n    // Blur to trigger save\n    await page.locator('header').first().click()\n\n    // Wait for save\n    await expect.poll(() => savedPayload, { timeout: 5000 }).not.toBeNull()\n    expect(savedPayload).toHaveProperty('description_requirements', '每页不超过50字')\n  })\n\n  test('auto-expands when requirements exist', async ({ page }) => {\n    await page.route(`**/api/projects/${PROJECT_ID}`, async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: mockProject({ description_requirements: 'Existing desc requirement' }),\n        }),\n      })\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/detail`)\n    await page.waitForLoadState('networkidle')\n\n    // Should auto-expand with existing content\n    const editor = descReqEditor(page)\n    await expect(editor).toBeVisible()\n    await expect(editor).toContainText('Existing desc requirement')\n  })\n})\n\n// ── Integration tests ───────────────────────────────────────────────\n\ntest.describe('Generation requirements (integration)', () => {\n  let projectId: string\n\n  test.beforeEach(async ({ request }) => {\n    const res = await request.post('/api/projects', {\n      data: { idea_prompt: 'Integration test for requirements', creation_type: 'idea' },\n    })\n    const body = await res.json()\n    projectId = body.data.project_id\n  })\n\n  test('outline requirements: save, reload, verify persisted', async ({ page }) => {\n    await page.goto(`/project/${projectId}/outline`)\n    await page.waitForLoadState('networkidle')\n\n    // Expand requirements section\n    const toggle = outlineReqToggle(page)\n    await toggle.click()\n\n    // Type requirements\n    const editor = outlineReqEditor(page)\n    await expect(editor).toBeVisible()\n    await clearAndType(editor, '限制在8页以内')\n\n    // Blur and wait for save\n    const savePromise = page.waitForResponse(\n      (resp) => resp.url().includes(`/api/projects/${projectId}`) && resp.request().method() === 'PUT'\n    )\n    await page.locator('header').first().click()\n    await savePromise\n\n    // Reload and verify persisted\n    await page.reload()\n    await page.waitForLoadState('networkidle')\n\n    // Should auto-expand since there's content\n    const editorAfter = outlineReqEditor(page)\n    await expect(editorAfter).toBeVisible()\n    await expect(editorAfter).toContainText('限制在8页以内')\n  })\n\n  test('description requirements: save, reload, verify persisted', async ({ page, request }) => {\n    // Create a page so detail editor has content\n    const outlineRes = await request.post(`/api/projects/${projectId}/pages`, {\n      data: {\n        outline_content: { title: 'Test Page', points: ['Point 1'] },\n        order_index: 0,\n      },\n    })\n    expect(outlineRes.ok()).toBeTruthy()\n\n    await page.goto(`/project/${projectId}/detail`)\n    await page.waitForLoadState('networkidle')\n\n    // Expand requirements section\n    const toggle = descReqToggle(page)\n    await toggle.click()\n\n    // Type requirements\n    const editor = descReqEditor(page)\n    await expect(editor).toBeVisible()\n    await clearAndType(editor, '多使用数据和案例')\n\n    // Blur and wait for save\n    const savePromise = page.waitForResponse(\n      (resp) => resp.url().includes(`/api/projects/${projectId}`) && resp.request().method() === 'PUT'\n    )\n    await page.locator('header').first().click()\n    await savePromise\n\n    // Reload and verify persisted\n    await page.reload()\n    await page.waitForLoadState('networkidle')\n\n    const editorAfter = descReqEditor(page)\n    await expect(editorAfter).toBeVisible()\n    await expect(editorAfter).toContainText('多使用数据和案例')\n  })\n\n  test('clearing requirements saves empty string', async ({ page }) => {\n    // First set a requirement via API\n    const setRes = await page.request.put(`/api/projects/${projectId}`, {\n      data: { outline_requirements: 'Temporary requirement' },\n    })\n    expect(setRes.ok()).toBeTruthy()\n\n    await page.goto(`/project/${projectId}/outline`)\n    await page.waitForLoadState('networkidle')\n\n    // Should auto-expand with existing content\n    const editor = outlineReqEditor(page)\n    await expect(editor).toBeVisible()\n    await expect(editor).toContainText('Temporary requirement')\n\n    // Clear it\n    await clearAndType(editor, '')\n\n    // Blur and wait for save\n    const savePromise = page.waitForResponse(\n      (resp) => resp.url().includes(`/api/projects/${projectId}`) && resp.request().method() === 'PUT'\n    )\n    await page.locator('header').first().click()\n    await savePromise\n\n    // Reload and verify cleared\n    await page.reload()\n    await page.waitForLoadState('networkidle')\n\n    // After clearing, the toggle should still exist\n    const toggle = outlineReqToggle(page)\n    await expect(toggle).toBeVisible()\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/helpers/seed-project.ts",
    "content": "/**\n * Shared helper to create projects with real images for E2E testing.\n * Bypasses AI image generation by placing fixture images on disk + updating DB directly.\n *\n * Usage:\n *   - Playwright: import { seedProjectWithImages } from './helpers/seed-project'\n *   - CLI:        npx tsx frontend/e2e/helpers/seed-project.ts [PAGE_COUNT]\n */\nimport { execSync } from 'child_process'\nimport * as fs from 'fs'\nimport * as path from 'path'\n\nconst cwd = process.cwd()\nconst FRONTEND_DIR = cwd.endsWith('frontend') ? cwd : path.join(cwd, 'frontend')\nconst PROJECT_ROOT = path.resolve(FRONTEND_DIR, '..')\nconst DB_PATH = path.join(PROJECT_ROOT, 'backend', 'instance', 'database.db')\nconst UPLOADS = path.join(PROJECT_ROOT, 'uploads')\nconst FIXTURES = path.join(FRONTEND_DIR, 'e2e', 'fixtures')\n\nfunction sql(query: string) {\n  execSync(`sqlite3 -cmd \".timeout 5000\" \"${DB_PATH}\" \"${query.replace(/\"/g, '\\\\\"')}\"`)\n}\n\n/** Get fixture image path (cycles through slide_1.jpg, slide_2.jpg, slide_3.jpg) */\nfunction getFixtureImage(index: number): string {\n  const num = (index % 3) + 1\n  return path.join(FIXTURES, `slide_${num}.jpg`)\n}\n\nexport interface SeededProject {\n  projectId: string\n  pageIds: string[]\n}\n\n/**\n * Create a project with N pages, each having a real image on disk.\n * @param baseUrl - Backend base URL, e.g. \"http://localhost:5441\"\n */\nexport async function seedProjectWithImages(\n  baseUrl: string,\n  pageCount = 1\n): Promise<SeededProject> {\n  const post = async (urlPath: string, body: object) => {\n    const resp = await fetch(`${baseUrl}${urlPath}`, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify(body),\n    })\n    return resp.json()\n  }\n\n  const projectId = (await post('/api/projects', {\n    creation_type: 'idea', idea_prompt: 'e2e test', template_style: 'default',\n  })).data?.project_id\n\n  const pageIds: string[] = []\n  fs.mkdirSync(path.join(UPLOADS, projectId, 'pages'), { recursive: true })\n\n  for (let i = 0; i < pageCount; i++) {\n    const pageId = (await post(`/api/projects/${projectId}/pages`, {\n      order_index: i, outline_content: { title: `Slide ${i + 1}` },\n    })).data?.page_id\n    pageIds.push(pageId)\n\n    const rel = `${projectId}/pages/${pageId}_v1.jpg`\n    fs.copyFileSync(getFixtureImage(i), path.join(UPLOADS, rel))\n    sql(`UPDATE pages SET generated_image_path='${rel}', status='COMPLETED' WHERE id='${pageId}'`)\n  }\n\n  sql(`UPDATE projects SET status='IMAGES_GENERATED' WHERE id='${projectId}'`)\n  return { projectId, pageIds }\n}\n\n// CLI entry point: npx tsx frontend/e2e/helpers/seed-project.ts [PAGE_COUNT]\nif (process.argv[1]?.includes('seed-project')) {\n  const { createHash } = await import('crypto')\n  const pageCount = parseInt(process.argv[2] || '3', 10)\n\n  // Auto-detect backend port (same MD5 logic as app.py)\n  const envFile = path.join(PROJECT_ROOT, '.env')\n  let port = '5000'\n  if (fs.existsSync(envFile)) {\n    const m = fs.readFileSync(envFile, 'utf8').match(/^BACKEND_PORT=(\\d+)/m)\n    if (m) port = m[1]\n  }\n  if (port === '5000') {\n    const basename = path.basename(PROJECT_ROOT)\n    const offset = parseInt(createHash('md5').update(basename).digest('hex').slice(0, 8), 16) % 500\n    port = String(5000 + offset)\n  }\n\n  const baseUrl = `http://localhost:${port}`\n  const res = await seedProjectWithImages(baseUrl, pageCount)\n  const fport = parseInt(port) - 2000\n  console.log(`Project: ${res.projectId}`)\n  console.log(`Preview: http://localhost:${fport}/project/${res.projectId}/preview`)\n}\n"
  },
  {
    "path": "frontend/e2e/history-pagination.spec.ts",
    "content": "/**\n * E2E tests for history page pagination.\n *\n * Mock tests: verify pagination UI renders correctly, page navigation works,\n * and correct API params are sent.\n *\n * Integration test: create enough projects to span multiple pages,\n * verify pagination controls appear and navigate correctly.\n */\nimport { test, expect } from '@playwright/test'\n\nconst PAGE_SIZE = 5\n\nfunction makeProject(index: number) {\n  const id = `proj-${String(index).padStart(3, '0')}`\n  const label = `P-${String(index).padStart(2, '0')}`\n  return {\n    id,\n    project_id: id,\n    idea_prompt: label,\n    status: 'DRAFT',\n    created_at: new Date(Date.now() - index * 60000).toISOString(),\n    updated_at: new Date(Date.now() - index * 60000).toISOString(),\n    pages: [\n      {\n        id: `page-${id}`,\n        page_id: `page-${id}`,\n        title: label,\n        order_index: 0,\n        status: 'DRAFT',\n        outline_content: { title: label, points: [] },\n      },\n    ],\n  }\n}\n\nasync function setupMockRoutes(\n  page: import('@playwright/test').Page,\n  totalProjects: number\n) {\n  // Mock access code check\n  await page.route('**/api/access-code/check', async (route) => {\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({ success: true, data: { enabled: false } }),\n    })\n  })\n\n  // Mock projects list API — handles both with and without query string\n  await page.route('**/api/projects**', async (route) => {\n    const req = route.request()\n    if (req.method() !== 'GET' || req.url().includes('/api/projects/')) {\n      await route.fallback()\n      return\n    }\n\n    const url = new URL(req.url())\n    const limit = parseInt(url.searchParams.get('limit') || String(PAGE_SIZE))\n    const offset = parseInt(url.searchParams.get('offset') || '0')\n\n    const allProjects = Array.from({ length: totalProjects }, (_, i) =>\n      makeProject(i + 1)\n    )\n    const sliced = allProjects.slice(offset, offset + limit)\n\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({\n        success: true,\n        data: {\n          projects: sliced,\n          total: totalProjects,\n          limit,\n          offset,\n        },\n      }),\n    })\n  })\n}\n\n// ───────────────── Mock tests ─────────────────\n\ntest.describe('History pagination — mock', () => {\n  test('should not show pagination when projects fit on one page', async ({\n    page,\n  }) => {\n    await setupMockRoutes(page, 3) // 3 < PAGE_SIZE, no pagination\n    await page.goto('/history')\n    await expect(page.getByRole('heading', { name: 'P-01', exact: true })).toBeVisible()\n    await expect(page.locator('nav[aria-label=\"Pagination\"]')).not.toBeVisible()\n  })\n\n  test('should show pagination when projects exceed one page', async ({\n    page,\n  }) => {\n    await setupMockRoutes(page, 12) // 3 pages: 5 + 5 + 2\n    await page.goto('/history')\n    await expect(page.getByRole('heading', { name: 'P-01', exact: true })).toBeVisible()\n    const pagination = page.locator('nav[aria-label=\"Pagination\"]')\n    await expect(pagination).toBeVisible()\n    await expect(\n      pagination.locator('button[aria-current=\"page\"]')\n    ).toHaveText('1')\n    await expect(pagination.locator('button:text-is(\"3\")')).toBeVisible()\n  })\n\n  test('should navigate to next page and load correct projects', async ({\n    page,\n  }) => {\n    await setupMockRoutes(page, 12)\n    await page.goto('/history')\n    await expect(page.getByRole('heading', { name: 'P-01', exact: true })).toBeVisible()\n\n    const pagination = page.locator('nav[aria-label=\"Pagination\"]')\n    await pagination.locator('button:text-is(\"2\")').click()\n\n    // Page 2: P-06 to P-10\n    await expect(page.getByRole('heading', { name: 'P-06', exact: true })).toBeVisible()\n    await expect(page.getByRole('heading', { name: 'P-01', exact: true })).not.toBeVisible()\n    await expect(\n      pagination.locator('button[aria-current=\"page\"]')\n    ).toHaveText('2')\n  })\n\n  test('should navigate to last page with fewer items', async ({ page }) => {\n    await setupMockRoutes(page, 12) // last page has 2 items (P-11, P-12)\n    await page.goto('/history')\n    await expect(page.getByRole('heading', { name: 'P-01', exact: true })).toBeVisible()\n\n    const pagination = page.locator('nav[aria-label=\"Pagination\"]')\n    await pagination.locator('button:text-is(\"3\")').click()\n\n    await expect(page.getByRole('heading', { name: 'P-11', exact: true })).toBeVisible()\n    await expect(page.getByRole('heading', { name: 'P-12', exact: true })).toBeVisible()\n    await expect(\n      pagination.locator('button[aria-current=\"page\"]')\n    ).toHaveText('3')\n  })\n\n  test('previous/next buttons should work correctly', async ({ page }) => {\n    await setupMockRoutes(page, 15) // 3 pages\n    await page.goto('/history')\n    await expect(page.getByRole('heading', { name: 'P-01', exact: true })).toBeVisible()\n\n    const pagination = page.locator('nav[aria-label=\"Pagination\"]')\n    const prevButton = pagination.locator('button[aria-label=\"Previous page\"]')\n    const nextButton = pagination.locator('button[aria-label=\"Next page\"]')\n\n    await expect(prevButton).toBeDisabled()\n\n    await nextButton.click()\n    await expect(page.getByRole('heading', { name: 'P-06', exact: true })).toBeVisible()\n\n    await expect(prevButton).not.toBeDisabled()\n\n    await prevButton.click()\n    await expect(page.getByRole('heading', { name: 'P-01', exact: true })).toBeVisible()\n  })\n\n  test('should send correct limit and offset params in API request', async ({\n    page,\n  }) => {\n    const requests: string[] = []\n    await setupMockRoutes(page, 12)\n\n    page.on('request', (req) => {\n      if (req.url().includes('/api/projects')) {\n        requests.push(req.url())\n      }\n    })\n\n    await page.goto('/history')\n    await expect(page.getByRole('heading', { name: 'P-01', exact: true })).toBeVisible()\n\n    const firstReq = requests.find((r) => r.includes('limit='))\n    expect(firstReq).toContain('limit=5')\n    expect(firstReq).toContain('offset=0')\n\n    requests.length = 0\n    const pagination = page.locator('nav[aria-label=\"Pagination\"]')\n    await pagination.locator('button:text-is(\"2\")').click()\n    await expect(page.getByRole('heading', { name: 'P-06', exact: true })).toBeVisible()\n\n    const secondReq = requests.find((r) => r.includes('limit='))\n    expect(secondReq).toContain('limit=5')\n    expect(secondReq).toContain('offset=5')\n  })\n})\n\n// ───────────────── Integration test ─────────────────\n\ntest.describe('History pagination — integration', () => {\n  const frontendUrl = process.env.BASE_URL || 'http://localhost:3000'\n  const frontendPort = parseInt(new URL(frontendUrl).port || '3000')\n  const BACKEND_URL = `http://localhost:${frontendPort + 2000}`\n\n  async function createSimpleProject(index: number): Promise<string> {\n    const resp = await fetch(`${BACKEND_URL}/api/projects`, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({ creation_type: 'idea', idea_prompt: `PagTest-${String(index).padStart(2, '0')}` }),\n    })\n    const json = await resp.json()\n    return json.data?.project_id\n  }\n\n  async function deleteProject(projectId: string) {\n    await fetch(`${BACKEND_URL}/api/projects/${projectId}`, { method: 'DELETE' })\n  }\n\n  test('pagination works with real backend data', async ({ page }) => {\n    // Create 8 projects (enough for 2 pages with PAGE_SIZE=5)\n    const projectIds: string[] = []\n    for (let i = 0; i < 8; i++) {\n      const id = await createSimpleProject(i + 1)\n      if (id) projectIds.push(id)\n    }\n    expect(projectIds.length).toBe(8)\n\n    try {\n      await page.goto('/history')\n      await page.waitForLoadState('networkidle')\n\n      await expect(page.locator('text=/历史项目|Project History/')).toBeVisible({ timeout: 10000 })\n\n      const pagination = page.locator('nav[aria-label=\"Pagination\"]')\n      await expect(pagination).toBeVisible({ timeout: 10000 })\n\n      await expect(\n        pagination.locator('button[aria-current=\"page\"]')\n      ).toHaveText('1')\n\n      await pagination.locator('button:text-is(\"2\")').click()\n      await page.waitForLoadState('networkidle')\n\n      await expect(\n        pagination.locator('button[aria-current=\"page\"]')\n      ).toHaveText('2')\n    } finally {\n      await Promise.all(projectIds.map(id => deleteProject(id)))\n    }\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/image-prompt-ratio.spec.ts",
    "content": "/**\n * Image Prompt Aspect Ratio - Integration E2E Test\n *\n * Verifies that the project's aspect ratio is correctly stored, updated,\n * and passed through to image generation tasks.\n */\nimport { test, expect } from '@playwright/test'\n\nconst BASE = process.env.BASE_URL || 'http://localhost:3000'\nconst API = `http://localhost:${Number(new URL(BASE).port) + 2000}`\n\ntest.describe('Image prompt aspect ratio', () => {\n  let projectId: string\n\n  test.afterEach(async ({ request }) => {\n    if (projectId) {\n      await request.delete(`${API}/api/projects/${projectId}`)\n      projectId = ''\n    }\n  })\n\n  test('project stores and returns custom aspect ratio (4:3)', async ({\n    request,\n  }) => {\n    // Create project with 4:3 ratio\n    const projRes = await request.post(`${API}/api/projects`, {\n      data: {\n        creation_type: 'idea',\n        idea_prompt: 'test ratio storage',\n        image_aspect_ratio: '4:3',\n        page_count: 1,\n      },\n    })\n    expect(projRes.ok()).toBeTruthy()\n    const proj = await projRes.json()\n    projectId = proj.data.project_id\n\n    // Verify it persists on GET\n    const getRes = await request.get(`${API}/api/projects/${projectId}`)\n    expect(getRes.ok()).toBeTruthy()\n    const fetched = await getRes.json()\n    expect(fetched.data.image_aspect_ratio).toBe('4:3')\n  })\n\n  test('project aspect ratio can be updated from 16:9 to 1:1', async ({\n    request,\n  }) => {\n    // Create with default\n    const projRes = await request.post(`${API}/api/projects`, {\n      data: {\n        creation_type: 'idea',\n        idea_prompt: 'test ratio update',\n        page_count: 1,\n      },\n    })\n    expect(projRes.ok()).toBeTruthy()\n    const proj = await projRes.json()\n    projectId = proj.data.project_id\n\n    // Verify default is 16:9\n    const getRes = await request.get(`${API}/api/projects/${projectId}`)\n    const fetched = await getRes.json()\n    expect(fetched.data.image_aspect_ratio).toBe('16:9')\n\n    // Update to 1:1\n    const updateRes = await request.put(\n      `${API}/api/projects/${projectId}`,\n      { data: { image_aspect_ratio: '1:1' } }\n    )\n    expect(updateRes.ok()).toBeTruthy()\n\n    // Verify update persisted\n    const getRes2 = await request.get(`${API}/api/projects/${projectId}`)\n    const fetched2 = await getRes2.json()\n    expect(fetched2.data.image_aspect_ratio).toBe('1:1')\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/image-queued-status.spec.ts",
    "content": "import { test, expect, Page } from '@playwright/test'\n\nconst PROJECT_ID = 'queued-status-mock'\nconst PAGE_IDS = ['p-1', 'p-2', 'p-3', 'p-4']\n\nfunction makePage(id: string, idx: number, status: string, hasImage: boolean) {\n  return {\n    page_id: id,\n    order_index: idx,\n    outline_content: { title: `Slide ${idx + 1}`, points: ['pt'] },\n    description_content: { text: `Desc ${idx + 1}` },\n    generated_image_url: hasImage ? `/files/${PROJECT_ID}/pages/${id}_v1.jpg` : null,\n    status,\n    created_at: '2026-01-01T00:00:00',\n    updated_at: '2026-01-01T00:00:00',\n  }\n}\n\nfunction projectJson(pages: ReturnType<typeof makePage>[], projectStatus = 'COMPLETED') {\n  return {\n    success: true,\n    data: {\n      id: PROJECT_ID,\n      creation_type: 'idea',\n      idea_prompt: 'test',\n      status: projectStatus,\n      template_style: 'default',\n      image_aspect_ratio: '16:9',\n      pages,\n      created_at: '2026-01-01T00:00:00',\n      updated_at: '2026-01-01T00:00:00',\n    },\n  }\n}\n\nasync function mockCommonRoutes(page: Page) {\n  await page.route('**/api/access-code/check', (r) =>\n    r.fulfill({ status: 200, contentType: 'application/json', body: '{\"success\":true,\"data\":{\"enabled\":false}}' }))\n  await page.route('**/api/user-templates', (r) =>\n    r.fulfill({ status: 200, contentType: 'application/json', body: '{\"success\":true,\"data\":{\"templates\":[]}}' }))\n  await page.route('**/api/projects/*/pages/*/image-versions', (r) =>\n    r.fulfill({ status: 200, contentType: 'application/json', body: '{\"success\":true,\"data\":{\"versions\":[]}}' }))\n  await page.route('**/files/**', (r) =>\n    r.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from([0xff, 0xd8, 0xff, 0xe0]) }))\n}\n\n// ─── Mock tests ───\n\ntest.describe('QUEUED status during batch image generation (mock)', () => {\n  test.beforeEach(async ({ page }) => {\n    await page.addInitScript(() => localStorage.setItem('hasSeenHelpModal', 'true'))\n  })\n\n  test('badges show QUEUED status for pages waiting in queue', async ({ page }) => {\n    await mockCommonRoutes(page)\n\n    // Mix of statuses: 1 generating, 3 queued (simulating batch generation with concurrency limit)\n    const pages = [\n      makePage('p-1', 0, 'GENERATING', false),\n      makePage('p-2', 1, 'QUEUED', false),\n      makePage('p-3', 2, 'QUEUED', false),\n      makePage('p-4', 3, 'QUEUED', false),\n    ]\n    await page.route(`**/api/projects/${PROJECT_ID}`, (r) => {\n      if (r.request().method() === 'GET') {\n        return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(projectJson(pages, 'GENERATING_IMAGES')) })\n      }\n      return r.continue()\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/preview`)\n    const badges = page.locator('[data-testid=\"status-badge\"]')\n    await expect(badges.first()).toBeVisible({ timeout: 10000 })\n\n    // First page should be GENERATING\n    await expect(badges.nth(0)).toHaveAttribute('data-status', 'GENERATING')\n    // Remaining pages should be QUEUED\n    for (let i = 1; i < 4; i++) {\n      await expect(badges.nth(i)).toHaveAttribute('data-status', 'QUEUED')\n    }\n  })\n\n  test('badges transition from QUEUED → GENERATING → COMPLETED', async ({ page }) => {\n    await mockCommonRoutes(page)\n\n    // Phase 1: all pages QUEUED\n    // Phase 2: 2 generating, 2 queued\n    // Phase 3: all completed\n    let phase: 'queued' | 'partial' | 'completed' = 'queued'\n\n    await page.route(`**/api/projects/${PROJECT_ID}`, (r) => {\n      if (r.request().method() !== 'GET') return r.continue()\n      if (phase === 'queued') {\n        const pages = PAGE_IDS.map((id, i) => makePage(id, i, 'QUEUED', false))\n        return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(projectJson(pages, 'GENERATING_IMAGES')) })\n      }\n      if (phase === 'partial') {\n        const pages = [\n          makePage('p-1', 0, 'GENERATING', false),\n          makePage('p-2', 1, 'GENERATING', false),\n          makePage('p-3', 2, 'QUEUED', false),\n          makePage('p-4', 3, 'QUEUED', false),\n        ]\n        return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(projectJson(pages, 'GENERATING_IMAGES')) })\n      }\n      const pages = PAGE_IDS.map((id, i) => makePage(id, i, 'COMPLETED', true))\n      return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(projectJson(pages)) })\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/preview`)\n    const badges = page.locator('[data-testid=\"status-badge\"]')\n    await expect(badges.first()).toBeVisible({ timeout: 10000 })\n\n    // Phase 1: all QUEUED\n    for (let i = 0; i < 4; i++) {\n      await expect(badges.nth(i)).toHaveAttribute('data-status', 'QUEUED')\n    }\n\n    // Phase 2: partial progress\n    phase = 'partial'\n    await page.evaluate(() => location.reload())\n    await expect(badges.first()).toBeVisible({ timeout: 10000 })\n    await expect(badges.nth(0)).toHaveAttribute('data-status', 'GENERATING')\n    await expect(badges.nth(1)).toHaveAttribute('data-status', 'GENERATING')\n    await expect(badges.nth(2)).toHaveAttribute('data-status', 'QUEUED')\n    await expect(badges.nth(3)).toHaveAttribute('data-status', 'QUEUED')\n\n    // Phase 3: all completed\n    phase = 'completed'\n    await page.evaluate(() => location.reload())\n    await expect(badges.first()).toBeVisible({ timeout: 10000 })\n    for (let i = 0; i < 4; i++) {\n      await expect(badges.nth(i)).toHaveAttribute('data-status', 'COMPLETED')\n    }\n  })\n\n  test('QUEUED pages show skeleton in slide cards', async ({ page }) => {\n    await mockCommonRoutes(page)\n\n    const pages = [\n      makePage('p-1', 0, 'QUEUED', false),\n      makePage('p-2', 1, 'QUEUED', false),\n    ]\n    await page.route(`**/api/projects/${PROJECT_ID}`, (r) => {\n      if (r.request().method() === 'GET') {\n        return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(projectJson(pages, 'GENERATING_IMAGES')) })\n      }\n      return r.continue()\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/preview`)\n\n    // Skeleton elements (animate-shimmer) should be visible for QUEUED pages\n    const skeletons = page.locator('.animate-shimmer')\n    await expect(skeletons.first()).toBeVisible({ timeout: 10000 })\n    const count = await skeletons.count()\n    expect(count).toBeGreaterThanOrEqual(2)\n  })\n})\n\n// ─── Integration test (real backend) ───\n\ntest.describe('QUEUED status (integration)', () => {\n  test.beforeEach(async ({ page }) => {\n    await page.addInitScript(() => localStorage.setItem('hasSeenHelpModal', 'true'))\n  })\n\n  test('batch generate sets pages to QUEUED status in backend', async ({ baseURL }) => {\n    // Create a project with description content via API\n    const createRes = await fetch(`${baseURL}/api/projects`, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({\n        idea_prompt: 'QUEUED status integration test',\n        creation_type: 'idea',\n      }),\n    })\n    const createData = await createRes.json()\n    const projectId = createData.data.project_id || createData.data.id\n\n    // Add description content to pages so they can be generated\n    const projectRes = await fetch(`${baseURL}/api/projects/${projectId}`)\n    const projectData = await projectRes.json()\n    const pages = projectData.data.pages || []\n\n    for (const p of pages) {\n      const pageId = p.page_id || p.id\n      await fetch(`${baseURL}/api/projects/${projectId}/pages/${pageId}`, {\n        method: 'PUT',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          description_content: { text: `Test description for page ${pageId}` },\n        }),\n      })\n    }\n\n    // Trigger batch generation — this should set pages to QUEUED immediately\n    // We use a template_style since we don't have a template image\n    await fetch(`${baseURL}/api/projects/${projectId}`, {\n      method: 'PUT',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({ template_style: 'modern minimalist' }),\n    })\n\n    const genRes = await fetch(`${baseURL}/api/projects/${projectId}/generate/images`, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({ max_workers: 1 }), // Use 1 worker so most pages stay QUEUED\n    })\n\n    if (genRes.status !== 202) {\n      // May fail if no AI key configured — skip gracefully\n      test.skip(true, 'Image generation not available (missing API key or config)')\n      return\n    }\n\n    // Immediately check page statuses — they should be QUEUED\n    const checkRes = await fetch(`${baseURL}/api/projects/${projectId}`)\n    const checkData = await checkRes.json()\n    const checkPages = checkData.data.pages || []\n\n    // At least some pages should be in QUEUED status\n    const queuedPages = checkPages.filter((p: any) => p.status === 'QUEUED')\n    const generatingPages = checkPages.filter((p: any) => p.status === 'GENERATING')\n\n    // All pages should be either QUEUED or GENERATING (the task may have already started for 1 page)\n    for (const p of checkPages) {\n      expect(['QUEUED', 'GENERATING']).toContain(p.status)\n    }\n    // With max_workers=1, at most 1 page should be GENERATING\n    expect(generatingPages.length).toBeLessThanOrEqual(1)\n    // The rest should be QUEUED\n    expect(queuedPages.length).toBeGreaterThanOrEqual(checkPages.length - 1)\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/import-markdown.spec.ts",
    "content": "/**\n * E2E test: Import outline / description from Markdown files\n */\nimport { test, expect } from '@playwright/test'\nimport * as path from 'path'\nimport * as fs from 'fs'\n\ntest.use({ baseURL: process.env.BASE_URL || 'http://localhost:3000' })\n\nconst PROJECT_ID = 'mock-import-proj'\n\nconst mockProject = (pages: any[] = []) => ({\n  success: true,\n  data: {\n    id: PROJECT_ID, project_id: PROJECT_ID, title: 'Test',\n    status: 'OUTLINE_GENERATED', creation_type: 'idea',\n    idea_prompt: 'test', pages,\n  }\n})\n\nconst mockSettings = () => ({\n  success: true,\n  data: { ai_provider_format: 'gemini', google_api_key: 'fake' }\n})\n\n// Unified format fixture (outline + description in one file)\nconst UNIFIED_MD = `# 项目\n\n## 第 1 页: AI简介\n\n> 章节: 引言\n\n**大纲要点：**\n- 什么是人工智能\n- AI的历史\n\n**页面描述：**\n这是关于AI简介的描述内容。\n\n---\n\n## 第 2 页: AI应用\n\n> 章节: 正文\n\n**大纲要点：**\n- 医疗领域\n- 教育领域\n\n**页面描述：**\n这是关于AI应用的描述内容。\n\n---\n`\n\n// Legacy format (no markers) — should still parse\nconst LEGACY_MD = `# 大纲\n\n## 第 1 页: 旧格式页面\n> 章节: 测试\n- 要点一\n- 要点二\n`\n\nconst EMPTY_MD = `# 空文件\n没有任何页面内容\n`\n\ntest.describe('Import Markdown (mocked)', () => {\n  test.setTimeout(60_000)\n\n  let addPageCalls: any[]\n  let projectPages: any[]\n\n  test.beforeEach(async ({ page }) => {\n    addPageCalls = []\n    projectPages = []\n\n    await page.route('**/api/settings', r =>\n      r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockSettings()) }))\n\n    await page.route('**/api/access-code/check', r =>\n      r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { required: false } }) }))\n\n    await page.route('**/api/user-templates', r =>\n      r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: [] }) }))\n\n    await page.route(`**/api/reference-files/project/${PROJECT_ID}`, r =>\n      r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { files: [] } }) }))\n\n    // Project endpoint: returns current pages state\n    await page.route(`**/api/projects/${PROJECT_ID}`, r =>\n      r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockProject(projectPages)) }))\n\n    // Add page endpoint: capture calls and grow projectPages\n    await page.route(`**/api/projects/${PROJECT_ID}/pages`, async (route) => {\n      if (route.request().method() === 'POST') {\n        const body = route.request().postDataJSON()\n        addPageCalls.push(body)\n        const newPage = {\n          id: `page-${addPageCalls.length}`,\n          page_id: `page-${addPageCalls.length}`,\n          order_index: body.order_index ?? projectPages.length,\n          outline_content: body.outline_content || { title: '', points: [] },\n          description_content: body.description_content || null,\n          part: body.part || null,\n          status: 'DRAFT',\n        }\n        projectPages.push(newPage)\n        await route.fulfill({\n          status: 201, contentType: 'application/json',\n          body: JSON.stringify({ success: true, data: newPage })\n        })\n      } else {\n        await route.fulfill({\n          status: 200, contentType: 'application/json',\n          body: JSON.stringify({ success: true, data: { pages: projectPages } })\n        })\n      }\n    })\n  })\n\n  function writeTempFile(name: string, content: string): string {\n    const filePath = path.join('/tmp', name)\n    fs.writeFileSync(filePath, content, 'utf-8')\n    return filePath\n  }\n\n  test('import unified markdown on outline page — preserves outline + description', async ({ page }) => {\n    const mdPath = writeTempFile('test-unified.md', UNIFIED_MD)\n\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n    await page.waitForSelector('button:has-text(\"导入\")', { timeout: 10_000 })\n\n    const fileInput = page.locator('input[type=\"file\"][accept=\".md,.txt\"]').first()\n    await fileInput.setInputFiles(mdPath)\n\n    await expect(page.locator('text=导入成功').first()).toBeVisible({ timeout: 5_000 })\n\n    expect(addPageCalls).toHaveLength(2)\n    // Page 1: outline + description + part\n    expect(addPageCalls[0].outline_content.title).toBe('AI简介')\n    expect(addPageCalls[0].outline_content.points).toContain('什么是人工智能')\n    expect(addPageCalls[0].outline_content.points).toContain('AI的历史')\n    expect(addPageCalls[0].part).toBe('引言')\n    expect(addPageCalls[0].description_content).toEqual({ text: '这是关于AI简介的描述内容。' })\n    // Page 2\n    expect(addPageCalls[1].outline_content.title).toBe('AI应用')\n    expect(addPageCalls[1].outline_content.points).toContain('医疗领域')\n    expect(addPageCalls[1].part).toBe('正文')\n    expect(addPageCalls[1].description_content).toEqual({ text: '这是关于AI应用的描述内容。' })\n  })\n\n  test('import unified markdown on detail page — same result', async ({ page }) => {\n    const mdPath = writeTempFile('test-unified-detail.md', UNIFIED_MD)\n\n    await page.goto(`/project/${PROJECT_ID}/detail`)\n    await page.waitForSelector('button:has-text(\"导入\")', { timeout: 10_000 })\n\n    const fileInput = page.locator('input[type=\"file\"][accept=\".md,.txt\"]').first()\n    await fileInput.setInputFiles(mdPath)\n\n    await expect(page.locator('text=导入成功').first()).toBeVisible({ timeout: 5_000 })\n\n    expect(addPageCalls).toHaveLength(2)\n    expect(addPageCalls[0].outline_content.title).toBe('AI简介')\n    expect(addPageCalls[0].description_content).toEqual({ text: '这是关于AI简介的描述内容。' })\n  })\n\n  test('import legacy format still works', async ({ page }) => {\n    const mdPath = writeTempFile('test-legacy.md', LEGACY_MD)\n\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n    await page.waitForSelector('button:has-text(\"导入\")', { timeout: 10_000 })\n\n    const fileInput = page.locator('input[type=\"file\"][accept=\".md,.txt\"]').first()\n    await fileInput.setInputFiles(mdPath)\n\n    await expect(page.locator('text=导入成功').first()).toBeVisible({ timeout: 5_000 })\n\n    expect(addPageCalls).toHaveLength(1)\n    expect(addPageCalls[0].outline_content.title).toBe('旧格式页面')\n    expect(addPageCalls[0].outline_content.points).toContain('要点一')\n    expect(addPageCalls[0].part).toBe('测试')\n  })\n\n  test('import empty markdown shows error toast', async ({ page }) => {\n    const mdPath = writeTempFile('test-empty.md', EMPTY_MD)\n\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n    await page.waitForSelector('button:has-text(\"导入\")', { timeout: 10_000 })\n\n    const fileInput = page.locator('input[type=\"file\"][accept=\".md,.txt\"]').first()\n    await fileInput.setInputFiles(mdPath)\n\n    await expect(page.locator('text=文件中未找到有效页面').first()).toBeVisible({ timeout: 5_000 })\n    expect(addPageCalls).toHaveLength(0)\n  })\n\n  test('export→import round-trip preserves data', async ({ page }) => {\n    // Pre-populate project with pages that have outline + description\n    const existingPages = [\n      {\n        id: 'p1', page_id: 'p1', order_index: 0, part: '第一章',\n        outline_content: { title: '导论', points: ['背景介绍', '研究目的'] },\n        description_content: { text: '这是导论页面的详细描述。' },\n        status: 'DESCRIPTION_GENERATED',\n      },\n      {\n        id: 'p2', page_id: 'p2', order_index: 1, part: null,\n        outline_content: { title: '方法论', points: ['实验设计'] },\n        description_content: { text: '方法论的描述内容。' },\n        status: 'DESCRIPTION_GENERATED',\n      },\n    ]\n\n    // Override project route to return pages\n    await page.route(`**/api/projects/${PROJECT_ID}`, r =>\n      r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockProject(existingPages)) }))\n\n    // Use detail page \"导出大纲+描述\" for full export\n    await page.goto(`/project/${PROJECT_ID}/detail`)\n    await page.waitForSelector('button:has-text(\"导出大纲+描述\")', { timeout: 10_000 })\n\n    const [download] = await Promise.all([\n      page.waitForEvent('download'),\n      page.click('button:has-text(\"导出大纲+描述\")'),\n    ])\n    const downloadPath = await download.path()\n    const exportedContent = fs.readFileSync(downloadPath!, 'utf-8')\n\n    // Verify exported content has both markers\n    expect(exportedContent).toContain('**大纲要点：**')\n    expect(exportedContent).toContain('**页面描述：**')\n    expect(exportedContent).toContain('导论')\n    expect(exportedContent).toContain('背景介绍')\n    expect(exportedContent).toContain('这是导论页面的详细描述。')\n\n    // Import the exported file back\n    addPageCalls = []\n    const reimportPath = writeTempFile('roundtrip.md', exportedContent)\n    const fileInput = page.locator('input[type=\"file\"][accept=\".md,.txt\"]').first()\n    await fileInput.setInputFiles(reimportPath)\n\n    await expect(page.locator('text=导入成功').first()).toBeVisible({ timeout: 5_000 })\n\n    // Verify round-trip fidelity\n    expect(addPageCalls).toHaveLength(2)\n    expect(addPageCalls[0].outline_content.title).toBe('导论')\n    expect(addPageCalls[0].outline_content.points).toEqual(['背景介绍', '研究目的'])\n    expect(addPageCalls[0].part).toBe('第一章')\n    expect(addPageCalls[0].description_content).toEqual({ text: '这是导论页面的详细描述。' })\n    expect(addPageCalls[1].outline_content.title).toBe('方法论')\n    expect(addPageCalls[1].outline_content.points).toEqual(['实验设计'])\n    expect(addPageCalls[1].description_content).toEqual({ text: '方法论的描述内容。' })\n    expect(addPageCalls[1].part).toBeUndefined()\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/lazyllm-global-vendor.spec.ts",
    "content": "/**\n * E2E tests for lazyllm global vendor fix.\n *\n * Bug: selecting a lazyllm vendor (e.g., \"doubao\") as global provider converted\n * it to \"lazyllm\" on save, losing vendor info. The backend then defaulted to\n * hardcoded 'deepseek' for text source, causing API key lookup failures.\n *\n * Fix: vendor name is now stored directly in ai_provider_format (e.g., \"doubao\").\n */\nimport { test, expect } from '@playwright/test'\n\n// ─── Mock tests ────────────────────────────────────────────────────\n\ntest.describe('Global lazyllm vendor — mock tests', () => {\n  test.setTimeout(30_000)\n\n  test('save sends vendor name directly, not \"lazyllm\"', async ({ page }) => {\n    // Mock GET settings\n    const mockSettings = {\n      success: true, message: 'Success',\n      data: {\n        id: 1, ai_provider_format: 'gemini', api_base_url: '',\n        api_key_length: 0, text_model: '', image_model: '',\n        image_caption_model: '', image_resolution: '2K',\n        image_aspect_ratio: '16:9', max_description_workers: 5,\n        max_image_workers: 8, output_language: 'zh',\n        enable_text_reasoning: false, text_thinking_budget: 1024,\n        enable_image_reasoning: false, image_thinking_budget: 1024,\n        mineru_api_base: '', mineru_token_length: 0,\n        baidu_api_key_length: 0,\n        text_model_source: '', text_api_key_length: 0, text_api_base_url: null,\n        image_model_source: '', image_api_key_length: 0, image_api_base_url: null,\n        image_caption_model_source: '', image_caption_api_key_length: 0,\n        image_caption_api_base_url: null, lazyllm_api_keys_info: {},\n      },\n    }\n\n    let capturedPayload: any = null\n\n    await page.route('**/api/settings', async route => {\n      if (route.request().method() === 'GET') {\n        await route.fulfill({\n          status: 200, contentType: 'application/json',\n          body: JSON.stringify(mockSettings),\n        })\n      } else if (route.request().method() === 'PUT') {\n        capturedPayload = route.request().postDataJSON()\n        await route.fulfill({\n          status: 200, contentType: 'application/json',\n          body: JSON.stringify({\n            success: true,\n            data: { ...mockSettings.data, ai_provider_format: 'doubao' },\n          }),\n        })\n      }\n    })\n\n    await page.goto('/settings')\n\n    // Select \"doubao\" as global provider\n    const globalProviderSelect = page.locator('select').first()\n    await globalProviderSelect.selectOption('doubao')\n\n    // Fill doubao API key (vendor key input appears for lazyllm vendors)\n    const vendorKeyInput = page.locator('input[type=\"password\"]').first()\n    await vendorKeyInput.fill('test-doubao-key-123')\n\n    // Save\n    await page.getByRole('button', { name: /保存|Save/ }).click()\n    await expect(page.locator('text=保存成功').or(page.locator('text=saved'))).toBeVisible({ timeout: 5000 })\n\n    // Key assertion: payload should send \"doubao\", NOT \"lazyllm\"\n    expect(capturedPayload).not.toBeNull()\n    expect(capturedPayload.ai_provider_format).toBe('doubao')\n  })\n\n  test('loading vendor name from backend displays correct dropdown value', async ({ page }) => {\n    const mockSettings = {\n      success: true, message: 'Success',\n      data: {\n        id: 1, ai_provider_format: 'qwen',\n        api_base_url: '', api_key_length: 0,\n        text_model: '', image_model: '',\n        image_caption_model: '', image_resolution: '2K',\n        image_aspect_ratio: '16:9', max_description_workers: 5,\n        max_image_workers: 8, output_language: 'zh',\n        enable_text_reasoning: false, text_thinking_budget: 1024,\n        enable_image_reasoning: false, image_thinking_budget: 1024,\n        mineru_api_base: '', mineru_token_length: 0,\n        baidu_api_key_length: 0,\n        text_model_source: '', text_api_key_length: 0, text_api_base_url: null,\n        image_model_source: '', image_api_key_length: 0, image_api_base_url: null,\n        image_caption_model_source: '', image_caption_api_key_length: 0,\n        image_caption_api_base_url: null,\n        lazyllm_api_keys_info: { qwen: 15 },\n      },\n    }\n\n    await page.route('**/api/settings', route =>\n      route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockSettings) })\n    )\n\n    await page.goto('/settings')\n\n    // Global provider dropdown should show \"qwen\"\n    const globalSelect = page.locator('select').first()\n    await expect(globalSelect).toHaveValue('qwen')\n\n    // Vendor key input should be visible (not Gemini/OpenAI base URL fields)\n    await expect(page.locator('text=API Base URL').first()).toBeHidden()\n  })\n\n  test('backward compat: \"lazyllm\" format resolves to first configured vendor', async ({ page }) => {\n    // Old data with generic \"lazyllm\" format\n    const mockSettings = {\n      success: true, message: 'Success',\n      data: {\n        id: 1, ai_provider_format: 'lazyllm',\n        api_base_url: '', api_key_length: 0,\n        text_model: '', image_model: '',\n        image_caption_model: '', image_resolution: '2K',\n        image_aspect_ratio: '16:9', max_description_workers: 5,\n        max_image_workers: 8, output_language: 'zh',\n        enable_text_reasoning: false, text_thinking_budget: 1024,\n        enable_image_reasoning: false, image_thinking_budget: 1024,\n        mineru_api_base: '', mineru_token_length: 0,\n        baidu_api_key_length: 0,\n        text_model_source: '', text_api_key_length: 0, text_api_base_url: null,\n        image_model_source: '', image_api_key_length: 0, image_api_base_url: null,\n        image_caption_model_source: '', image_caption_api_key_length: 0,\n        image_caption_api_base_url: null,\n        lazyllm_api_keys_info: { doubao: 20 },\n      },\n    }\n\n    await page.route('**/api/settings', route =>\n      route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockSettings) })\n    )\n\n    await page.goto('/settings')\n\n    // resolveLazyllmVendor should resolve \"lazyllm\" to \"doubao\" (first configured vendor)\n    const globalSelect = page.locator('select').first()\n    await expect(globalSelect).toHaveValue('doubao')\n  })\n})\n\n// ─── Integration tests ─────────────────────────────────────────────\n\ntest.describe('Global lazyllm vendor — integration tests', () => {\n  test.describe.configure({ mode: 'serial' })\n  test.setTimeout(30_000)\n\n  test('save doubao as global provider, reload shows doubao', async ({ page }) => {\n    await page.goto('/settings')\n\n    // Select doubao as global provider\n    const globalSelect = page.locator('select').first()\n    await globalSelect.selectOption('doubao')\n\n    // Fill a test doubao API key\n    const vendorKeyInput = page.locator('input[type=\"password\"]').first()\n    await vendorKeyInput.fill('test-doubao-integration-key')\n\n    // Save\n    await page.getByRole('button', { name: /保存|Save/ }).click()\n    await expect(page.locator('text=保存成功').or(page.locator('text=saved'))).toBeVisible({ timeout: 5000 })\n\n    // Reload page\n    await page.goto('/settings')\n\n    // Should still show doubao (not fall back to generic lazyllm / deepseek)\n    await expect(page.locator('select').first()).toHaveValue('doubao')\n  })\n\n  test('save qwen as global provider, verify backend stores vendor name', async ({ page }) => {\n    await page.goto('/settings')\n\n    // Select qwen\n    const globalSelect = page.locator('select').first()\n    await globalSelect.selectOption('qwen')\n\n    // Fill qwen API key\n    const vendorKeyInput = page.locator('input[type=\"password\"]').first()\n    await vendorKeyInput.fill('test-qwen-key')\n\n    // Save\n    await page.getByRole('button', { name: /保存|Save/ }).click()\n    await expect(page.locator('text=保存成功').or(page.locator('text=saved'))).toBeVisible({ timeout: 5000 })\n\n    // Verify via API that backend stored \"qwen\", not \"lazyllm\"\n    const response = await page.request.get('/api/settings')\n    const data = await response.json()\n    expect(data.data.ai_provider_format).toBe('qwen')\n  })\n\n  test('reset after vendor save restores default format', async ({ page }) => {\n    await page.goto('/settings')\n\n    // First save doubao\n    const globalSelect = page.locator('select').first()\n    await globalSelect.selectOption('doubao')\n    const vendorKeyInput = page.locator('input[type=\"password\"]').first()\n    await vendorKeyInput.fill('test-key')\n    await page.getByRole('button', { name: /保存|Save/ }).click()\n    await expect(page.locator('text=保存成功').or(page.locator('text=saved'))).toBeVisible({ timeout: 5000 })\n\n    // Reset\n    await page.getByRole('button', { name: /重置|Reset/ }).click()\n    await page.getByRole('button', { name: /确定重置|Confirm/ }).click()\n    await expect(page.locator('text=设置已重置').or(page.locator('text=reset successfully'))).toBeVisible({ timeout: 5000 })\n\n    // After reset, format should revert to .env default (typically gemini)\n    const response = await page.request.get('/api/settings')\n    const data = await response.json()\n    // Format should no longer be \"doubao\"\n    expect(data.data.ai_provider_format).not.toBe('doubao')\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/lazyllm-image-content-type.spec.ts",
    "content": "/**\n * E2E tests for LazyLLM image content-type fallback.\n *\n * Mock test: verifies the frontend handles image generation errors gracefully\n * and that the generate-images API endpoint is called correctly.\n *\n * Integration test: verifies the generate-images endpoint returns a proper\n * response (success or known error) without crashing.\n */\nimport { test, expect } from '@playwright/test'\nimport { seedProjectWithImages } from './helpers/seed-project'\n\nconst BASE = process.env.BASE_URL ?? 'http://localhost:3000'\n\n// ---------------------------------------------------------------------------\n// Mock test — frontend behaviour when image generation fails\n// ---------------------------------------------------------------------------\ntest.describe('Image generation error handling (mock)', () => {\n  test('shows error state when generate-images returns 503', async ({ page }) => {\n    const projectId = 'mock-img-err-proj'\n\n    // Mock project fetch\n    await page.route(`**/api/projects/${projectId}`, async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: {\n            project_id: projectId,\n            idea_prompt: 'test',\n            pages: [{ page_id: 'p1', title: 'Page 1', description_content: 'desc', image_url: null }],\n          },\n        }),\n      })\n    })\n\n    // Mock generate-images to return error\n    await page.route(`**/api/projects/${projectId}/generate-images`, async (route) => {\n      await route.fulfill({\n        status: 503,\n        contentType: 'application/json',\n        body: JSON.stringify({ error: { message: 'LazyLLM content-type error' } }),\n      })\n    })\n\n    await page.addInitScript(() => localStorage.setItem('hasSeenHelpModal', 'true'))\n    await page.goto(`${BASE}/detail/${projectId}`)\n\n    // Trigger image generation\n    const genBtn = page.locator('button').filter({ hasText: /生成图片|Generate Images/i }).first()\n    if (await genBtn.isVisible({ timeout: 5000 }).catch(() => false)) {\n      await genBtn.click()\n      // Should show some error feedback (toast / alert)\n      const errorVisible = await page\n        .locator('[class*=\"error\"], [class*=\"toast\"], [role=\"alert\"]')\n        .first()\n        .isVisible({ timeout: 10000 })\n        .catch(() => false)\n      // Either error shown or page still functional (no crash)\n      expect(page.url()).toContain('/detail/')\n    }\n  })\n})\n\n// ---------------------------------------------------------------------------\n// Integration test — generate-images endpoint smoke test\n// ---------------------------------------------------------------------------\ntest.describe('Image generation endpoint (integration)', () => {\n  test('generate-images endpoint responds without server crash', async ({ page }) => {\n    // Seed a project with real images so we have a valid project_id\n    const { projectId } = await seedProjectWithImages(BASE, 1)\n\n    // Navigate first so relative URLs resolve through Vite proxy\n    await page.goto(BASE)\n\n    // Call generate-images via browser fetch (goes through Vite proxy)\n    const resp = await page.evaluate(async (id) => {\n      const r = await fetch(`/api/projects/${id}/generate-images`, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ page_ids: [] }),\n      })\n      return { status: r.status, ok: r.ok }\n    }, projectId)\n\n    // Endpoint should return 2xx (task queued) or 4xx (validation), never 5xx crash\n    expect(resp.status).toBeLessThan(500)\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/markdown-card-style.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\n\nconst BASE = process.env.BASE_URL || 'http://localhost:3000';\nconst PROJECT_ID = 'mock-style-test';\n\n// 1x1 transparent PNG as data URL (always loads successfully)\nconst TINY_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';\n\nfunction makePage(id: string, index: number, title: string, description: string) {\n  return {\n    id,\n    page_id: id,\n    title,\n    sort_order: index,\n    order_index: index,\n    status: 'COMPLETED',\n    outline_content: { title, points: [`Point for ${title}`] },\n    description_content: { text: description },\n    generated_image_path: null,\n  };\n}\n\nfunction setupMocks(page: import('@playwright/test').Page, pages: ReturnType<typeof makePage>[]) {\n  return Promise.all([\n    page.route(`**/api/projects/${PROJECT_ID}`, async (route) => {\n      if (route.request().method() !== 'GET') { await route.continue(); return; }\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: {\n            project_id: PROJECT_ID,\n            id: PROJECT_ID,\n            status: 'DESCRIPTIONS_GENERATED',\n            creation_type: 'idea',\n            pages,\n          },\n        }),\n      });\n    }),\n    page.route('**/api/projects/*/files*', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ success: true, data: [] }),\n      });\n    }),\n  ]);\n}\n\ntest.describe('Markdown image size and DescriptionCard max height', () => {\n  test('markdown images should have constrained size classes', async ({ page }) => {\n    const pages = [\n      makePage('p1', 0, 'Cover', `Some text\\n\\n![test image](${TINY_PNG})\\n\\nMore text`),\n    ];\n    await setupMocks(page, pages);\n\n    await page.goto(`${BASE}/project/${PROJECT_ID}/detail`);\n    await expect(page.locator('text=第 1 页')).toBeVisible({ timeout: 10000 });\n\n    const img = page.locator('.markdown-content img').first();\n    await expect(img).toBeVisible({ timeout: 10000 });\n\n    // Verify the image has constrained size classes\n    await expect(img).toHaveClass(/max-w-48/);\n    await expect(img).toHaveClass(/max-h-36/);\n  });\n\n  test('description card content area should have max height with scroll', async ({ page }) => {\n    const longText = Array(50).fill('This is a long line of description text for testing overflow.').join('\\n\\n');\n    const pages = [\n      makePage('p1', 0, 'Cover', longText),\n    ];\n    await setupMocks(page, pages);\n\n    await page.goto(`${BASE}/project/${PROJECT_ID}/detail`);\n    await expect(page.locator('text=第 1 页')).toBeVisible({ timeout: 10000 });\n\n    const contentArea = page.getByTestId('description-card-content');\n    await expect(contentArea).toHaveClass(/max-h-96/);\n    await expect(contentArea).toHaveClass(/overflow-y-auto/);\n\n    // Verify actual computed max-height (max-h-96 = 24rem = 384px)\n    const maxHeight = await contentArea.evaluate((el) => getComputedStyle(el).maxHeight);\n    expect(maxHeight).toBe('384px');\n  });\n});\n"
  },
  {
    "path": "frontend/e2e/material-aspect-ratio.spec.ts",
    "content": "import { test, expect, type Page } from '@playwright/test';\nimport { ASPECT_RATIO_OPTIONS } from '../src/config/aspectRatio';\n\n/**\n * E2E tests for material generation aspect ratio selector.\n * Tests both UI rendering (mock) and API payload (mock).\n */\n\ntest.describe('Material generation aspect ratio selector', () => {\n  test.beforeEach(async ({ page }) => {\n    // Disable access code guard\n    await page.route('**/api/access-code/check', (route) =>\n      route.fulfill({ json: { data: { enabled: false } } })\n    );\n    // Mark help modal as already seen to prevent it from blocking interactions\n    await page.addInitScript(() => {\n      localStorage.setItem('hasSeenHelpModal', 'true');\n    });\n    await page.goto('/');\n    await page.waitForLoadState('networkidle');\n  });\n\n  async function openMaterialGeneratorModal(page: Page) {\n    // Use dispatchEvent to reliably trigger the click on the 素材生成 button\n    // (regular click may be blocked by overlay elements)\n    const materialBtn = page.locator('button', { hasText: /素材生成/ }).first();\n    await expect(materialBtn).toBeAttached({ timeout: 5000 });\n    await materialBtn.dispatchEvent('click');\n    // Wait for the MaterialGeneratorModal dialog to appear (identified by its title)\n    await expect(page.getByRole('dialog', { name: /素材生成|Generate Material/ })).toBeVisible({ timeout: 5000 });\n  }\n\n  test('should render aspect ratio selector with all options in material generator modal', async ({ page }) => {\n    await openMaterialGeneratorModal(page);\n\n    const dialog = page.getByRole('dialog', { name: /素材生成|Generate Material/ });\n\n    // Check the aspect ratio label is visible\n    await expect(dialog.getByText(/生成比例|Aspect Ratio/)).toBeVisible();\n\n    // Check that all ratio buttons are visible inside the dialog (derived from config)\n    for (const { value } of ASPECT_RATIO_OPTIONS) {\n      await expect(dialog.locator('button', { hasText: value })).toBeVisible();\n    }\n  });\n\n  test('should default to 16:9 and allow changing aspect ratio selection', async ({ page }) => {\n    await openMaterialGeneratorModal(page);\n\n    const dialog = page.getByRole('dialog', { name: /素材生成|Generate Material/ });\n\n    // 16:9 should be the default selected ratio\n    const btn169 = dialog.locator('button', { hasText: '16:9' }).first();\n    await expect(btn169).toHaveClass(/border-banana-500/);\n\n    // Click on 4:3\n    const btn43 = dialog.locator('button', { hasText: '4:3' }).first();\n    await btn43.click();\n\n    // 4:3 should now be selected\n    await expect(btn43).toHaveClass(/border-banana-500/);\n    // 16:9 should no longer be selected\n    await expect(btn169).not.toHaveClass(/border-banana-500/);\n  });\n\n  test('should send selected aspect_ratio in material generation API request', async ({ page }) => {\n    let capturedAspectRatio: string | null = null;\n    let requestIntercepted = false;\n\n    // Intercept the material generation call (global, projectId=none)\n    await page.route('**/api/projects/none/materials/generate', async (route) => {\n      const request = route.request();\n      const postData = request.postData() || '';\n\n      // Multipart form: find aspect_ratio field value\n      const match = postData.match(/name=\"aspect_ratio\"\\r\\n\\r\\n([^\\r\\n]*)/);\n      if (match) {\n        capturedAspectRatio = match[1].trim();\n      }\n      requestIntercepted = true;\n\n      await route.fulfill({\n        status: 202,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: { task_id: 'mock-task-id', status: 'PENDING' },\n        }),\n      });\n    });\n\n    // Mock task status poll\n    await page.route('**/api/projects/global/tasks/mock-task-id', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: {\n            id: 'mock-task-id',\n            status: 'COMPLETED',\n            progress: { image_url: '/files/materials/test.png', total: 1, completed: 1, failed: 0 },\n          },\n        }),\n      });\n    });\n\n    await openMaterialGeneratorModal(page);\n\n    const dialog = page.getByRole('dialog', { name: /素材生成|Generate Material/ });\n\n    // Select 1:1 ratio\n    await dialog.locator('button', { hasText: '1:1' }).first().click();\n\n    // Fill in prompt\n    await dialog.locator('textarea').first().fill('test material prompt');\n\n    // Click the generate button and wait for the API response\n    const [response] = await Promise.all([\n      page.waitForResponse('**/api/projects/none/materials/generate'),\n      dialog.locator('button', { hasText: /生成素材|Generate Material/ }).first().click(),\n    ]);\n\n    expect(response.status()).toBe(202);\n    expect(requestIntercepted).toBe(true);\n    expect(capturedAspectRatio).toBe('1:1');\n  });\n});\n"
  },
  {
    "path": "frontend/e2e/outline-autosave-blur.spec.ts",
    "content": "import { test, expect } from '@playwright/test'\n\nconst PROJECT_ID = 'mock-autosave-project'\n\nconst mockProject = {\n  project_id: PROJECT_ID,\n  status: 'OUTLINE_GENERATED',\n  idea_prompt: 'Original idea text',\n  creation_type: 'idea',\n  pages: [\n    {\n      page_id: 'page-1',\n      order_index: 0,\n      outline_content: { title: 'Page One', points: ['Point A'] },\n      status: 'DRAFT',\n    },\n  ],\n  created_at: '2025-01-01T00:00:00',\n  updated_at: '2025-01-01T00:00:00',\n}\n\n// Mock test: verify blur triggers save API call\ntest.describe('Outline auto-save on blur (mock)', () => {\n  test('saves input text when textarea loses focus', async ({ page }) => {\n    let savePayload: { idea_prompt?: string } | null = null\n\n    await page.route(`**/api/projects/${PROJECT_ID}`, async (route) => {\n      if (route.request().method() === 'PUT') {\n        savePayload = route.request().postDataJSON()\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ success: true, data: { ...mockProject, idea_prompt: savePayload?.idea_prompt } }),\n        })\n      } else {\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ success: true, data: mockProject }),\n        })\n      }\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n    await page.waitForLoadState('networkidle')\n\n    // Find the contenteditable editor in the left panel (desktop)\n    const editor = page.locator('[contenteditable=\"true\"]').first()\n    await expect(editor).toBeVisible()\n\n    // Type new text\n    await editor.click()\n    await editor.pressSequentially(' updated content')\n\n    // Blur by clicking outside\n    await page.locator('header').first().click()\n\n    // Wait for the save API call\n    await expect.poll(() => savePayload, { timeout: 5000 }).not.toBeNull()\n    expect(savePayload).toHaveProperty('idea_prompt')\n    expect(savePayload.idea_prompt).toContain('updated content')\n  })\n\n  test('does not save when content is unchanged', async ({ page }) => {\n    let putCalled = false\n\n    await page.route(`**/api/projects/${PROJECT_ID}`, async (route) => {\n      if (route.request().method() === 'PUT') {\n        putCalled = true\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ success: true, data: mockProject }),\n        })\n      } else {\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({ success: true, data: mockProject }),\n        })\n      }\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n    await page.waitForLoadState('networkidle')\n\n    // Click editor then blur without changing content\n    const editor = page.locator('[contenteditable=\"true\"]').first()\n    await editor.click()\n    await page.locator('header').first().click()\n\n    // Verify no save is triggered after blur\n    await expect.poll(() => putCalled, { timeout: 2000 }).toBe(false)\n  })\n})\n\n// Integration test: verify data persists after blur\ntest.describe('Outline auto-save on blur (integration)', () => {\n  let projectId: string\n\n  test.beforeEach(async ({ request }) => {\n    const res = await request.post('/api/projects', {\n      data: { idea_prompt: 'Integration test idea', creation_type: 'idea' },\n    })\n    const body = await res.json()\n    projectId = body.data.project_id\n  })\n\n  test('persists edited text after blur and page reload', async ({ page }) => {\n    await page.goto(`/project/${projectId}/outline`)\n    await page.waitForLoadState('networkidle')\n\n    const editor = page.locator('[contenteditable=\"true\"]').first()\n    await expect(editor).toBeVisible()\n\n    // Edit the text\n    await editor.click()\n    await page.keyboard.press('End')\n    await editor.pressSequentially(' - auto saved')\n\n    // Blur to trigger save and wait for the PUT request to complete\n    const savePromise = page.waitForResponse(\n      (resp) => resp.url().includes(`/api/projects/${projectId}`) && resp.request().method() === 'PUT'\n    )\n    await page.locator('header').first().click()\n    await savePromise\n\n    // Reload and verify text persisted\n    await page.reload()\n    await page.waitForLoadState('networkidle')\n\n    const editorAfter = page.locator('[contenteditable=\"true\"]').first()\n    await expect(editorAfter).toContainText('auto saved', { timeout: 5000 })\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/outline-null-crash.spec.ts",
    "content": "import { test, expect } from '@playwright/test'\n\nconst PROJECT_ID = 'mock-null-outline'\n\nconst mockProject = {\n  project_id: PROJECT_ID,\n  status: 'OUTLINE_GENERATED',\n  idea_prompt: 'Test project',\n  pages: [\n    {\n      page_id: 'page-1',\n      order_index: 0,\n      outline_content: { title: 'Normal Page', points: ['Point A', 'Point B'] },\n      status: 'DRAFT',\n    },\n    {\n      page_id: 'page-2',\n      order_index: 1,\n      outline_content: null,\n      status: 'DRAFT',\n    },\n  ],\n  created_at: '2025-01-01T00:00:00',\n  updated_at: '2025-01-01T00:00:00',\n}\n\ntest.describe('OutlineCard null outline_content', () => {\n  test('renders without crash when outline_content is null', async ({ page }) => {\n    await page.route('**/api/projects/' + PROJECT_ID, async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ success: true, data: mockProject }),\n      })\n    })\n\n    // Navigate to outline editor\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n\n    // The normal page should render its title\n    await expect(page.getByText('Normal Page')).toBeVisible()\n\n    // Page 2 (null outline) should render without crashing — check page number label\n    await expect(page.getByText(/Page 2|第 2 页/)).toBeVisible()\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/parsing-preview-toast.spec.ts",
    "content": "/**\n * E2E test: Clicking a parsing attachment shows toast instead of preview modal\n */\nimport { test, expect } from '@playwright/test'\n\ntest.use({ baseURL: process.env.BASE_URL || 'http://localhost:3000' })\n\nconst PROJECT_ID = 'mock-proj-parse'\nconst FILE_PARSING = 'file-parsing-001'\nconst FILE_COMPLETED = 'file-completed-002'\n\nconst mockSettings = () => ({\n  success: true,\n  data: { ai_provider_format: 'gemini', google_api_key: 'fake' }\n})\n\nconst mockProject = () => ({\n  success: true,\n  data: {\n    id: PROJECT_ID, project_id: PROJECT_ID, title: 'Test',\n    status: 'OUTLINE_GENERATED', creation_type: 'idea',\n    pages: [{ id: 'p1', page_id: 'p1', title: 'Page 1', order_index: 0, outline_content: { title: 'Page 1', points: ['p'] } }]\n  }\n})\n\nconst mockFiles = () => ({\n  success: true,\n  data: {\n    files: [\n      { id: FILE_PARSING, filename: 'parsing-doc.pdf', file_size: 1000, file_type: 'application/pdf', parse_status: 'parsing' },\n      { id: FILE_COMPLETED, filename: 'done-doc.pdf', file_size: 2000, file_type: 'application/pdf', parse_status: 'completed' },\n    ]\n  }\n})\n\ntest.describe('Parsing attachment preview toast (mocked)', () => {\n  test.setTimeout(60_000)\n\n  test.beforeEach(async ({ page }) => {\n    await page.route('**/api/settings', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockSettings()) }))\n    await page.route(`**/api/projects/${PROJECT_ID}`, r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockProject()) }))\n    await page.route(`**/api/projects/${PROJECT_ID}/pages`, r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { pages: [] } }) }))\n    await page.route(`**/api/reference-files/project/${PROJECT_ID}`, r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockFiles()) }))\n    await page.route(`**/api/reference-files/${FILE_COMPLETED}`, r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { file: { id: FILE_COMPLETED, filename: 'done-doc.pdf', file_size: 2000, file_type: 'application/pdf', parse_status: 'completed', markdown_content: '# Done' } } }) }))\n  })\n\n  test('clicking parsing file shows toast, not preview modal', async ({ page }) => {\n    let parsingFileFetched = false\n    await page.route(`**/api/reference-files/${FILE_PARSING}`, async r => {\n      parsingFileFetched = true\n      await r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { file: { id: FILE_PARSING, filename: 'parsing-doc.pdf', file_size: 1000, file_type: 'application/pdf', parse_status: 'parsing', markdown_content: null } } }) })\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n\n    const parsingCard = page.locator('text=parsing-doc.pdf').first()\n    await parsingCard.waitFor({ state: 'visible', timeout: 10_000 })\n    await parsingCard.click()\n\n    const toast = page.locator('text=解析完成后可预览').or(page.locator('text=Preview available after parsing'))\n    await expect(toast.first()).toBeVisible({ timeout: 3_000 })\n\n    expect(parsingFileFetched).toBe(false)\n  })\n\n  test('clicking completed file still opens preview modal', async ({ page }) => {\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n\n    const completedCard = page.locator('text=done-doc.pdf').first()\n    await completedCard.waitFor({ state: 'visible', timeout: 10_000 })\n    await completedCard.click()\n\n    const modal = page.locator('[role=\"dialog\"]').last()\n    await expect(modal).toBeVisible({ timeout: 5_000 })\n    await expect(modal.locator('.prose h1')).toBeVisible({ timeout: 3_000 })\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/pdf-export-metadata.spec.ts",
    "content": "/**\n * E2E tests for PDF export with author metadata\n */\n\nimport { test, expect } from '@playwright/test'\nimport { seedProjectWithImages } from './helpers/seed-project'\n\ntest.describe('PDF Export - Backend API', () => {\n  test('exports PDF with author metadata', async ({ request, baseURL }) => {\n    const { projectId } = await seedProjectWithImages(baseURL!, 2)\n\n    const resp = await request.get(`/api/projects/${projectId}/export/pdf`)\n    expect(resp.ok()).toBe(true)\n    const data = (await resp.json()).data\n    expect(data.download_url).toContain('.pdf')\n\n    // Verify the PDF is downloadable\n    const fileResp = await request.get(data.download_url)\n    expect(fileResp.ok()).toBe(true)\n    expect(fileResp.headers()['content-type']).toContain('application/pdf')\n\n    // Verify PDF has content (non-zero size)\n    const pdfBuffer = await fileResp.body()\n    expect(pdfBuffer.length).toBeGreaterThan(1000)\n\n    // Verify PDF contains metadata (check for \"banana-slides\" in PDF content)\n    const pdfContent = pdfBuffer.toString('utf-8')\n    expect(pdfContent).toContain('banana-slides')\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/per-model-startup-creds.spec.ts",
    "content": "/**\n * Integration E2E test for issue #284:\n * Per-model API credentials must be loaded into app.config on backend startup.\n *\n * Strategy: save per-model settings → restart backend → verify startup logs\n * contain the loaded credentials, proving _load_settings_to_config() works.\n */\nimport { test, expect } from '@playwright/test'\nimport { execSync } from 'child_process'\nimport path from 'path'\nimport { fileURLToPath } from 'url'\n\nconst __filename = fileURLToPath(import.meta.url)\nconst __dirname = path.dirname(__filename)\n\nconst backendPort = (() => {\n  const m = (process.env.BASE_URL ?? '').match(/:(\\d+)$/)\n  // backend base=5000, frontend base=3000, same offset → backend = frontend + 2000\n  return m ? Number(m[1]) + 2000 : 5000\n})()\nconst BACKEND_URL = `http://localhost:${backendPort}`\nconst PROJECT_ROOT = path.resolve(__dirname, '..', '..')\nconst LOG_FILE = path.join('/tmp', `startup-creds-backend-${process.pid}.log`)\n\nfunction restartBackend() {\n  // Kill existing backend\n  try {\n    execSync(`lsof -ti:${backendPort} | xargs kill -9 2>/dev/null`, { timeout: 5000 })\n  } catch { /* may already be dead */ }\n  execSync('sleep 1')\n  // Truncate log so we only see fresh startup output\n  execSync(`truncate -s 0 ${LOG_FILE}`)\n  // Start backend fresh\n  execSync(\n    `cd ${PROJECT_ROOT}/backend && nohup uv run python app.py >> ${LOG_FILE} 2>&1 &`,\n    { timeout: 10000 },\n  )\n  // Wait for backend to be ready\n  for (let i = 0; i < 20; i++) {\n    try {\n      execSync(`curl -sf --noproxy localhost ${BACKEND_URL}/api/settings`, { timeout: 3000 })\n      return\n    } catch { execSync('sleep 1') }\n  }\n  throw new Error('Backend did not start within 20s')\n}\n\n// Clean up after all tests: reset settings and remove temp log\ntest.afterAll(async ({ browser }) => {\n  const page = await browser.newPage()\n  await page.goto('/settings')\n  await page.getByRole('button', { name: /重置/ }).click()\n  await page.getByRole('button', { name: /确定重置/ }).click()\n  await expect(page.locator('text=已重置').or(page.locator('text=reset'))).toBeVisible({ timeout: 5000 })\n  await page.close()\n  try { execSync(`rm -f ${LOG_FILE}`) } catch { /* ignore */ }\n})\n\ntest.describe('Per-model API credentials loaded on startup (#284)', () => {\n  test.describe.configure({ mode: 'serial' })\n  test.setTimeout(60_000)\n\n  test('saved per-model credentials appear in startup logs after restart', async ({ request }) => {\n    // 1. Save per-model settings via API (through Vite proxy)\n    const payload = {\n      text_model_source: 'openai',\n      text_api_base_url: 'https://startup-test.example.com/v1',\n      text_api_key: 'sk-startup-test-key-284',\n    }\n    const saveRes = await request.put('/api/settings', { data: payload })\n    expect(saveRes.ok()).toBeTruthy()\n\n    // 2. Restart backend\n    restartBackend()\n\n    // 3. Read startup logs and verify per-model credentials were loaded\n    const logs = execSync(`cat ${LOG_FILE}`).toString()\n\n    expect(logs).toContain('Loaded TEXT_API_BASE from settings: https://startup-test.example.com/v1')\n    expect(logs).toContain('Loaded TEXT_API_KEY from settings')\n    expect(logs).toContain('Loaded TEXT_MODEL_SOURCE from settings: openai')\n  })\n\n  test('settings page shows correct values after backend restart', async ({ page }) => {\n    // Navigate to settings — backend was restarted in previous test\n    await page.goto('/settings')\n\n    // Find the text model group (first one with a select)\n    const textGroup = page.locator('.space-y-4 > div').filter({ has: page.locator('select') }).nth(0)\n\n    // Verify provider is still openai\n    await expect(textGroup.locator('select')).toHaveValue('openai')\n\n    // Verify API Base URL persisted\n    const baseUrlInput = textGroup.locator('input[type=\"text\"]').nth(1)\n    await expect(baseUrlInput).toHaveValue('https://startup-test.example.com/v1')\n\n    // Verify API Key shows placeholder indicating it's set\n    const apiKeyInput = textGroup.locator('input[type=\"password\"]')\n    const placeholder = await apiKeyInput.getAttribute('placeholder')\n    expect(placeholder).toMatch(/长度|length/i)\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/preset-capsules.spec.ts",
    "content": "import { test, expect } from '@playwright/test'\n\nconst PROJECT_ID = 'mock-preset-project'\n\nconst mockProject = (overrides: Record<string, unknown> = {}) => ({\n  project_id: PROJECT_ID,\n  status: 'OUTLINE_GENERATED',\n  idea_prompt: 'Test idea',\n  creation_type: 'idea',\n  outline_requirements: '',\n  description_requirements: '',\n  pages: [\n    {\n      page_id: 'page-1',\n      order_index: 0,\n      outline_content: { title: 'Page One', points: ['Point A'] },\n      description_content: { text: 'Page one description', generated_at: '2025-01-01' },\n      status: 'DESCRIPTION_GENERATED',\n    },\n  ],\n  created_at: '2025-01-01T00:00:00',\n  updated_at: '2025-01-01T00:00:00',\n  ...overrides,\n})\n\n/** Shared mock route handler for project API */\nasync function setupProjectMock(page: import('@playwright/test').Page) {\n  await page.route(`**/api/projects/${PROJECT_ID}`, async (route) => {\n    if (route.request().method() === 'PUT') {\n      const data = route.request().postDataJSON()\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ success: true, data: mockProject(data) }),\n      })\n    } else {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ success: true, data: mockProject() }),\n      })\n    }\n  })\n}\n\n// ── Mock tests: Outline presets ──────────────────────────────────────\n\ntest.describe('Preset capsules - OutlineEditor (mock)', () => {\n  test.beforeEach(async ({ page }) => {\n    await page.addInitScript(() => {\n      localStorage.removeItem('presetCapsules_outline')\n      localStorage.setItem('outlineReqOpen', 'true')\n    })\n    await setupProjectMock(page)\n  })\n\n  test('displays preset area with add button', async ({ page }) => {\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n    await page.waitForLoadState('networkidle')\n\n    const presets = page.locator('[data-testid=\"outline-presets\"]')\n    await expect(presets).toBeVisible()\n    await expect(page.locator('[data-testid=\"outline-add-preset\"]')).toBeVisible()\n  })\n\n  test('can add custom preset', async ({ page }) => {\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n    await page.waitForLoadState('networkidle')\n\n    await page.locator('[data-testid=\"outline-add-preset\"]').click()\n    await page.locator('[data-testid=\"outline-preset-name-input\"]').fill('我的预设')\n    await page.locator('[data-testid=\"outline-preset-content-input\"]').fill('自定义提示词内容')\n    await page.locator('[data-testid=\"outline-preset-confirm\"]').click()\n\n    const userPreset = page.locator('[data-testid=\"outline-user-preset-0\"]')\n    await expect(userPreset).toBeVisible()\n    await expect(userPreset).toContainText('我的预设')\n    await expect(page.locator('[data-testid=\"outline-delete-preset-0\"]')).toBeVisible()\n  })\n\n  test('clicking custom preset appends content', async ({ page }) => {\n    await page.addInitScript(() => {\n      localStorage.setItem('presetCapsules_outline', JSON.stringify([\n        { name: '测试预设', content: '测试提示词' }\n      ]))\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n    await page.waitForLoadState('networkidle')\n\n    const textarea = page.locator('[data-testid=\"outline-requirements-textarea\"]')\n    const userPreset = page.locator('[data-testid=\"outline-user-preset-0\"]')\n    await expect(userPreset).toBeVisible()\n    await userPreset.locator('button').first().click()\n\n    await expect(textarea).toHaveValue('测试提示词')\n  })\n\n  test('can delete custom preset', async ({ page }) => {\n    await page.addInitScript(() => {\n      localStorage.setItem('presetCapsules_outline', JSON.stringify([\n        { name: '待删除', content: '内容' }\n      ]))\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n    await page.waitForLoadState('networkidle')\n\n    await expect(page.locator('[data-testid=\"outline-user-preset-0\"]')).toBeVisible()\n    await page.locator('[data-testid=\"outline-delete-preset-0\"]').click()\n    await expect(page.locator('[data-testid=\"outline-user-preset-0\"]')).not.toBeVisible()\n  })\n\n  test('can cancel adding preset with Escape', async ({ page }) => {\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n    await page.waitForLoadState('networkidle')\n\n    await page.locator('[data-testid=\"outline-add-preset\"]').click()\n    await expect(page.locator('[data-testid=\"outline-preset-name-input\"]')).toBeVisible()\n\n    await page.locator('[data-testid=\"outline-preset-name-input\"]').press('Escape')\n\n    await expect(page.locator('[data-testid=\"outline-preset-name-input\"]')).not.toBeVisible()\n    await expect(page.locator('[data-testid=\"outline-add-preset\"]')).toBeVisible()\n  })\n\n  test('add button is disabled when fields are empty', async ({ page }) => {\n    await page.goto(`/project/${PROJECT_ID}/outline`)\n    await page.waitForLoadState('networkidle')\n\n    await page.locator('[data-testid=\"outline-add-preset\"]').click()\n\n    const confirmBtn = page.locator('[data-testid=\"outline-preset-confirm\"]')\n    await expect(confirmBtn).toBeDisabled()\n\n    await page.locator('[data-testid=\"outline-preset-name-input\"]').fill('名称')\n    await expect(confirmBtn).toBeDisabled()\n\n    await page.locator('[data-testid=\"outline-preset-content-input\"]').fill('内容')\n    await expect(confirmBtn).toBeEnabled()\n  })\n})\n\n// ── Mock tests: Description presets ──────────────────────────────────\n\ntest.describe('Preset capsules - DetailEditor (mock)', () => {\n  test.beforeEach(async ({ page }) => {\n    await page.addInitScript(() => {\n      localStorage.removeItem('presetCapsules_description')\n      localStorage.setItem('descReqOpen', 'true')\n    })\n    await setupProjectMock(page)\n  })\n\n  test('displays preset area with add button', async ({ page }) => {\n    await page.goto(`/project/${PROJECT_ID}/detail`)\n    await page.waitForLoadState('networkidle')\n\n    const presets = page.locator('[data-testid=\"description-presets\"]')\n    await expect(presets).toBeVisible()\n    await expect(page.locator('[data-testid=\"description-add-preset\"]')).toBeVisible()\n  })\n\n  test('description custom presets are independent from outline presets', async ({ page }) => {\n    await page.addInitScript(() => {\n      localStorage.setItem('presetCapsules_outline', JSON.stringify([\n        { name: '大纲预设', content: '大纲内容' }\n      ]))\n      localStorage.setItem('presetCapsules_description', JSON.stringify([\n        { name: '描述预设', content: '描述内容' }\n      ]))\n    })\n\n    await page.goto(`/project/${PROJECT_ID}/detail`)\n    await page.waitForLoadState('networkidle')\n\n    const descPreset = page.locator('[data-testid=\"description-user-preset-0\"]')\n    await expect(descPreset).toBeVisible()\n    await expect(descPreset).toContainText('描述预设')\n    await expect(page.locator('text=大纲预设')).not.toBeVisible()\n  })\n})\n\n// ── Integration tests ────────────────────────────────────────────────\n\ntest.describe('Preset capsules (integration)', () => {\n  let projectId: string\n\n  test.beforeEach(async ({ request, page }) => {\n    const res = await request.post('/api/projects', {\n      data: { idea_prompt: 'Preset integration test', creation_type: 'idea' },\n    })\n    const body = await res.json()\n    projectId = body.data.project_id\n\n    await page.goto('/')\n    await page.evaluate(() => {\n      localStorage.removeItem('presetCapsules_outline')\n      localStorage.removeItem('presetCapsules_description')\n      localStorage.setItem('outlineReqOpen', 'true')\n    })\n  })\n\n  test('custom preset click appends to textarea and auto-saves', async ({ page }) => {\n    // Seed a preset\n    await page.evaluate(() => {\n      localStorage.setItem('presetCapsules_outline', JSON.stringify([\n        { name: '集成预设', content: '集成测试内容' }\n      ]))\n    })\n\n    await page.goto(`/project/${projectId}/outline`)\n    await page.waitForLoadState('networkidle')\n\n    const textarea = page.locator('[data-testid=\"outline-requirements-textarea\"]')\n    await expect(textarea).toBeVisible()\n\n    // Click user preset\n    await page.locator('[data-testid=\"outline-user-preset-0\"]').locator('button').first().click()\n    await expect(textarea).toHaveValue('集成测试内容')\n\n    // Wait for debounced auto-save\n    const savePromise = page.waitForResponse(\n      (resp) => resp.url().includes(`/api/projects/${projectId}`) && resp.request().method() === 'PUT'\n    )\n    await savePromise\n\n    // Reload and verify persisted\n    await page.reload()\n    await page.waitForLoadState('networkidle')\n\n    const textareaAfter = page.locator('[data-testid=\"outline-requirements-textarea\"]')\n    await expect(textareaAfter).toBeVisible()\n    await expect(textareaAfter).toHaveValue('集成测试内容')\n  })\n\n  test('custom presets persist in localStorage across page navigations', async ({ page }) => {\n    await page.goto(`/project/${projectId}/outline`)\n    await page.waitForLoadState('networkidle')\n\n    await page.locator('[data-testid=\"outline-add-preset\"]').click()\n    await page.locator('[data-testid=\"outline-preset-name-input\"]').fill('集成测试预设')\n    await page.locator('[data-testid=\"outline-preset-content-input\"]').fill('集成测试内容')\n    await page.locator('[data-testid=\"outline-preset-confirm\"]').click()\n\n    await expect(page.locator('[data-testid=\"outline-user-preset-0\"]')).toBeVisible()\n\n    // Navigate away and back\n    await page.goto(`/project/${projectId}/outline`)\n    await page.waitForLoadState('networkidle')\n\n    const userPreset = page.locator('[data-testid=\"outline-user-preset-0\"]')\n    await expect(userPreset).toBeVisible()\n    await expect(userPreset).toContainText('集成测试预设')\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/preview-text-style-template.spec.ts",
    "content": "/**\n * E2E tests for text style mode in SlidePreview template modal.\n *\n * Mock test: verify toggle, TextStyleSelector rendering, preset click, apply button.\n * Integration test: verify style is persisted after apply and survives page reload.\n */\nimport { test, expect } from '@playwright/test'\nimport { seedProjectWithImages } from './helpers/seed-project'\n\nconst BASE_URL = process.env.BASE_URL || 'http://localhost:3000'\nconst BACKEND_URL = BASE_URL.replace(/:\\d+$/, (m) => `:${parseInt(m.slice(1)) + 2000}`)\n\n/** Set up all mocks needed for SlidePreview to render */\nasync function setupMocks(page: import('@playwright/test').Page) {\n  // AccessCodeGuard: bypass\n  await page.route('**/api/access-code/check', async (route) => {\n    await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { enabled: false } }) })\n  })\n  // Project data\n  await page.route('**/api/projects/*', async (route) => {\n    if (route.request().method() === 'GET') {\n      await route.fulfill({\n        status: 200, contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: {\n            id: 'mock-proj', status: 'IMAGES_GENERATED', template_style: '',\n            pages: [{ id: 'p1', order_index: 0, status: 'COMPLETED', outline_content: { title: 'Slide 1' }, generated_image_path: 'mock.jpg' }],\n          },\n        }),\n      })\n    } else {\n      await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true }) })\n    }\n  })\n  await page.route('**/api/user-templates', async (route) => {\n    await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { templates: [] } }) })\n  })\n  await page.route('**/api/settings', async (route) => {\n    await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: {} }) })\n  })\n  // Image versions\n  await page.route('**/image-versions', async (route) => {\n    await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { versions: [] } }) })\n  })\n  // Image files\n  await page.route('**/files/**', async (route) => {\n    await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from([]) })\n  })\n}\n\ntest.describe('Preview text style template - Mock tests', () => {\n  test.beforeEach(async ({ page }) => {\n    await page.addInitScript(() => localStorage.setItem('hasSeenHelpModal', 'true'))\n  })\n\n  test('toggle switches between image template and text style mode', async ({ page }) => {\n    await setupMocks(page)\n    await page.goto(`${BASE_URL}/project/mock-proj/preview`)\n\n    // Open template modal\n    await page.getByText(/更换模板|Change Template/).click()\n\n    // Initially should show toggle label but NOT TextStyleSelector content\n    await expect(page.getByText(/使用文字描述风格|Use text description for style/)).toBeVisible()\n    await expect(page.getByText(/快速选择预设风格|Quick select preset styles/)).not.toBeVisible()\n\n    // Toggle to text style mode (click label text — the actual input is sr-only/off-screen)\n    await page.getByText(/使用文字描述风格|Use text description for style/).click()\n\n    // Now TextStyleSelector should be visible\n    await expect(page.getByText(/快速选择预设风格|Quick select preset styles/)).toBeVisible()\n    // Apply button should appear\n    await expect(page.getByText(/应用风格|Apply Style/)).toBeVisible()\n  })\n\n  test('clicking preset style fills textarea', async ({ page }) => {\n    await setupMocks(page)\n    await page.goto(`${BASE_URL}/project/mock-proj/preview`)\n    await page.getByText(/更换模板|Change Template/).click()\n\n    // Toggle to text style mode\n    await page.getByText(/使用文字描述风格|Use text description for style/).click()\n\n    // Click first preset style button (简约商务 / Business Simple)\n    await page.getByText(/简约商务|Business Simple/).click()\n\n    // Textarea should now contain the preset description\n    await expect(page.locator('textarea')).not.toHaveValue('')\n  })\n  test('closing modal without apply discards preset change', async ({ page }) => {\n    await setupMocks(page)\n    await page.goto(`${BASE_URL}/project/mock-proj/preview`)\n    await page.getByText(/更换模板|Change Template/).click()\n\n    // Toggle to text style, click a preset\n    await page.getByText(/使用文字描述风格|Use text description for style/).click()\n    await page.getByText(/简约商务|Business Simple/).click()\n    await expect(page.locator('textarea')).not.toHaveValue('')\n\n    // Close modal without clicking Apply\n    await page.getByText(/关闭|Close/).click()\n    await expect(page.getByText(/快速选择预设风格|Quick select preset styles/)).not.toBeVisible()\n\n    // Reopen — toggle is still on, textarea should be empty (draft discarded)\n    await page.getByRole('button', { name: /更换模板|Change Template/ }).click()\n    await expect(page.locator('textarea')).toHaveValue('')\n  })\n})\n\ntest.describe('Preview text style template - Integration tests', () => {\n  let projectId: string\n\n  test.beforeAll(async () => {\n    const seeded = await seedProjectWithImages(BACKEND_URL, 1)\n    projectId = seeded.projectId\n  })\n\n  test.beforeEach(async ({ page }) => {\n    await page.addInitScript(() => localStorage.setItem('hasSeenHelpModal', 'true'))\n  })\n\n  test('apply text style persists and survives reload', async ({ page }) => {\n    await page.goto(`${BASE_URL}/project/${projectId}/preview`)\n    await page.waitForLoadState('networkidle')\n\n    // Open template modal\n    await page.getByText(/更换模板|Change Template/).click()\n\n    // Toggle to text style mode\n    await page.getByText(/使用文字描述风格|Use text description for style/).click()\n\n    // Type a custom style\n    const textarea = page.locator('textarea')\n    await textarea.fill('E2E test custom style description')\n\n    // Click apply\n    await page.getByText(/应用风格|Apply Style/).click()\n\n    // Modal should close\n    await expect(page.getByText(/快速选择预设风格|Quick select preset styles/)).not.toBeVisible()\n\n    // Reload and verify persistence\n    await page.reload()\n    await page.waitForLoadState('networkidle')\n\n    // Reopen template modal and toggle to text style to verify saved value\n    await page.getByText(/更换模板|Change Template/).click()\n    await page.getByText(/使用文字描述风格|Use text description for style/).click()\n    await expect(page.locator('textarea')).toHaveValue('E2E test custom style description')\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/renovation-aspect-ratio.spec.ts",
    "content": "/**\n * PPT Renovation Aspect Ratio - Integration E2E Test\n *\n * Verifies that PPT renovation projects preserve the original PDF's\n * aspect ratio instead of always defaulting to 16:9.\n */\nimport { test, expect } from '@playwright/test'\nimport * as fs from 'fs'\nimport * as path from 'path'\nimport { fileURLToPath } from 'url'\n\nconst BASE = process.env.BASE_URL || 'http://localhost:3000'\nconst API = `http://localhost:${Number(new URL(BASE).port) + 2000}`\n\nconst __filename = fileURLToPath(import.meta.url)\nconst __dirname = path.dirname(__filename)\n\ntest.describe.serial('Renovation aspect ratio', () => {\n  test.setTimeout(60_000)\n\n  const createdProjects: string[] = []\n\n  test.afterAll(async ({ request }) => {\n    for (const id of createdProjects) {\n      try {\n        await request.delete(`${API}/api/projects/${id}`)\n      } catch { /* best effort */ }\n    }\n  })\n\n  test('4:3 PDF gets 4:3 aspect ratio on project', async ({ request }) => {\n    const pdfPath = path.join(__dirname, 'fixtures', 'test-4-3.pdf')\n    const pdfBuffer = fs.readFileSync(pdfPath)\n\n    const res = await request.post(`${API}/api/projects/renovation`, {\n      multipart: {\n        file: {\n          name: 'test-4-3.pdf',\n          mimeType: 'application/pdf',\n          buffer: pdfBuffer,\n        },\n      },\n    })\n    expect(res.ok()).toBeTruthy()\n\n    const body = await res.json()\n    const projectId = body.data.project_id\n    createdProjects.push(projectId)\n\n    // Fetch the project to check its aspect ratio\n    const projRes = await request.get(`${API}/api/projects/${projectId}`)\n    expect(projRes.ok()).toBeTruthy()\n\n    const projData = await projRes.json()\n    expect(projData.data.image_aspect_ratio).toBe('4:3')\n  })\n\n  test('16:9 PDF gets 16:9 aspect ratio on project', async ({ request }) => {\n    const pdfPath = path.join(__dirname, 'fixtures', 'test-16-9.pdf')\n    const pdfBuffer = fs.readFileSync(pdfPath)\n\n    const res = await request.post(`${API}/api/projects/renovation`, {\n      multipart: {\n        file: {\n          name: 'test-16-9.pdf',\n          mimeType: 'application/pdf',\n          buffer: pdfBuffer,\n        },\n      },\n    })\n    expect(res.ok()).toBeTruthy()\n\n    const body = await res.json()\n    const projectId = body.data.project_id\n    createdProjects.push(projectId)\n\n    // Fetch the project to check its aspect ratio\n    const projRes = await request.get(`${API}/api/projects/${projectId}`)\n    expect(projRes.ok()).toBeTruthy()\n\n    const projData = await projRes.json()\n    expect(projData.data.image_aspect_ratio).toBe('16:9')\n  })\n\n  test('aspect ratio reflected in SlidePreview UI', async ({ page, request }) => {\n    // Upload a 4:3 PDF\n    const pdfPath = path.join(__dirname, 'fixtures', 'test-4-3.pdf')\n    const pdfBuffer = fs.readFileSync(pdfPath)\n\n    const res = await request.post(`${API}/api/projects/renovation`, {\n      multipart: {\n        file: {\n          name: 'test-4-3.pdf',\n          mimeType: 'application/pdf',\n          buffer: pdfBuffer,\n        },\n      },\n    })\n    expect(res.ok()).toBeTruthy()\n\n    const body = await res.json()\n    const projectId = body.data.project_id\n    createdProjects.push(projectId)\n\n    // Navigate to SlidePreview\n    await page.goto(`/project/${projectId}/preview`)\n    await page.waitForLoadState('networkidle')\n\n    // Open project settings\n    const settingsBtn = page.locator('button').filter({ hasText: /设置|Settings/ }).first()\n    await settingsBtn.click()\n\n    // The 4:3 button should be the active/selected one (has border-banana-500 class)\n    const ratioButton = page.locator('button:has-text(\"4:3\")').first()\n    await expect(ratioButton).toBeVisible()\n    await expect(ratioButton).toHaveClass(/border-banana-500/)\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/settings-api-clarity.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\n\ntest.beforeEach(async ({ page }) => {\n  await page.goto('/settings');\n  await page.waitForLoadState('networkidle');\n});\n\ntest('default API config section shows provider dropdown instead of buttons', async ({ page }) => {\n  await expect(page.getByText('默认 API 配置')).toBeVisible();\n\n  // Should have a provider dropdown (select), not buttons\n  const section = page.getByTestId('global-api-config-section');\n  const providerSelect = section.locator('select').first();\n  await expect(providerSelect).toBeVisible();\n\n  // Dropdown should contain same vendors as per-model\n  const texts = await providerSelect.locator('option').allTextContents();\n  expect(texts).toContain('Gemini');\n  expect(texts).toContain('OpenAI');\n  expect(texts).toContain('DeepSeek');\n});\n\ntest('per-model provider placeholder references default config', async ({ page }) => {\n  const defaultOption = page.locator('option', { hasText: '默认配置' });\n  await expect(defaultOption.first()).toBeAttached();\n});\n"
  },
  {
    "path": "frontend/e2e/settings-api-links.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\n\ntest.describe('Settings page API key labels and links', () => {\n  test.beforeEach(async ({ page }) => {\n    await page.goto('/settings');\n    await page.waitForLoadState('networkidle');\n  });\n\n  test('Baidu section title should not contain OCR', async ({ page }) => {\n    const baiduSection = page.locator('h2').filter({ hasText: /百度配置|Baidu Configuration/ });\n    await expect(baiduSection).toBeVisible();\n    await expect(page.locator('h2').filter({ hasText: /百度 OCR 配置|Baidu OCR Configuration/ })).not.toBeVisible();\n  });\n\n  test('Baidu API Key label should not contain OCR', async ({ page }) => {\n    const baiduLabel = page.locator('label').filter({ hasText: /百度 API Key|Baidu API Key/ });\n    await expect(baiduLabel).toBeVisible();\n    await expect(page.locator('label:has-text(\"百度 OCR API Key\")')).not.toBeVisible();\n  });\n\n  test('MinerU Token field has application link', async ({ page }) => {\n    const mineruLink = page.locator('a[href=\"https://mineru.net/apiManage/token\"]');\n    await expect(mineruLink).toBeVisible();\n    await expect(mineruLink).toHaveAttribute('target', '_blank');\n  });\n\n  test('Baidu API Key field has application link', async ({ page }) => {\n    const baiduLink = page.locator('a[href=\"https://console.bce.baidu.com/iam/#/iam/apikey/list\"]');\n    await expect(baiduLink).toBeVisible();\n    await expect(baiduLink).toHaveAttribute('target', '_blank');\n  });\n\n  test('AIHubMix has apply link', async ({ page }) => {\n    const aihubLink = page.locator('a[href=\"https://aihubmix.com/token?aff=17EC\"]');\n    await expect(aihubLink).toBeVisible();\n    await expect(aihubLink).toHaveAttribute('target', '_blank');\n  });\n});\n"
  },
  {
    "path": "frontend/e2e/settings-back-to-top.spec.ts",
    "content": "import { test, expect } from '@playwright/test'\n\n// Mock test: verify UI logic with mocked backend\ntest.describe('Settings back-to-top button (mock)', () => {\n  test('shows button on scroll and scrolls to top on click', async ({ page }) => {\n    await page.route('**/api/settings', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: { ai_provider_format: 'gemini', image_resolution: '2K', max_description_workers: 5, max_image_workers: 8, output_language: 'zh' }\n        })\n      })\n    })\n\n    await page.goto('/settings')\n    await page.waitForLoadState('networkidle')\n\n    const btn = page.getByTestId('back-to-top-button')\n    await expect(btn).not.toBeVisible()\n\n    await page.evaluate(() => window.scrollTo(0, 500))\n    await expect(btn).toBeVisible({ timeout: 3000 })\n\n    await btn.click()\n    await page.waitForFunction(() => window.scrollY < 50, null, { timeout: 3000 })\n    expect(await page.evaluate(() => window.scrollY)).toBeLessThan(50)\n  })\n})\n\n// Integration test: verify with real backend settings data\ntest.describe('Settings back-to-top button (integration)', () => {\n  test('works with real backend settings loaded', async ({ page }) => {\n    await page.goto('/settings')\n    await page.waitForLoadState('networkidle')\n\n    const btn = page.getByTestId('back-to-top-button')\n    await expect(btn).not.toBeVisible()\n\n    await page.evaluate(() => window.scrollTo(0, 500))\n    await expect(btn).toBeVisible({ timeout: 3000 })\n\n    await btn.click()\n    await page.waitForFunction(() => window.scrollY < 50, null, { timeout: 3000 })\n    expect(await page.evaluate(() => window.scrollY)).toBeLessThan(50)\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/settings-backfill.spec.ts",
    "content": "/**\n * E2E tests for Settings page env backfill behavior.\n *\n * Mock tests verify the frontend correctly renders backfilled values.\n * Integration tests verify the backend actually backfills None fields from Config.\n */\n\nimport { test, expect } from '@playwright/test'\n\nconst BASE_URL = process.env.BASE_URL || 'http://localhost:3000'\n\ntest.describe('Settings backfill - Mock tests', () => {\n  test('should display env-backfilled values on first load', async ({ page }) => {\n    // Mock GET /api/settings to return data as if backend backfilled from env\n    await page.route('**/api/settings', async (route) => {\n      if (route.request().method() === 'GET') {\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({\n            success: true,\n            data: {\n              id: 1,\n              ai_provider_format: 'gemini',\n              api_base_url: null,\n              api_key_length: 39,\n              image_resolution: '2K',\n              image_aspect_ratio: '16:9',\n              max_description_workers: 5,\n              max_image_workers: 8,\n              text_model: 'gemini-2.5-flash',\n              image_model: 'gemini-2.0-flash-preview-image-generation',\n              mineru_api_base: null,\n              mineru_token_length: 0,\n              image_caption_model: null,\n              output_language: 'zh',\n              enable_text_reasoning: false,\n              text_thinking_budget: 1024,\n              enable_image_reasoning: false,\n              image_thinking_budget: 1024,\n              baidu_api_key_length: 0,\n              text_model_source: null,\n              image_model_source: null,\n              image_caption_model_source: null,\n              lazyllm_api_keys_info: {},\n            },\n          }),\n        })\n      } else {\n        await route.continue()\n      }\n    })\n\n    await page.goto(`${BASE_URL}/settings`)\n    await page.waitForLoadState('networkidle')\n\n    // text_model should be populated from env\n    const textModel = page.locator('input[value=\"gemini-2.5-flash\"]')\n    await expect(textModel).toBeVisible()\n\n    // image_model should be populated from env\n    const imageModel = page.locator('input[value=\"gemini-2.0-flash-preview-image-generation\"]')\n    await expect(imageModel).toBeVisible()\n\n    // API key placeholder should show length > 0 (已设置（长度: 39）)\n    const apiKeyInput = page.locator('input[type=\"password\"]').first()\n    const placeholder = await apiKeyInput.getAttribute('placeholder')\n    expect(placeholder).toContain('39')\n  })\n\n  test('should show length 0 when api_key is not configured', async ({ page }) => {\n    await page.route('**/api/settings', async (route) => {\n      if (route.request().method() === 'GET') {\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({\n            success: true,\n            data: {\n              id: 1,\n              ai_provider_format: 'gemini',\n              api_base_url: null,\n              api_key_length: 0,\n              image_resolution: '2K',\n              image_aspect_ratio: '16:9',\n              max_description_workers: 5,\n              max_image_workers: 8,\n              text_model: '',\n              image_model: '',\n              mineru_api_base: null,\n              mineru_token_length: 0,\n              image_caption_model: null,\n              output_language: 'zh',\n              enable_text_reasoning: false,\n              text_thinking_budget: 1024,\n              enable_image_reasoning: false,\n              image_thinking_budget: 1024,\n              baidu_api_key_length: 0,\n              text_model_source: null,\n              image_model_source: null,\n              image_caption_model_source: null,\n              lazyllm_api_keys_info: {},\n            },\n          }),\n        })\n      } else {\n        await route.continue()\n      }\n    })\n\n    await page.goto(`${BASE_URL}/settings`)\n    await page.waitForLoadState('networkidle')\n\n    // API key placeholder should NOT contain a non-zero length\n    const apiKeyInput = page.locator('input[type=\"password\"]').first()\n    const placeholder = await apiKeyInput.getAttribute('placeholder')\n    // After frontend fix: when length is 0, should show default placeholder, not \"已设置（长度: 0）\"\n    expect(placeholder).not.toContain('已设置')\n  })\n})\n\ntest.describe('Settings backfill - Integration tests', () => {\n  test('GET /api/settings should return backfilled env values', async ({ request }) => {\n    // Reset then clear text_model — backend converts empty string to NULL in DB\n    await request.post(`${BASE_URL}/api/settings/reset`)\n    await request.put(`${BASE_URL}/api/settings`, {\n      data: { text_model: '' },\n    })\n\n    // GET triggers backfill: NULL fields get re-populated from env Config\n    const resp = await request.get(`${BASE_URL}/api/settings`)\n    expect(resp.ok()).toBeTruthy()\n    const data = (await resp.json()).data\n\n    // text_model should be backfilled from env (non-empty if TEXT_MODEL is set)\n    expect(data.text_model).not.toBe('')\n    expect(data.text_model).not.toBeNull()\n    expect(data).toHaveProperty('api_key_length')\n    expect(typeof data.api_key_length).toBe('number')\n    expect(data).toHaveProperty('image_model')\n  })\n\n  test('Settings page should load and display values from backend', async ({ page }) => {\n    // Reset settings to ensure env values are loaded\n    await page.request.post(`${BASE_URL}/api/settings/reset`)\n\n    await page.goto(`${BASE_URL}/settings`)\n    await page.waitForLoadState('networkidle')\n\n    // The page should load without errors - check that the settings form is visible\n    // Look for the save button as indicator the page loaded\n    const saveButton = page.getByRole('button', { name: /保存|Save/ })\n    await expect(saveButton).toBeVisible()\n\n    // Verify the reset button exists\n    const resetButton = page.getByRole('button', { name: /重置|Reset/ })\n    await expect(resetButton).toBeVisible()\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/settings-env-fallback.spec.ts",
    "content": "/**\n * E2E tests for issue #289: _sync_settings_to_config should restore .env\n * defaults instead of popping config keys when DB fields are NULL.\n *\n * Mock test: verifies the settings save UI flow works when API key is not touched.\n * Integration test: verifies backend preserves config state after saving without api_key.\n */\n\nimport { test, expect } from '@playwright/test'\n\nconst BASE_URL = process.env.BASE_URL || 'http://localhost:3000'\n\ntest.describe('Settings env fallback - Mock tests', () => {\n  test('saving settings without touching API key should succeed', async ({ page }) => {\n    // Mock GET /api/settings — DB has NULL api_key (relies on .env)\n    await page.route('**/api/settings', async (route) => {\n      if (route.request().method() === 'GET') {\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({\n            success: true,\n            data: {\n              id: 1,\n              ai_provider_format: 'gemini',\n              api_base_url: null,\n              api_key_length: 39,\n              image_resolution: '2K',\n              image_aspect_ratio: '16:9',\n              max_description_workers: 5,\n              max_image_workers: 8,\n              text_model: 'gemini-2.5-flash',\n              image_model: 'gemini-2.0-flash-preview-image-generation',\n              mineru_api_base: null,\n              mineru_token_length: 0,\n              image_caption_model: null,\n              output_language: 'zh',\n              enable_text_reasoning: false,\n              text_thinking_budget: 1024,\n              enable_image_reasoning: false,\n              image_thinking_budget: 1024,\n              baidu_api_key_length: 0,\n              text_model_source: null,\n              image_model_source: null,\n              image_caption_model_source: null,\n              lazyllm_api_keys_info: {},\n              text_api_key_length: 0,\n              text_api_base_url: null,\n              image_api_key_length: 0,\n              image_api_base_url: null,\n              image_caption_api_key_length: 0,\n              image_caption_api_base_url: null,\n            },\n          }),\n        })\n      } else {\n        await route.continue()\n      }\n    })\n\n    // Mock PUT /api/settings — capture payload to verify api_key is NOT sent\n    let putPayload: Record<string, unknown> | null = null\n    await page.route('**/api/settings', async (route) => {\n      if (route.request().method() === 'PUT') {\n        putPayload = route.request().postDataJSON()\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({\n            success: true,\n            data: { id: 1, image_resolution: '4K' },\n          }),\n        })\n      } else {\n        await route.fallback()\n      }\n    })\n\n    await page.goto(`${BASE_URL}/settings`)\n    await page.waitForLoadState('networkidle')\n\n    // Change image resolution from 2K to 4K (a non-key field)\n    const resolutionSelect = page.locator('select').filter({ has: page.locator('option[value=\"2K\"]') })\n    await resolutionSelect.selectOption('4K')\n\n    // Click save\n    const saveButton = page.getByRole('button', { name: /保存|Save/i })\n    await saveButton.click()\n\n    // Verify success toast appears (text is \"设置保存成功\" or \"Settings saved successfully\")\n    await expect(page.getByText(/设置保存成功|Settings saved successfully/)).toBeVisible({ timeout: 5000 })\n\n    // Verify PUT payload does NOT include api_key (frontend only sends it when user types a new value)\n    expect(putPayload).not.toBeNull()\n    expect(putPayload).not.toHaveProperty('api_key')\n  })\n})\n\ntest.describe('Settings env fallback - Integration tests', () => {\n  test('saving without api_key should not corrupt backend config', async ({ request }) => {\n    // 1. Get initial settings state\n    const getRes1 = await request.get(`${BASE_URL}/api/settings`)\n    expect(getRes1.ok()).toBeTruthy()\n    const initial = (await getRes1.json()).data\n    const initialKeyLen = initial.api_key_length\n\n    // 2. Save settings with only image_resolution (no api_key in payload)\n    //    This triggers _sync_settings_to_config with settings.api_key = NULL\n    const putRes = await request.put(`${BASE_URL}/api/settings`, {\n      data: { image_resolution: '4K' },\n    })\n    expect(putRes.ok()).toBeTruthy()\n\n    // 3. Save again with a different field to trigger _sync_settings_to_config a second time\n    //    Before the fix, the second save would find config keys already popped\n    const putRes2 = await request.put(`${BASE_URL}/api/settings`, {\n      data: { image_resolution: '2K' },\n    })\n    expect(putRes2.ok()).toBeTruthy()\n\n    // 4. Verify settings are still consistent — api_key_length should be unchanged\n    //    (to_dict backfills from Config, so this confirms no crash; the real fix\n    //    is that app.config keys are preserved for services like _create_file_parser)\n    const getRes2 = await request.get(`${BASE_URL}/api/settings`)\n    expect(getRes2.ok()).toBeTruthy()\n    const after = (await getRes2.json()).data\n    expect(after.api_key_length).toBe(initialKeyLen)\n\n    // Restore original resolution\n    await request.put(`${BASE_URL}/api/settings`, {\n      data: { image_resolution: initial.image_resolution },\n    })\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/settings-per-model-provider-integration.spec.ts",
    "content": "/**\n * Integration E2E test for per-model provider configuration.\n * Hits the REAL backend — verifies save persistence, reload, and reset.\n */\nimport { test, expect, Page } from '@playwright/test'\n\n/** Helper: get the nth model config group (0=text, 1=image, 2=caption) */\nfunction getModelGroup(page: Page, index: number) {\n  return page.locator('.space-y-4 > div').filter({ has: page.locator('select') }).nth(index)\n}\n\n// Clean up after all tests: reset settings to defaults\ntest.afterAll(async ({ browser }) => {\n  const page = await browser.newPage()\n  await page.goto('/settings')\n  await page.getByRole('button', { name: /重置/ }).click()\n  await page.getByRole('button', { name: /确定重置/ }).click()\n  await page.waitForTimeout(1000)\n  await page.close()\n})\n\ntest.describe('Settings: Per-model provider integration (real backend)', () => {\n  test.describe.configure({ mode: 'serial' })\n  test.setTimeout(30_000)\n\n  test('save per-model provider config persists to backend', async ({ page }) => {\n    await page.goto('/settings')\n\n    const textGroup = getModelGroup(page, 0)\n    const textSelect = textGroup.locator('select')\n\n    // Switch text model provider to OpenAI\n    await textSelect.selectOption('openai')\n\n    // Fill API Base URL\n    const baseUrlInput = textGroup.locator('input[type=\"text\"]').nth(1)\n    await baseUrlInput.fill('https://integration-test.example.com/v1')\n\n    // Fill API Key\n    const apiKeyInput = textGroup.locator('input[type=\"password\"]')\n    await apiKeyInput.fill('sk-integration-test-key')\n\n    // Click save\n    await page.getByRole('button', { name: /保存/ }).click()\n    await expect(page.locator('text=保存成功').or(page.locator('text=saved'))).toBeVisible({ timeout: 5000 })\n  })\n\n  test('reload page shows persisted per-model config', async ({ page }) => {\n    await page.goto('/settings')\n\n    const textGroup = getModelGroup(page, 0)\n    const textSelect = textGroup.locator('select')\n\n    // Verify provider selection persisted\n    await expect(textSelect).toHaveValue('openai')\n\n    // Verify API Base URL persisted\n    const baseUrlInput = textGroup.locator('input[type=\"text\"]').nth(1)\n    await expect(baseUrlInput).toHaveValue('https://integration-test.example.com/v1')\n\n    // Verify API Key shows \"已设置\" placeholder (length > 0)\n    const apiKeyInput = textGroup.locator('input[type=\"password\"]')\n    const placeholder = await apiKeyInput.getAttribute('placeholder')\n    expect(placeholder).toMatch(/长度|length/i)\n  })\n\n  test('reset clears per-model config from backend', async ({ page }) => {\n    await page.goto('/settings')\n\n    // Verify we start with per-model config\n    const textGroup = getModelGroup(page, 0)\n    await expect(textGroup.locator('select')).toHaveValue('openai')\n\n    // Click reset\n    await page.getByRole('button', { name: /重置/ }).click()\n    await page.getByRole('button', { name: /确定重置/ }).click()\n\n    // Wait for reset to complete\n    await expect(page.locator('text=已重置').or(page.locator('text=reset'))).toBeVisible({ timeout: 5000 })\n\n    // Verify provider reverted to env default\n    await expect(textGroup.locator('select')).not.toHaveValue('openai')\n\n    // Verify API Base URL field is hidden (lazyllm vendor or empty = no base URL)\n    await expect(textGroup.locator('text=API Base URL')).toBeHidden()\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/settings-per-model-provider.spec.ts",
    "content": "/**\n * E2E test for per-model provider configuration in Settings page.\n * Tests: load with saved config, provider switching, save, reload persistence, reset.\n */\nimport { test, expect, Page } from '@playwright/test'\n\n// Mock settings data with per-model provider config\nconst mockSettingsWithPerModel = {\n  success: true,\n  message: 'Success',\n  data: {\n    id: 1,\n    ai_provider_format: 'gemini',\n    api_base_url: 'https://aihubmix.com/gemini',\n    api_key_length: 51,\n    text_model: 'glm-4.5',\n    image_model: 'imagen-3.0-generate-001',\n    image_caption_model: 'gemini-3-flash-preview',\n    image_resolution: '2K',\n    image_aspect_ratio: '16:9',\n    max_description_workers: 5,\n    max_image_workers: 8,\n    output_language: 'zh',\n    enable_text_reasoning: false,\n    text_thinking_budget: 1024,\n    enable_image_reasoning: false,\n    image_thinking_budget: 1024,\n    mineru_api_base: '',\n    mineru_token_length: 0,\n    baidu_api_key_length: 0,\n    // Per-model provider config\n    text_model_source: 'openai',\n    text_api_key_length: 26,\n    text_api_base_url: 'https://test-openai.example.com/v1',\n    image_model_source: 'gemini',\n    image_api_key_length: 30,\n    image_api_base_url: 'https://test-gemini.example.com',\n    image_caption_model_source: 'doubao',\n    image_caption_api_key_length: 0,\n    image_caption_api_base_url: null,\n    lazyllm_api_keys_info: {},\n  },\n}\n\n// Default settings (after reset)\nconst mockDefaultSettings = {\n  success: true,\n  message: 'Success',\n  data: {\n    ...mockSettingsWithPerModel.data,\n    text_model_source: 'deepseek',\n    text_api_key_length: 0,\n    text_api_base_url: null,\n    image_model_source: 'doubao',\n    image_api_key_length: 0,\n    image_api_base_url: null,\n    image_caption_model_source: 'doubao',\n    image_caption_api_key_length: 0,\n    image_caption_api_base_url: null,\n  },\n}\n\n/** Helper: get the nth model config group (0=text, 1=image, 2=caption) */\nfunction getModelGroup(page: Page, index: number) {\n  return page.locator('.space-y-4 > div').filter({ has: page.locator('select') }).nth(index)\n}\n\ntest.describe('Settings: Per-model provider configuration', () => {\n  test.setTimeout(30_000)\n\n  test('loads saved per-model provider config correctly', async ({ page }) => {\n    await page.route('**/api/settings', route =>\n      route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockSettingsWithPerModel) })\n    )\n\n    await page.goto('/settings')\n\n    // Text model: OpenAI selected → should show API Base URL + API Key fields\n    const textSelect = page.locator('select').nth(0)\n    await expect(textSelect).toHaveValue('openai')\n\n    const textGroup = getModelGroup(page, 0)\n    const textBaseUrl = textGroup.locator('input[type=\"text\"]').nth(1) // nth(0) is model name\n    await expect(textBaseUrl).toHaveValue('https://test-openai.example.com/v1')\n\n    // Image model: Gemini selected → should show API Base URL + API Key fields\n    const imageSelect = page.locator('select').nth(1)\n    await expect(imageSelect).toHaveValue('gemini')\n\n    const imageGroup = getModelGroup(page, 1)\n    const imageBaseUrl = imageGroup.locator('input[type=\"text\"]').nth(1)\n    await expect(imageBaseUrl).toHaveValue('https://test-gemini.example.com')\n\n    // Image caption: Doubao (lazyllm vendor) → should show vendor API Key, NOT base URL\n    const captionSelect = page.locator('select').nth(2)\n    await expect(captionSelect).toHaveValue('doubao')\n\n    // Doubao is lazyllm vendor → no API Base URL field, but has vendor API Key\n    const captionGroup = getModelGroup(page, 2)\n    await expect(captionGroup.locator('text=API Base URL')).toBeHidden()\n    await expect(captionGroup.locator('text=API Key').first()).toBeVisible()\n  })\n\n  test('switching provider shows/hides conditional fields', async ({ page }) => {\n    await page.route('**/api/settings', route =>\n      route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockDefaultSettings) })\n    )\n\n    await page.goto('/settings')\n\n    const textGroup = getModelGroup(page, 0)\n    const textSelect = textGroup.locator('select')\n\n    // Default: deepseek (lazyllm) → vendor API Key shown, no Base URL\n    await expect(textSelect).toHaveValue('deepseek')\n    await expect(textGroup.locator('text=API Base URL')).toBeHidden()\n\n    // Switch to OpenAI → API Base URL + API Key appear\n    await textSelect.selectOption('openai')\n    await expect(textGroup.locator('text=API Base URL')).toBeVisible()\n    await expect(textGroup.locator('input[type=\"password\"]')).toBeVisible()\n\n    // Switch to Gemini → still shows API Base URL + API Key\n    await textSelect.selectOption('gemini')\n    await expect(textGroup.locator('text=API Base URL')).toBeVisible()\n\n    // Switch to default → no extra fields\n    await textSelect.selectOption('')\n    await expect(textGroup.locator('text=API Base URL')).toBeHidden()\n    await expect(textGroup.locator('input[type=\"password\"]')).toBeHidden()\n  })\n\n  test('save sends correct per-model payload', async ({ page }) => {\n    await page.route('**/api/settings', async route => {\n      if (route.request().method() === 'GET') {\n        await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockDefaultSettings) })\n      } else if (route.request().method() === 'PUT') {\n        const body = route.request().postDataJSON()\n        // Verify per-model fields in payload\n        expect(body.text_model_source).toBe('openai')\n        expect(body.text_api_base_url).toBe('https://new-openai.example.com')\n        expect(body.text_api_key).toBe('sk-test-key-123')\n\n        await route.fulfill({\n          status: 200, contentType: 'application/json',\n          body: JSON.stringify({\n            success: true,\n            data: { ...mockDefaultSettings.data, text_model_source: 'openai', text_api_base_url: 'https://new-openai.example.com', text_api_key_length: 15 },\n          }),\n        })\n      }\n    })\n\n    await page.goto('/settings')\n\n    // Switch text model to OpenAI and fill credentials\n    const textGroup = getModelGroup(page, 0)\n    await textGroup.locator('select').selectOption('openai')\n    await textGroup.locator('input[type=\"text\"]').nth(1).fill('https://new-openai.example.com')\n    await textGroup.locator('input[type=\"password\"]').fill('sk-test-key-123')\n\n    // Save\n    await page.getByRole('button', { name: /保存/ }).click()\n\n    // Verify success toast\n    await expect(page.locator('text=保存成功').or(page.locator('text=saved'))).toBeVisible({ timeout: 5000 })\n  })\n\n  test('reload persists saved per-model config', async ({ page }) => {\n    let usePerModel = false\n    await page.route('**/api/settings', route => {\n      const data = usePerModel ? mockSettingsWithPerModel : mockDefaultSettings\n      route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) })\n    })\n\n    // First load — default config\n    await page.goto('/settings')\n    await expect(page.locator('select').nth(0)).toHaveValue('deepseek')\n\n    // Simulate reload with updated data\n    usePerModel = true\n    await page.goto('/settings')\n    await expect(page.locator('select').nth(0)).toHaveValue('openai')\n\n    const textGroup = getModelGroup(page, 0)\n    const textBaseUrl = textGroup.locator('input[type=\"text\"]').nth(1)\n    await expect(textBaseUrl).toHaveValue('https://test-openai.example.com/v1')\n  })\n\n  test('reset clears per-model config', async ({ page }) => {\n    let isReset = false\n    await page.route('**/api/settings', route => {\n      const data = isReset ? mockDefaultSettings : mockSettingsWithPerModel\n      route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) })\n    })\n    await page.route('**/api/settings/reset', async route => {\n      isReset = true\n      await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockDefaultSettings) })\n    })\n\n    await page.goto('/settings')\n\n    // Verify initial state has per-model config\n    await expect(page.locator('select').nth(0)).toHaveValue('openai')\n\n    // Click reset\n    await page.getByRole('button', { name: /重置/ }).click()\n    // Confirm dialog\n    await page.getByRole('button', { name: /确定重置/ }).click()\n\n    // After reset: sources revert to env defaults, no API base URL fields\n    await expect(page.locator('select').nth(0)).toHaveValue('deepseek')\n\n    const textGroup = getModelGroup(page, 0)\n    await expect(textGroup.locator('text=API Base URL')).toBeHidden()\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/settings-read-only.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\nimport { execSync } from 'child_process';\nimport path from 'path';\nimport { fileURLToPath } from 'url';\n\nconst BASE = process.env.BASE_URL ?? 'http://localhost:5173';\nconst DB_PATH = process.env.DB_PATH ??\n  path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../backend/instance/database.db');\n\nfunction dbQuery(sql: string): string {\n  return execSync(`sqlite3 \"${DB_PATH}\" \"${sql}\"`).toString().trim();\n}\n\n// ===== Integration Tests =====\n\ntest.describe.configure({ mode: 'serial' });\n\ntest.describe('Settings .env fallback behavior', () => {\n\n  test('GET /api/settings does not persist .env defaults to DB', async ({ request }) => {\n    const original = dbQuery('SELECT quote(text_model) FROM settings WHERE id=1;');\n    dbQuery('UPDATE settings SET text_model=NULL WHERE id=1;');\n\n    try {\n      const res = await request.get(`${BASE}/api/settings`);\n      expect(res.ok()).toBeTruthy();\n      const data = (await res.json()).data;\n      // API returns .env default even though DB is NULL\n      expect(data.text_model).toBeTruthy();\n\n      // DB field is still NULL (no write side-effect)\n      const dbVal = dbQuery('SELECT quote(text_model) FROM settings WHERE id=1;');\n      expect(dbVal).toBe('NULL');\n    } finally {\n      dbQuery('UPDATE settings SET text_model=' + original + ' WHERE id=1;');\n    }\n  });\n\n  test('PUT /api/settings persists value to DB', async ({ request }) => {\n    const original = dbQuery('SELECT quote(text_model) FROM settings WHERE id=1;');\n\n    try {\n      const res = await request.put(`${BASE}/api/settings`, {\n        data: { text_model: 'test-model-persist' },\n      });\n      expect(res.ok()).toBeTruthy();\n\n      // Verify DB has the saved value\n      const dbVal = dbQuery('SELECT text_model FROM settings WHERE id=1;');\n      expect(dbVal).toBe('test-model-persist');\n    } finally {\n      dbQuery('UPDATE settings SET text_model=' + original + ' WHERE id=1;');\n    }\n  });\n\n  test('POST /api/settings/reset clears fields to NULL', async ({ request }) => {\n    const origModel = dbQuery('SELECT quote(text_model) FROM settings WHERE id=1;');\n    const origRes = dbQuery('SELECT quote(image_resolution) FROM settings WHERE id=1;');\n\n    // Ensure non-NULL values exist before reset\n    dbQuery(\"UPDATE settings SET text_model='before-reset', image_resolution='4K' WHERE id=1;\");\n\n    try {\n      const res = await request.post(`${BASE}/api/settings/reset`);\n      expect(res.ok()).toBeTruthy();\n\n      // Verify DB fields are NULL after reset\n      const modelVal = dbQuery('SELECT quote(text_model) FROM settings WHERE id=1;');\n      expect(modelVal).toBe('NULL');\n      const resVal = dbQuery('SELECT quote(image_resolution) FROM settings WHERE id=1;');\n      expect(resVal).toBe('NULL');\n\n      // API still returns .env defaults (not NULL)\n      const getRes = await request.get(`${BASE}/api/settings`);\n      const data = (await getRes.json()).data;\n      expect(data.image_resolution).toBeTruthy();\n    } finally {\n      dbQuery('UPDATE settings SET text_model=' + origModel + ', image_resolution=' + origRes + ' WHERE id=1;');\n    }\n  });\n\n  test('NULL fields in DB fall back to .env on every GET', async ({ request }) => {\n    const origLang = dbQuery('SELECT quote(output_language) FROM settings WHERE id=1;');\n    const origFormat = dbQuery('SELECT quote(ai_provider_format) FROM settings WHERE id=1;');\n\n    dbQuery('UPDATE settings SET output_language=NULL, ai_provider_format=NULL WHERE id=1;');\n\n    try {\n      const res = await request.get(`${BASE}/api/settings`);\n      expect(res.ok()).toBeTruthy();\n      const data = (await res.json()).data;\n\n      // These should return .env defaults, not NULL\n      expect(data.output_language).toBeTruthy();\n      expect(data.ai_provider_format).toBeTruthy();\n    } finally {\n      dbQuery('UPDATE settings SET output_language=' + origLang + ', ai_provider_format=' + origFormat + ' WHERE id=1;');\n    }\n  });\n});\n"
  },
  {
    "path": "frontend/e2e/settings-reset-fallback.spec.ts",
    "content": "/**\n * E2E tests for settings reset fallback behavior.\n *\n * Verifies that after saving custom model/format values then resetting,\n * both the API response (via to_dict) AND the internal app.config\n * correctly fall back to .env defaults.\n *\n * This covers the regression where _sync_settings_to_config skipped\n * restoring text_model, image_model, and ai_provider_format to .env\n * defaults when DB fields were NULL after reset.\n */\n\nimport { test, expect } from '@playwright/test'\n\nconst BASE = process.env.BASE_URL ?? 'http://localhost:5173'\n\ntest.describe.configure({ mode: 'serial' })\n\ntest.describe('Settings reset fallback - Integration tests', () => {\n  // Capture .env defaults from initial state\n  let envDefaults: {\n    text_model: string\n    image_model: string\n    ai_provider_format: string\n    output_language: string\n  }\n\n  test.beforeAll(async ({ request }) => {\n    // Reset first to ensure clean state, then read .env defaults\n    const resetRes = await request.post(`${BASE}/api/settings/reset`)\n    expect(resetRes.ok()).toBeTruthy()\n\n    const configRes = await request.get(`${BASE}/api/settings/active-config`)\n    expect(configRes.ok()).toBeTruthy()\n    envDefaults = (await configRes.json()).data\n    expect(envDefaults.text_model).toBeTruthy()\n    expect(envDefaults.image_model).toBeTruthy()\n    expect(envDefaults.ai_provider_format).toBeTruthy()\n  })\n\n  test('reset after custom save restores app.config to .env defaults', async ({ request }) => {\n    // 1. Save custom values\n    const putRes = await request.put(`${BASE}/api/settings`, {\n      data: {\n        text_model: 'custom-test-model',\n        image_model: 'custom-image-model',\n        ai_provider_format: 'openai',\n        output_language: 'en',\n      },\n    })\n    expect(putRes.ok()).toBeTruthy()\n\n    // 2. Verify app.config picked up the custom values\n    const configAfterSave = await request.get(`${BASE}/api/settings/active-config`)\n    expect(configAfterSave.ok()).toBeTruthy()\n    const savedConfig = (await configAfterSave.json()).data\n    expect(savedConfig.text_model).toBe('custom-test-model')\n    expect(savedConfig.image_model).toBe('custom-image-model')\n    expect(savedConfig.ai_provider_format).toBe('openai')\n    expect(savedConfig.output_language).toBe('en')\n\n    // 3. Reset settings\n    const resetRes = await request.post(`${BASE}/api/settings/reset`)\n    expect(resetRes.ok()).toBeTruthy()\n\n    // 4. Verify app.config has .env defaults (not stale custom values)\n    const configAfterReset = await request.get(`${BASE}/api/settings/active-config`)\n    expect(configAfterReset.ok()).toBeTruthy()\n    const resetConfig = (await configAfterReset.json()).data\n    expect(resetConfig.text_model).toBe(envDefaults.text_model)\n    expect(resetConfig.image_model).toBe(envDefaults.image_model)\n    expect(resetConfig.ai_provider_format).toBe(envDefaults.ai_provider_format)\n    expect(resetConfig.output_language).toBe(envDefaults.output_language)\n  })\n\n  test('save after reset still uses .env defaults in app.config', async ({ request }) => {\n    // This tests the double-save scenario: reset → save unrelated field →\n    // verify model fields in app.config are still .env defaults (not missing)\n\n    // 1. Save custom text_model\n    await request.put(`${BASE}/api/settings`, {\n      data: { text_model: 'will-be-reset' },\n    })\n\n    // 2. Reset\n    const resetRes = await request.post(`${BASE}/api/settings/reset`)\n    expect(resetRes.ok()).toBeTruthy()\n\n    // 3. Save an unrelated field (triggers _sync_settings_to_config with NULL text_model)\n    const putRes = await request.put(`${BASE}/api/settings`, {\n      data: { image_resolution: '4K' },\n    })\n    expect(putRes.ok()).toBeTruthy()\n\n    // 4. app.config should still have .env defaults\n    const configRes = await request.get(`${BASE}/api/settings/active-config`)\n    expect(configRes.ok()).toBeTruthy()\n    const config = (await configRes.json()).data\n    expect(config.text_model).toBe(envDefaults.text_model)\n    expect(config.image_model).toBe(envDefaults.image_model)\n    expect(config.ai_provider_format).toBe(envDefaults.ai_provider_format)\n\n    // Cleanup\n    await request.put(`${BASE}/api/settings`, {\n      data: { image_resolution: null },\n    })\n  })\n\n  test('API response and app.config agree after reset', async ({ request }) => {\n    // Save custom values then reset — both to_dict() and app.config should return .env defaults\n    await request.put(`${BASE}/api/settings`, {\n      data: {\n        text_model: 'mismatch-test-model',\n        image_model: 'mismatch-test-image',\n      },\n    })\n\n    await request.post(`${BASE}/api/settings/reset`)\n\n    // Get both API response and active config\n    const [settingsRes, configRes] = await Promise.all([\n      request.get(`${BASE}/api/settings`),\n      request.get(`${BASE}/api/settings/active-config`),\n    ])\n    expect(settingsRes.ok()).toBeTruthy()\n    expect(configRes.ok()).toBeTruthy()\n\n    const apiData = (await settingsRes.json()).data\n    const configData = (await configRes.json()).data\n\n    // API response and app.config must agree\n    expect(apiData.text_model).toBe(configData.text_model)\n    expect(apiData.image_model).toBe(configData.image_model)\n    expect(apiData.ai_provider_format).toBe(configData.ai_provider_format)\n    expect(apiData.output_language).toBe(configData.output_language)\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/settings-test-vendor-format.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\n\ntest.beforeEach(async ({ page }) => {\n  // Mock settings API to return deepseek as default provider\n  await page.route('**/api/settings', async (route) => {\n    if (route.request().method() === 'GET') {\n      await route.fulfill({\n        json: {\n          data: {\n            ai_provider_format: 'lazyllm',\n            lazyllm_api_keys_info: { deepseek: 10 },\n            api_base_url: '', api_key_length: 0,\n            text_model: '', image_model: '', image_caption_model: '',\n            text_model_source: '', image_model_source: '', image_caption_model_source: '',\n            image_resolution: '2K', max_description_workers: 5, max_image_workers: 8,\n            output_language: 'zh', mineru_api_base: '', mineru_token_length: 0,\n            enable_text_reasoning: false, text_thinking_budget: 1024,\n            enable_image_reasoning: false, image_thinking_budget: 1024,\n            baidu_api_key_length: 0,\n            text_api_key_length: 0, text_api_base_url: '',\n            image_api_key_length: 0, image_api_base_url: '',\n            image_caption_api_key_length: 0, image_caption_api_base_url: '',\n          }\n        }\n      });\n    } else {\n      await route.continue();\n    }\n  });\n\n  await page.goto('/settings');\n  await page.waitForLoadState('networkidle');\n});\n\ntest('service test sends lazyllm format instead of raw vendor name', async ({ page }) => {\n  const section = page.getByTestId('global-api-config-section');\n  const providerSelect = section.locator('select').first();\n  await expect(providerSelect).toHaveValue('deepseek');\n\n  let capturedPayload: any = null;\n  await page.route('**/api/settings/tests/text-model', async (route) => {\n    capturedPayload = route.request().postDataJSON();\n    await route.fulfill({ json: { data: { task_id: 'mock-task-123' } } });\n  });\n\n  const textModelTestBtn = page.locator('button', { hasText: /开始测试|Start Test/ }).nth(1);\n  await textModelTestBtn.click();\n\n  expect(capturedPayload).toBeTruthy();\n  expect(capturedPayload.ai_provider_format).toBe('lazyllm');\n});\n\ntest('service test sends empty model source to clear saved per-model override', async ({ page }) => {\n  let capturedPayload: any = null;\n  await page.route('**/api/settings/tests/text-model', async (route) => {\n    capturedPayload = route.request().postDataJSON();\n    await route.fulfill({ json: { data: { task_id: 'mock-task-123' } } });\n  });\n\n  const textModelTestBtn = page.locator('button', { hasText: /开始测试|Start Test/ }).nth(1);\n  await textModelTestBtn.click();\n\n  expect(capturedPayload).toBeTruthy();\n  // Per-model sources should always be sent (even empty) so backend clears saved overrides\n  expect(capturedPayload).toHaveProperty('text_model_source', '');\n  expect(capturedPayload).toHaveProperty('image_model_source', '');\n  expect(capturedPayload).toHaveProperty('image_caption_model_source', '');\n});\n"
  },
  {
    "path": "frontend/e2e/smart-merge.spec.ts",
    "content": "/**\n * Position-based Page Merge - Mock E2E Tests\n *\n * Verifies that regenerating/refining outline preserves descriptions and images\n * by page position, and that trailing pages are deleted when the new outline is shorter.\n */\nimport { test, expect } from '@playwright/test'\n\nconst BASE = process.env.BASE_URL || 'http://localhost:3000'\nconst PROJECT_ID = 'mock-merge-proj'\n\nconst INITIAL_PAGES = [\n  {\n    page_id: 'page-0',\n    order_index: 0,\n    part: null,\n    outline_content: { title: 'Introduction', points: ['overview'] },\n    description_content: { text: 'Intro description' },\n    generated_image_url: '/files/mock/pages/img-0.jpg',\n    status: 'IMAGE_GENERATED',\n  },\n  {\n    page_id: 'page-1',\n    order_index: 1,\n    part: null,\n    outline_content: { title: 'Details', points: ['detail1'] },\n    description_content: { text: 'Details description' },\n    generated_image_url: '/files/mock/pages/img-1.jpg',\n    status: 'IMAGE_GENERATED',\n  },\n  {\n    page_id: 'page-2',\n    order_index: 2,\n    part: null,\n    outline_content: { title: 'Conclusion', points: ['summary'] },\n    description_content: { text: 'Conclusion description' },\n    generated_image_url: '/files/mock/pages/img-2.jpg',\n    status: 'IMAGE_GENERATED',\n  },\n]\n\n// After refine with fewer pages: positions 0,1 preserved, position 2 deleted\nconst REFINED_FEWER_PAGES = [\n  {\n    page_id: 'page-0',\n    order_index: 0,\n    part: null,\n    outline_content: { title: 'New Intro Title', points: ['updated'] },\n    description_content: { text: 'Intro description' },\n    generated_image_url: '/files/mock/pages/img-0.jpg',\n    status: 'IMAGE_GENERATED',\n  },\n  {\n    page_id: 'page-1',\n    order_index: 1,\n    part: null,\n    outline_content: { title: 'New Details Title', points: ['updated'] },\n    description_content: { text: 'Details description' },\n    generated_image_url: '/files/mock/pages/img-1.jpg',\n    status: 'IMAGE_GENERATED',\n  },\n]\n\n// After refine with more pages: positions 0,1,2 preserved, position 3 new\nconst REFINED_MORE_PAGES = [\n  {\n    page_id: 'page-0',\n    order_index: 0,\n    part: null,\n    outline_content: { title: 'Intro Refined', points: ['new'] },\n    description_content: { text: 'Intro description' },\n    generated_image_url: '/files/mock/pages/img-0.jpg',\n    status: 'IMAGE_GENERATED',\n  },\n  {\n    page_id: 'page-1',\n    order_index: 1,\n    part: null,\n    outline_content: { title: 'Details Refined', points: ['new'] },\n    description_content: { text: 'Details description' },\n    generated_image_url: '/files/mock/pages/img-1.jpg',\n    status: 'IMAGE_GENERATED',\n  },\n  {\n    page_id: 'page-2',\n    order_index: 2,\n    part: null,\n    outline_content: { title: 'Conclusion Refined', points: ['new'] },\n    description_content: { text: 'Conclusion description' },\n    generated_image_url: '/files/mock/pages/img-2.jpg',\n    status: 'IMAGE_GENERATED',\n  },\n  {\n    page_id: 'page-3',\n    order_index: 3,\n    part: null,\n    outline_content: { title: 'New Extra Page', points: ['extra'] },\n    description_content: null,\n    generated_image_url: null,\n    status: 'DRAFT',\n  },\n]\n\nfunction setupMocks(page: import('@playwright/test').Page, pagesRef: { current: typeof INITIAL_PAGES }) {\n  return Promise.all([\n    page.route(`**/api/projects/${PROJECT_ID}`, async (route) => {\n      if (route.request().method() === 'GET') {\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({\n            success: true,\n            data: {\n              project_id: PROJECT_ID,\n              creation_type: 'idea',\n              idea_prompt: 'test presentation',\n              status: 'OUTLINE_GENERATED',\n              pages: pagesRef.current,\n            },\n          }),\n        })\n      } else {\n        await route.continue()\n      }\n    }),\n    page.route('**/files/mock/pages/**', async (route) => {\n      const pixel = Buffer.from(\n        'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',\n        'base64'\n      )\n      await route.fulfill({ status: 200, contentType: 'image/png', body: pixel })\n    }),\n  ])\n}\n\ntest.describe('Position-based Page Merge (Mocked)', () => {\n  test.setTimeout(30_000)\n\n  test('refine with fewer pages: trailing pages removed, earlier pages preserved', async ({ page }) => {\n    const pagesRef = { current: [...INITIAL_PAGES] }\n    await setupMocks(page, pagesRef)\n\n    await page.route(`**/api/projects/${PROJECT_ID}/refine/outline`, async (route) => {\n      pagesRef.current = REFINED_FEWER_PAGES\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: { pages: REFINED_FEWER_PAGES, message: '大纲修改成功' },\n        }),\n      })\n    })\n\n    await page.goto(`${BASE}/project/${PROJECT_ID}/outline`)\n    await expect(page.getByText('Introduction')).toBeVisible({ timeout: 5000 })\n    await expect(page.getByText('Details')).toBeVisible()\n    await expect(page.getByText('Conclusion')).toBeVisible()\n\n    // Refine to reduce pages\n    const refineInput = page.locator('input[placeholder*=\"增加\"], textarea[placeholder*=\"增加\"], input[placeholder*=\"Add\"], textarea[placeholder*=\"Add\"]')\n    if (await refineInput.count() > 0) {\n      await refineInput.first().fill('删除最后一页')\n      const refinePromise = page.waitForResponse(\n        (r) => r.url().includes('/refine/outline') && r.status() === 200\n      )\n      await refineInput.first().press('Control+Enter')\n      await refinePromise\n\n      // After: 2 pages, Conclusion gone\n      await expect(page.getByText('New Intro Title')).toBeVisible({ timeout: 5000 })\n      await expect(page.getByText('New Details Title')).toBeVisible()\n      await expect(page.getByText('Conclusion')).not.toBeVisible()\n    }\n  })\n\n  test('refine with more pages: all old pages preserved, new page added as DRAFT', async ({ page }) => {\n    const pagesRef = { current: [...INITIAL_PAGES] }\n    await setupMocks(page, pagesRef)\n\n    await page.route(`**/api/projects/${PROJECT_ID}/refine/outline`, async (route) => {\n      pagesRef.current = REFINED_MORE_PAGES\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: { pages: REFINED_MORE_PAGES, message: '大纲修改成功' },\n        }),\n      })\n    })\n\n    await page.goto(`${BASE}/project/${PROJECT_ID}/outline`)\n    await expect(page.getByText('Introduction')).toBeVisible({ timeout: 5000 })\n\n    const refineInput = page.locator('input[placeholder*=\"增加\"], textarea[placeholder*=\"增加\"], input[placeholder*=\"Add\"], textarea[placeholder*=\"Add\"]')\n    if (await refineInput.count() > 0) {\n      await refineInput.first().fill('增加一页额外内容')\n      const refinePromise = page.waitForResponse(\n        (r) => r.url().includes('/refine/outline') && r.status() === 200\n      )\n      await refineInput.first().press('Control+Enter')\n      await refinePromise\n\n      // All 4 pages visible\n      await expect(page.getByText('Intro Refined')).toBeVisible({ timeout: 5000 })\n      await expect(page.getByText('Details Refined')).toBeVisible()\n      await expect(page.getByText('Conclusion Refined')).toBeVisible()\n      await expect(page.getByText('New Extra Page')).toBeVisible()\n    }\n  })\n\n  test('regenerate shows warning dialog mentioning page deletion', async ({ page }) => {\n    await setupMocks(page, { current: INITIAL_PAGES })\n\n    await page.goto(`${BASE}/project/${PROJECT_ID}/outline`)\n    await expect(page.getByText('Introduction')).toBeVisible({ timeout: 5000 })\n\n    // Click regenerate button\n    const regenButton = page.getByRole('button', { name: /重新生成|Regenerate/i })\n    if (await regenButton.count() > 0) {\n      await regenButton.click()\n\n      // Warning dialog should mention page deletion\n      const dialog = page.locator('[role=\"dialog\"], .modal, [class*=\"dialog\"]')\n      await expect(dialog).toBeVisible({ timeout: 3000 })\n      // Check that warning mentions deletion of pages\n      const dialogText = await dialog.textContent()\n      expect(dialogText).toMatch(/删除|removed|remove/i)\n    }\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/streaming-descriptions.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\n\nconst BASE_URL = process.env.BASE_URL || 'http://localhost:3240';\n\n/**\n * Helper: create a project with outline pages via API and navigate to detail editor\n */\nasync function createProjectWithOutline(page: import('@playwright/test').Page, ideaPrompt: string) {\n  // Create project\n  const resp = await page.request.post(`${BASE_URL}/api/projects`, {\n    data: {\n      creation_type: 'idea',\n      idea_prompt: ideaPrompt,\n    },\n  });\n  const body = await resp.json();\n  const projectId = body.data?.project_id;\n  expect(projectId).toBeTruthy();\n\n  // Create some pages with outlines\n  const pageTitles = ['Introduction', 'Main Content', 'Conclusion'];\n  for (let i = 0; i < pageTitles.length; i++) {\n    await page.request.post(`${BASE_URL}/api/projects/${projectId}/pages`, {\n      data: {\n        order_index: i,\n        outline_content: { title: pageTitles[i], points: [`Point ${i + 1}A`, `Point ${i + 1}B`] },\n        status: 'DRAFT',\n      },\n    });\n  }\n\n  // Update project status\n  await page.request.put(`${BASE_URL}/api/projects/${projectId}`, {\n    data: { status: 'OUTLINE_GENERATED' },\n  });\n\n  await page.goto(`${BASE_URL}/project/${projectId}/detail`);\n  await page.waitForLoadState('networkidle');\n  return projectId;\n}\n\n// ===== Mock Tests =====\n\ntest.describe('Streaming Descriptions - Mock Tests', () => {\n  test('should render descriptions incrementally via SSE', async ({ page }) => {\n    const projectId = await createProjectWithOutline(page, 'Test streaming descriptions');\n\n    // Get page IDs\n    const projectResp = await page.request.get(`${BASE_URL}/api/projects/${projectId}`);\n    const projectData = await projectResp.json();\n    const pages = projectData.data?.pages || [];\n    expect(pages.length).toBe(3);\n\n    // Mock SSE streaming endpoint\n    let mockCalled = false;\n    await page.route(`**/api/projects/*/generate/descriptions/stream`, async (route) => {\n      mockCalled = true;\n\n      const sseEvents = pages.map((p: any, i: number) => {\n        const descEvent = `event: description\\ndata: ${JSON.stringify({\n          page_index: i,\n          page_id: p.page_id,\n          text: `页面标题：Page ${i + 1}\\n\\n页面文字：\\n- Content for page ${i + 1}`,\n          extra_fields: i === 0 ? { '排版布局': '居中布局，大标题' } : { '排版布局': '左文右图' },\n        })}\\n\\n`;\n        return descEvent;\n      });\n\n      const doneEvent = `event: done\\ndata: ${JSON.stringify({\n        total: pages.length,\n        pages: pages.map((p: any, i: number) => ({\n          ...p,\n          status: 'DESCRIPTION_GENERATED',\n          description_content: {\n            text: `页面标题：Page ${i + 1}\\n\\n页面文字：\\n- Content for page ${i + 1}`,\n            extra_fields: i === 0 ? { '排版布局': '居中布局，大标题' } : { '排版布局': '左文右图' },\n          },\n        })),\n      })}\\n\\n`;\n\n      const body = sseEvents.join('') + doneEvent;\n\n      await route.fulfill({\n        status: 200,\n        headers: {\n          'Content-Type': 'text/event-stream',\n          'Cache-Control': 'no-cache',\n          'Connection': 'keep-alive',\n        },\n        body,\n      });\n    });\n\n    // Also mock the settings to return streaming mode (cached in sessionStorage)\n    await page.evaluate(() => {\n      sessionStorage.setItem('banana-settings', JSON.stringify({\n        description_generation_mode: 'streaming',\n      }));\n    });\n\n    // Click the generate descriptions button\n    const generateBtn = page.locator('button').filter({ hasText: /生成描述|Generate/ });\n    await generateBtn.first().click();\n\n    // Wait for descriptions to appear\n    await expect(page.locator('text=Content for page 1')).toBeVisible({ timeout: 10000 });\n    await expect(page.locator('text=Content for page 2')).toBeVisible({ timeout: 10000 });\n    await expect(page.locator('text=Content for page 3')).toBeVisible({ timeout: 10000 });\n\n    expect(mockCalled).toBe(true);\n  });\n\n  test('should display extra fields when present', async ({ page }) => {\n    const projectId = await createProjectWithOutline(page, 'Test extra fields display');\n\n    // Get page IDs\n    const projectResp = await page.request.get(`${BASE_URL}/api/projects/${projectId}`);\n    const projectData = await projectResp.json();\n    const pages = projectData.data?.pages || [];\n\n    // Update a page with description_content that includes extra_fields\n    await page.request.put(\n      `${BASE_URL}/api/projects/${projectId}/pages/${pages[0].page_id}/description`,\n      {\n        data: {\n          description_content: {\n            text: '页面标题：Test Page\\n\\n页面文字：\\n- Test content',\n            extra_fields: { '排版布局': '居中布局，大标题+副标题' },\n          },\n        },\n      }\n    );\n\n    // Navigate to detail editor\n    await page.goto(`${BASE_URL}/project/${projectId}/detail`);\n    await page.waitForLoadState('networkidle');\n\n    // Check extra field is displayed\n    await expect(page.locator('text=排版布局')).toBeVisible({ timeout: 5000 });\n    await expect(page.locator('text=居中布局，大标题+副标题')).toBeVisible({ timeout: 5000 });\n  });\n\n  test('should display extra fields from old layout_suggestion format (backward compat)', async ({ page }) => {\n    const projectId = await createProjectWithOutline(page, 'Test backward compat');\n\n    const projectResp = await page.request.get(`${BASE_URL}/api/projects/${projectId}`);\n    const projectData = await projectResp.json();\n    const pages = projectData.data?.pages || [];\n\n    // Old format with layout_suggestion\n    await page.request.put(\n      `${BASE_URL}/api/projects/${projectId}/pages/${pages[0].page_id}/description`,\n      {\n        data: {\n          description_content: {\n            text: '测试页面内容',\n            layout_suggestion: '左右分栏布局',\n          },\n        },\n      }\n    );\n\n    await page.goto(`${BASE_URL}/project/${projectId}/detail`);\n    await page.waitForLoadState('networkidle');\n\n    // Old layout_suggestion should be mapped to \"排版建议\" field (legacy name)\n    await expect(page.locator('text=排版建议')).toBeVisible({ timeout: 5000 });\n    await expect(page.locator('text=左右分栏布局')).toBeVisible({ timeout: 5000 });\n  });\n\n  test('should fall back to parallel mode when setting is parallel', async ({ page }) => {\n    const projectId = await createProjectWithOutline(page, 'Test parallel mode');\n\n    // Get page IDs\n    const projectResp = await page.request.get(`${BASE_URL}/api/projects/${projectId}`);\n    const projectData = await projectResp.json();\n    const pages = projectData.data?.pages || [];\n\n    // Set parallel mode in sessionStorage\n    await page.evaluate(() => {\n      sessionStorage.setItem('banana-settings', JSON.stringify({\n        description_generation_mode: 'parallel',\n      }));\n    });\n\n    // Mock the parallel endpoint (not streaming)\n    let parallelCalled = false;\n    await page.route(`**/api/projects/*/generate/descriptions`, async (route) => {\n      // Only intercept POST (not the stream endpoint which has /stream suffix)\n      if (route.request().url().includes('/stream')) {\n        return route.continue();\n      }\n      parallelCalled = true;\n      await route.fulfill({\n        status: 202,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: { task_id: 'mock-task-123', status: 'GENERATING_DESCRIPTIONS', total_pages: pages.length },\n        }),\n      });\n    });\n\n    // Mock task polling\n    await page.route(`**/api/tasks/mock-task-123`, async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: { status: 'COMPLETED', progress: { total: pages.length, completed: pages.length } },\n        }),\n      });\n    });\n\n    // Click generate\n    const generateBtn = page.locator('button').filter({ hasText: /生成描述|Generate/ });\n    await generateBtn.first().click();\n\n    // Wait a bit for the mode dispatch\n    await page.waitForTimeout(2000);\n    expect(parallelCalled).toBe(true);\n  });\n});\n\n// ===== Integration Tests =====\n\ntest.describe('Streaming Descriptions - Integration Tests', () => {\n  test('DetailEditor settings panel should show generation mode and extra fields', async ({ page }) => {\n    const projectId = await createProjectWithOutline(page, 'Test settings panel');\n    await page.goto(`${BASE_URL}/project/${projectId}/detail`);\n    await page.waitForLoadState('networkidle');\n\n    // Find the Settings2 button by its title attribute\n    const gearBtn = page.locator('button[title=\"描述设置\"], button[title=\"Description Settings\"]');\n    await expect(gearBtn).toBeVisible({ timeout: 5000 });\n    await gearBtn.click();\n\n    // Check generation mode buttons\n    await expect(page.locator('text=流式').or(page.locator('text=Streaming'))).toBeVisible({ timeout: 3000 });\n    await expect(page.locator('text=并行').or(page.locator('text=Parallel'))).toBeVisible({ timeout: 3000 });\n\n    // Check detail level buttons\n    await expect(page.locator('text=精简').or(page.locator('text=Concise'))).toBeVisible();\n    await expect(page.locator('text=默认').or(page.locator('text=Default'))).toBeVisible();\n    await expect(page.getByRole('button', { name: /详细|Detailed/ })).toBeVisible();\n\n    // Check extra fields section\n    await expect(page.locator('text=额外字段').or(page.locator('text=Extra Fields'))).toBeVisible();\n    // Default field \"排版建议\" should be shown\n    await expect(page.locator('text=排版布局')).toBeVisible();\n  });\n\n  test('should persist generation mode via settings API', async ({ page }) => {\n    const projectId = await createProjectWithOutline(page, 'Test mode persist');\n    await page.goto(`${BASE_URL}/project/${projectId}/detail`);\n    await page.waitForLoadState('networkidle');\n\n    // Open settings panel\n    const gearBtn = page.locator('button[title=\"描述设置\"], button[title=\"Description Settings\"]');\n    await gearBtn.click();\n\n    // Click parallel button\n    const parallelBtn = page.locator('button').filter({ hasText: /并行|Parallel/ });\n    await parallelBtn.first().click();\n\n    // Wait for debounced save\n    await page.waitForTimeout(1500);\n\n    // Verify via API\n    const settingsResp = await page.request.get(`${BASE_URL}/api/settings`);\n    const settingsData = await settingsResp.json();\n    expect(settingsData.data?.description_generation_mode).toBe('parallel');\n\n    // Reset back to streaming\n    await page.request.put(`${BASE_URL}/api/settings`, {\n      data: { description_generation_mode: 'streaming' },\n    });\n  });\n\n  test('should add and remove extra fields', async ({ page }) => {\n    const projectId = await createProjectWithOutline(page, 'Test extra fields config');\n    await page.goto(`${BASE_URL}/project/${projectId}/detail`);\n    await page.waitForLoadState('networkidle');\n\n    // Open settings panel\n    const gearBtn = page.locator('button[title=\"描述设置\"], button[title=\"Description Settings\"]');\n    await gearBtn.click();\n\n    // Add a new field via input\n    const fieldInput = page.locator('input[placeholder=\"添加字段\"], input[placeholder=\"Add Field\"]');\n    await fieldInput.fill('配图建议');\n    await fieldInput.press('Enter');\n\n    // New field should appear as an active pill button\n    const newPill = page.locator('button').filter({ hasText: '配图建议' });\n    await expect(newPill).toBeVisible({ timeout: 3000 });\n\n    // Wait for debounced save\n    await page.waitForTimeout(1500);\n\n    // Verify via API — both fields should be active\n    const settingsResp = await page.request.get(`${BASE_URL}/api/settings`);\n    const settingsData = await settingsResp.json();\n    expect(settingsData.data?.description_extra_fields).toContain('配图建议');\n    expect(settingsData.data?.description_extra_fields).toContain('排版布局');\n\n    // Toggle off 配图建议 by clicking the pill\n    await newPill.click();\n    await page.waitForTimeout(1500);\n\n    // Verify it's removed from active fields but still visible in pool\n    const settingsResp2 = await page.request.get(`${BASE_URL}/api/settings`);\n    const settingsData2 = await settingsResp2.json();\n    expect(settingsData2.data?.description_extra_fields).not.toContain('配图建议');\n    await expect(newPill).toBeVisible(); // Still in pool, just inactive\n\n    // Clean up: reset extra fields\n    await page.request.put(`${BASE_URL}/api/settings`, {\n      data: { description_extra_fields: ['视觉元素', '视觉焦点', '排版布局', '演讲者备注'] },\n    });\n    // Clean up localStorage pool\n    await page.evaluate(() => localStorage.removeItem('banana-available-extra-fields'));\n  });\n\n  test('edit dialog should preserve extra fields on save', async ({ page }) => {\n    const projectId = await createProjectWithOutline(page, 'Test edit extra fields');\n\n    const projectResp = await page.request.get(`${BASE_URL}/api/projects/${projectId}`);\n    const projectData = await projectResp.json();\n    const pages = projectData.data?.pages || [];\n\n    // Set a page with extra_fields\n    await page.request.put(\n      `${BASE_URL}/api/projects/${projectId}/pages/${pages[0].page_id}/description`,\n      {\n        data: {\n          description_content: {\n            text: '测试内容',\n            extra_fields: { '排版布局': '居中布局' },\n          },\n        },\n      }\n    );\n\n    await page.goto(`${BASE_URL}/project/${projectId}/detail`);\n    await page.waitForLoadState('networkidle');\n\n    // Click edit on first card\n    const editBtn = page.locator('button').filter({ hasText: /编辑|Edit/ }).first();\n    await editBtn.click();\n\n    // Modal should be visible with extra field input\n    await expect(page.locator('label').filter({ hasText: '排版布局' })).toBeVisible({ timeout: 5000 });\n    const fieldTextarea = page.locator('textarea').filter({ hasText: '居中布局' });\n    await expect(fieldTextarea).toBeVisible();\n\n    // Edit the extra field value\n    await fieldTextarea.fill('左右分栏');\n\n    // Save\n    const saveBtn = page.locator('button').filter({ hasText: /保存|Save/ });\n    await saveBtn.click();\n\n    // Verify the card shows updated value (use paragraph to avoid matching textarea)\n    await expect(page.getByRole('paragraph').filter({ hasText: '左右分栏' })).toBeVisible({ timeout: 5000 });\n  });\n\n  test('single page regeneration should still work', async ({ page }) => {\n    const projectId = await createProjectWithOutline(page, 'Test single page regen');\n\n    await page.goto(`${BASE_URL}/project/${projectId}/detail`);\n    await page.waitForLoadState('networkidle');\n\n    // Click regenerate on the first page card\n    const regenBtn = page.locator('button').filter({ hasText: /重新生成|Regenerate/ });\n    await expect(regenBtn.first()).toBeVisible({ timeout: 5000 });\n  });\n});\n"
  },
  {
    "path": "frontend/e2e/streaming-outline.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\n\nconst BASE_URL = process.env.BASE_URL || 'http://localhost:3240';\n\n/**\n * Helper: create a project via API and navigate to outline editor\n */\nasync function createProjectAndNavigate(page: import('@playwright/test').Page, ideaPrompt: string) {\n  const resp = await page.request.post(`${BASE_URL}/api/projects`, {\n    data: {\n      creation_type: 'idea',\n      idea_prompt: ideaPrompt,\n    },\n  });\n  const body = await resp.json();\n  const projectId = body.data?.project_id;\n  expect(projectId).toBeTruthy();\n  await page.goto(`${BASE_URL}/project/${projectId}/outline`);\n  await page.waitForLoadState('networkidle');\n  return projectId;\n}\n\n// ===== Mock Tests =====\n\ntest.describe('Streaming Outline - Mock Tests', () => {\n  test('should render cards incrementally as SSE pages arrive', async ({ page }) => {\n    const projectId = await createProjectAndNavigate(page, 'Test streaming outline');\n\n    // Mock the SSE streaming endpoint\n    let requestReceived = false;\n    await page.route(`**/api/projects/*/generate/outline/stream`, async (route) => {\n      requestReceived = true;\n\n      // Simulate SSE response with 3 pages arriving sequentially\n      const pages = [\n        { index: 0, title: 'Introduction', points: ['Welcome', 'Overview'], part: null },\n        { index: 1, title: 'Main Content', points: ['Topic A', 'Topic B'], part: 'Part 1' },\n        { index: 2, title: 'Conclusion', points: ['Summary', 'Q&A'], part: 'Part 1' },\n      ];\n\n      let sseBody = '';\n      for (const p of pages) {\n        sseBody += `event: page\\ndata: ${JSON.stringify(p)}\\n\\n`;\n      }\n\n      // Done event with fake persisted pages (include real IDs)\n      const donePages = pages.map((p, i) => ({\n        id: `real-page-${i}`,\n        order_index: i,\n        outline_content: { title: p.title, points: p.points },\n        part: p.part,\n        status: 'DRAFT',\n      }));\n      sseBody += `event: done\\ndata: ${JSON.stringify({ total: 3, pages: donePages })}\\n\\n`;\n\n      await route.fulfill({\n        status: 200,\n        headers: {\n          'Content-Type': 'text/event-stream',\n          'Cache-Control': 'no-cache',\n        },\n        body: sseBody,\n      });\n    });\n\n    // Click the generate button\n    const generateBtn = page.getByRole('button', { name: /自动生成|Auto Generate/i });\n    await generateBtn.click();\n\n    // Wait for cards to appear\n    await expect(page.getByText('Introduction')).toBeVisible({ timeout: 10000 });\n    await expect(page.getByText('Main Content')).toBeVisible();\n    await expect(page.getByText('Conclusion')).toBeVisible();\n\n    // Verify the SSE endpoint was called\n    expect(requestReceived).toBe(true);\n\n    // Verify all 3 cards are rendered\n    const cards = page.locator('[class*=\"animate-slide-in-up\"], [data-testid=\"outline-card\"]');\n    // At minimum, check that the page titles are visible\n    await expect(page.getByText('Topic A')).toBeVisible();\n    await expect(page.getByText('Summary')).toBeVisible();\n  });\n\n  test('should show error message on SSE error event', async ({ page }) => {\n    const projectId = await createProjectAndNavigate(page, 'Test error handling');\n\n    await page.route(`**/api/projects/*/generate/outline/stream`, async (route) => {\n      const sseBody = `event: error\\ndata: ${JSON.stringify({ message: 'AI service unavailable' })}\\n\\n`;\n      await route.fulfill({\n        status: 200,\n        headers: { 'Content-Type': 'text/event-stream' },\n        body: sseBody,\n      });\n    });\n\n    const generateBtn = page.getByRole('button', { name: /自动生成|Auto Generate/i });\n    await generateBtn.click();\n\n    // The error should be displayed somewhere in the UI\n    // Wait a moment for the error to propagate\n    await page.waitForTimeout(1000);\n\n    // The store sets error state, which may show as a toast or error message\n    // Just verify no cards appeared\n    await expect(page.getByText('Introduction')).not.toBeVisible();\n  });\n\n  test('should disable generate button during streaming and re-enable on completion', async ({ page }) => {\n    const projectId = await createProjectAndNavigate(page, 'Test button state');\n\n    await page.route(`**/api/projects/*/generate/outline/stream`, async (route) => {\n      const pageEvent = `event: page\\ndata: ${JSON.stringify({ index: 0, title: 'Page 1', points: ['Point'] })}\\n\\n`;\n      const doneEvent = `event: done\\ndata: ${JSON.stringify({ total: 1, pages: [{id: 'p1', order_index: 0, outline_content: {title: 'Page 1', points: ['Point']}}] })}\\n\\n`;\n      await route.fulfill({\n        status: 200,\n        headers: { 'Content-Type': 'text/event-stream' },\n        body: pageEvent + doneEvent,\n      });\n    });\n\n    const generateBtn = page.getByRole('button', { name: /自动生成|Auto Generate/i });\n    await generateBtn.click();\n\n    // Assert button shows disabled \"Generating...\" state\n    await expect(page.getByRole('button', { name: /生成中|Generating/i })).toBeDisabled();\n\n    // Wait for page to render\n    await expect(page.getByText('Page 1')).toBeVisible();\n\n    // Assert button re-enables with \"Regenerate\" text\n    await expect(page.getByRole('button', { name: /重新生成|Regenerate/i })).toBeEnabled();\n  });\n});\n\n// ===== Integration Tests =====\n\ntest.describe('Streaming Outline - Integration Tests', () => {\n  // Skip in CI — requires real AI API keys\n  test.skip(!!process.env.CI, 'Requires real AI backend');\n\n  test('should stream outline from real backend and persist pages', async ({ page }) => {\n    // Create project\n    const projectId = await createProjectAndNavigate(page, 'A 3-page presentation about cats');\n\n    // Click generate\n    const generateBtn = page.getByRole('button', { name: /自动生成|Auto Generate/i });\n    await generateBtn.click();\n\n    // Wait for at least one card to appear (streaming in progress)\n    // The first card should appear within 15 seconds\n    await expect(page.locator('h4').first()).toBeVisible({ timeout: 30000 });\n\n    // Wait for streaming to complete - \"Regenerate\" button appears when done\n    await expect(page.getByRole('button', { name: /重新生成|Regenerate/i })).toBeVisible({ timeout: 60000 });\n\n    // Verify multiple cards were generated\n    const cardTitles = page.locator('h4');\n    const count = await cardTitles.count();\n    expect(count).toBeGreaterThanOrEqual(2);\n\n    // Reload page and verify pages persisted\n    await page.reload();\n    await page.waitForLoadState('networkidle');\n\n    const reloadedTitles = page.locator('h4');\n    const reloadedCount = await reloadedTitles.count();\n    expect(reloadedCount).toBe(count);\n  });\n});\n"
  },
  {
    "path": "frontend/e2e/ui-full-flow-mocked.spec.ts",
    "content": "/**\n * UI-driven E2E test with Mocked Backend\n * \n * This test simulates the complete user operation flow but mocks all backend API calls.\n * This allows fast testing (1-2 minutes) without waiting for real AI generation.\n * \n * Use this for:\n * - Quick UI regression testing\n * - CI/CD pipeline (fast feedback)\n * - Development iteration\n * \n * For real E2E testing with actual AI, use ui-full-flow.spec.ts\n */\n\nimport { test, expect } from '@playwright/test'\nimport * as fs from 'fs'\nimport * as path from 'path'\n\ntest.describe('UI-driven E2E test (Mocked Backend)', () => {\n  test.setTimeout(2 * 60 * 1000) // 2 minutes max\n  \n  test('User Full Flow: Create and export PPT with mocked API', async ({ page }) => {\n    console.log('\\n========================================')\n    console.log('🌐 Starting UI-driven E2E test (Mocked Backend)')\n    console.log('========================================\\n')\n    \n    // Mock API responses\n    await page.route('**/api/projects', async (route) => {\n      if (route.request().method() === 'POST') {\n        await route.fulfill({\n          status: 201,\n          contentType: 'application/json',\n          body: JSON.stringify({\n            success: true,\n            data: {\n              project_id: 'mock-project-123',\n              status: 'DRAFT'\n            }\n          })\n        })\n      } else {\n        await route.continue()\n      }\n    })\n    \n    // Mock outline generation\n    await page.route('**/api/projects/*/generate/outline', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: { task_id: 'mock-outline-task' }\n        })\n      })\n    })\n    \n    // Mock project status (outline generated)\n    await page.route('**/api/projects/mock-project-123', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: {\n            project_id: 'mock-project-123',\n            status: 'OUTLINE_GENERATED',\n            outline_content: {\n              pages: [\n                { title: '什么是AI', order_index: 0 },\n                { title: 'AI的应用', order_index: 1 },\n                { title: 'AI的未来', order_index: 2 }\n              ]\n            }\n          }\n        })\n      })\n    })\n    \n    // Mock description generation\n    await page.route('**/api/projects/*/generate/descriptions', async (route) => {\n      await route.fulfill({\n        status: 202,  // 202 Accepted for async operations\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: { task_id: 'mock-desc-task' }\n        })\n      })\n    })\n    \n    // Mock image generation\n    await page.route('**/api/projects/*/generate/images', async (route) => {\n      await route.fulfill({\n        status: 202,  // 202 Accepted for async operations\n        contentType: 'application/json',\n        body: JSON.stringify({\n          success: true,\n          data: { task_id: 'mock-image-task' }\n        })\n      })\n    })\n    \n    // Mock PPT export\n    await page.route('**/api/projects/*/export/pptx**', async (route) => {\n      // Create a minimal mock PPTX file\n      const mockPptxPath = path.join(__dirname, 'fixtures', 'mock-presentation.pptx')\n      \n      if (fs.existsSync(mockPptxPath)) {\n        const buffer = fs.readFileSync(mockPptxPath)\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n          body: buffer\n        })\n      } else {\n        // If mock file doesn't exist, return a simple response\n        await route.fulfill({\n          status: 200,\n          contentType: 'application/json',\n          body: JSON.stringify({\n            success: true,\n            data: {\n              download_url: '/files/mock-project-123/exports/mock-presentation.pptx'\n            }\n          })\n        })\n      }\n    })\n    \n    // ====================================\n    // Step 1: Visit homepage\n    // ====================================\n    console.log('📱 Step 1: Opening homepage...')\n    await page.goto('http://localhost:3000')\n    await expect(page).toHaveTitle(/蕉幻|Banana/i)\n    console.log('✓ Homepage loaded successfully\\n')\n    \n    // ====================================\n    // Step 2: Ensure \"一句话生成\" tab is selected (it's selected by default)\n    // ====================================\n    console.log('🖱️  Step 2: Ensuring \"一句话生成\" tab is selected...')\n    // The \"一句话生成\" tab is selected by default, but we can click it to ensure it's active\n    await page.click('button:has-text(\"一句话生成\")').catch(() => {\n      // If click fails, the tab might already be selected, which is fine\n    })\n    await page.waitForSelector('textarea, input[type=\"text\"]', { timeout: 10000 })\n    console.log('✓ Create form displayed\\n')\n    \n    // ====================================\n    // Step 3: Enter idea and click \"Next\"\n    // ====================================\n    console.log('✍️  Step 3: Entering idea content...')\n    const ideaInput = page.locator('textarea, input[type=\"text\"]').first()\n    await ideaInput.fill('创建一份关于人工智能基础的简短PPT，包含3页：什么是AI、AI的应用、AI的未来')\n    \n    console.log('🚀 Clicking \"Next\" button...')\n    await page.click('button:has-text(\"下一步\")')\n    \n    // Wait for navigation (mocked response should be fast)\n    await page.waitForTimeout(1000)\n    console.log('✓ Clicked \"Next\" button\\n')\n    \n    // ====================================\n    // Step 4: Verify outline editor page loaded\n    // ====================================\n    console.log('📋 Step 4: Verifying outline editor page...')\n    await page.waitForSelector('button:has-text(\"自动生成大纲\"), button:has-text(\"重新生成大纲\")', { timeout: 10000 })\n    console.log('✓ Outline editor page loaded\\n')\n    \n    // ====================================\n    // Step 5: Click generate outline (mocked)\n    // ====================================\n    console.log('📋 Step 5: Clicking batch generate outline button (mocked)...')\n    const generateOutlineBtn = page.locator('button:has-text(\"自动生成大纲\"), button:has-text(\"重新生成大纲\")')\n    await generateOutlineBtn.first().click()\n    \n    // Wait for mocked response (should be instant, but UI might need time to update)\n    await page.waitForTimeout(2000)\n    console.log('✓ Mocked outline generation triggered\\n')\n    \n    // ====================================\n    // Step 6: Verify UI shows outline (mocked data)\n    // ====================================\n    console.log('✅ Step 6: Verifying UI shows outline items...')\n    // The UI should show the mocked outline data\n    await expect(page.locator('.outline-card, [data-testid=\"outline-item\"], .outline-section').first())\n      .toBeVisible({ timeout: 10000 })\n    console.log('✓ Outline items visible in UI\\n')\n    \n    // ====================================\n    // Step 7: Navigate to description editor\n    // ====================================\n    console.log('➡️  Step 7: Clicking \"Next\" to go to description editor...')\n    const nextBtn = page.locator('button:has-text(\"下一步\")')\n    if (await nextBtn.count() > 0) {\n      await nextBtn.first().click()\n      await page.waitForTimeout(1000)\n      console.log('✓ Navigated to description editor\\n')\n    }\n    \n    // ====================================\n    // Step 8: Test description generation UI (mocked)\n    // ====================================\n    console.log('✍️  Step 8: Testing description generation UI (mocked)...')\n    await page.waitForSelector('button:has-text(\"批量生成描述\")', { timeout: 10000 })\n    const generateDescBtn = page.locator('button:has-text(\"批量生成描述\")')\n    await generateDescBtn.first().click()\n    await page.waitForTimeout(2000) // Mock response should be fast\n    console.log('✓ Mocked description generation triggered\\n')\n    \n    // ====================================\n    // Step 9: Navigate to image generation\n    // ====================================\n    console.log('➡️  Step 9: Navigating to image generation page...')\n    const nextBtn2 = page.locator('button:has-text(\"下一步\")')\n    if (await nextBtn2.count() > 0) {\n      await nextBtn2.first().click()\n      await page.waitForTimeout(1000)\n      console.log('✓ Navigated to image generation page\\n')\n    }\n    \n    // ====================================\n    // Step 10: Test image generation UI (mocked)\n    // ====================================\n    console.log('🎨 Step 10: Testing image generation UI (mocked)...')\n    await page.waitForSelector('button:has-text(\"批量生成图片\")', { timeout: 10000 })\n    const generateImageBtn = page.locator('button:has-text(\"批量生成图片\")')\n    if (await generateImageBtn.count() > 0) {\n      await generateImageBtn.first().click()\n      await page.waitForTimeout(2000)\n      console.log('✓ Mocked image generation triggered\\n')\n    }\n    \n    // ====================================\n    // Step 11: Test export UI\n    // ====================================\n    console.log('📦 Step 11: Testing export UI...')\n    const exportBtn = page.locator('button:has-text(\"导出\"), button:has-text(\"下载\"), button:has-text(\"完成\")')\n    \n    if (await exportBtn.count() > 0) {\n      const downloadPromise = page.waitForEvent('download', { timeout: 10000 }).catch(() => null)\n      await exportBtn.first().click()\n      \n      const download = await downloadPromise\n      if (download) {\n        const downloadPath = path.join('test-results', 'e2e-mocked-test-output.pptx')\n        await download.saveAs(downloadPath)\n        console.log(`✓ Mock PPT file downloaded: ${downloadPath}\\n`)\n      } else {\n        console.log('⚠️  Download event not triggered (may be handled differently in UI)\\n')\n      }\n    }\n    \n    // ====================================\n    // Final verification\n    // ====================================\n    console.log('========================================')\n    console.log('✅ Mocked E2E test completed!')\n    console.log('========================================\\n')\n    \n    // Take final screenshot\n    await page.screenshot({ \n      path: 'test-results/e2e-mocked-final-state.png',\n      fullPage: true \n    })\n  })\n})\n\n"
  },
  {
    "path": "frontend/e2e/ui-full-flow.spec.ts",
    "content": "/**\n * UI-driven end-to-end test: From user interface operations to final PPT export\n * \n * This test simulates the complete user operation flow in the browser:\n * 1. Enter idea in frontend\n * 2. Click \"下一步\" (Next) button\n * 3. Click batch generate outline button on outline editor page\n * 4. Wait for outline generation (visible in UI)\n * 5. Click \"下一步\" (Next) to go to description editor page\n * 6. Click batch generate descriptions button\n * 7. Wait for descriptions to generate (visible in UI)\n * 8. Test retry single card functionality\n * 9. Click \"生成图片\" (Generate Images) to go to image generation page\n * 10. Click batch generate images button\n * 11. Wait for images to generate (visible in UI)\n * 12. Export PPT\n * 13. Verify downloaded file\n * \n * Note:\n * - This test requires real AI API keys\n * - Takes 10-15 minutes to complete\n * - Depends on frontend UI stability\n * - Recommended to run only before release or in Nightly Build\n */\n\nimport { test, expect } from '@playwright/test'\nimport * as fs from 'fs'\nimport * as path from 'path'\n\ntest.describe('UI-driven E2E test: From user interface to PPT export', () => {\n  // Increase timeout to 25 minutes (image generation may need retries on API disconnects)\n  test.setTimeout(25 * 60 * 1000)\n  \n  test('User Full Flow: Create and export PPT in browser', async ({ page }) => {\n    console.log('\\n========================================')\n    console.log('🌐 Starting UI-driven E2E test (via frontend interface)')\n    console.log('========================================\\n')\n    \n    // ====================================\n    // Step 1: Visit homepage\n    // ====================================\n    console.log('📱 Step 1: Opening homepage...')\n\n    // Prevent HelpModal from appearing (it opens with a 500ms delay on first visit)\n    await page.addInitScript(() => {\n      localStorage.setItem('hasSeenHelpModal', 'true')\n    })\n    await page.goto('http://localhost:3000')\n\n    // Verify page loaded\n    await expect(page).toHaveTitle(/蕉幻|Banana/i)\n    console.log('✓ Homepage loaded successfully\\n')\n    \n    // ====================================\n    // Step 2: Ensure \"一句话生成\" tab is selected (it's selected by default)\n    // ====================================\n    console.log('🖱️  Step 2: Ensuring \"一句话生成\" tab is selected...')\n    // The \"一句话生成\" tab is selected by default, but we can click it to ensure it's active\n    await page.click('button:has-text(\"一句话生成\")').catch(() => {\n      // If click fails, the tab might already be selected, which is fine\n    })\n    \n    // Wait for form to appear (MarkdownTextarea uses contentEditable div with role=\"textbox\")\n    await page.waitForSelector('[role=\"textbox\"], textarea, input[type=\"text\"]', { timeout: 10000 })\n    console.log('✓ Create form displayed\\n')\n\n    // ====================================\n    // Step 3: Enter idea and click \"Next\"\n    // ====================================\n    console.log('✍️  Step 3: Entering idea content...')\n    const ideaInput = page.locator('[role=\"textbox\"], textarea, input[type=\"text\"]').first()\n    await ideaInput.click()\n    await ideaInput.pressSequentially('创建一份关于人工智能基础的简短PPT，包含3页：什么是AI、AI的应用、AI的未来')\n    \n    console.log('🚀 Clicking \"Next\" button...')\n    await page.click('button:has-text(\"下一步\")')\n    \n    // Wait for navigation to outline editor page\n    await page.waitForURL(/\\/project\\/.*\\/outline/, { timeout: 10000 })\n    console.log('✓ Clicked \"Next\" button and navigated to outline editor page\\n')\n    \n    // ====================================\n    // Step 4: Click batch generate outline button on outline editor page\n    // ====================================\n    console.log('⏳ Step 4: Waiting for outline editor page to load...')\n    await page.waitForSelector('button:has-text(\"自动生成大纲\"), button:has-text(\"重新生成大纲\")', { timeout: 10000 })\n    \n    console.log('📋 Step 4: Clicking batch generate outline button...')\n    const generateOutlineBtn = page.locator('button:has-text(\"自动生成大纲\"), button:has-text(\"重新生成大纲\")')\n    await generateOutlineBtn.first().click()\n    console.log('✓ Clicked batch generate outline button\\n')\n    \n    // ====================================\n    // Step 5: Wait for outline generation to complete (smart wait)\n    // ====================================\n    console.log('⏳ Step 5: Waiting for outline generation (may take 3-5 minutes)...')\n\n    // Outline generation uses SSE streaming: the button shows \"生成中...\" and\n    // pages appear incrementally. Wait for the first card, then for streaming\n    // to finish (button text reverts from \"生成中...\").\n    const streamingBtn = page.locator('button:has-text(\"生成中...\")')\n    await streamingBtn.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {\n      console.log('  Streaming button state not detected, generation may have completed quickly')\n    })\n\n    // Wait for at least one outline card (pages stream in one by one)\n    await expect(page.locator('text=/第 \\\\d+ 页/').first()).toBeVisible({ timeout: 300000 })\n    console.log('  First outline card appeared')\n\n    // Wait for streaming to finish (button reverts from \"生成中...\")\n    await expect(streamingBtn).toBeHidden({ timeout: 300000 })\n    \n    // Verify outline content\n    const outlineItems = page.locator('text=/第 \\\\d+ 页/')\n    const outlineCount = await outlineItems.count()\n    \n    expect(outlineCount).toBeGreaterThan(0)\n    console.log(`✓ Outline generated successfully, total ${outlineCount} pages\\n`)\n    \n    // Take screenshot of current state\n    await page.screenshot({ path: 'test-results/e2e-outline-generated.png' })\n    \n    // ====================================\n    // Step 6: Click \"Next\" to go to description editor page\n    // ====================================\n    console.log('➡️  Step 6: Clicking \"Next\" to go to description editor page...')\n    const nextBtn = page.locator('button:has-text(\"下一步\")')\n    if (await nextBtn.count() > 0) {\n      await nextBtn.first().click()\n      \n      // Wait for navigation to detail editor page\n      await page.waitForURL(/\\/project\\/.*\\/detail/, { timeout: 10000 })\n      console.log('✓ Clicked \"Next\" button and navigated to description editor page\\n')\n    }\n    \n    // ====================================\n    // Step 7: Click batch generate descriptions button\n    // ====================================\n    console.log('✍️  Step 7: Clicking batch generate descriptions button...')\n    \n    // Wait for description editor page to load\n    await page.waitForSelector('button:has-text(\"批量生成描述\")', { timeout: 10000 })\n    \n    const generateDescBtn = page.locator('button:has-text(\"批量生成描述\")')\n    await generateDescBtn.first().click()\n    console.log('✓ Clicked batch generate descriptions button\\n')\n    \n    // ====================================\n    // Step 8: Wait for descriptions to generate (smart wait)\n    // ====================================\n    console.log('⏳ Step 8: Waiting for descriptions to generate (may take 2-5 minutes)...')\n    \n    // Smart wait: The \"生成图片\" button is disabled until ALL pages have description_content.\n    // Wait for it to become enabled as the definitive signal that all descriptions are done.\n    const generateImagesBtnForWait = page.locator('button:has-text(\"生成图片\")').first()\n    await expect(async () => {\n      await expect(generateImagesBtnForWait).toBeEnabled()\n    }).toPass({ timeout: 300000, intervals: [3000, 5000, 10000] })\n\n    console.log('✓ All descriptions generated (生成图片 button enabled)\\n')\n    await page.screenshot({ path: 'test-results/e2e-descriptions-generated.png' })\n    \n    // ====================================\n    // Step 9: Test retry single card functionality\n    // ====================================\n    console.log('🔄 Step 9: Testing retry single card functionality...')\n    \n    // Find the first description card with retry button\n    const retryButtons = page.locator('button:has-text(\"重新生成\")')\n    const retryCount = await retryButtons.count()\n    \n    if (retryCount > 0) {\n      // Click the first retry button\n      await retryButtons.first().click()\n      console.log('✓ Clicked retry button on first card')\n      \n      // Handle confirmation dialog if it appears (appears when page already has description)\n      try {\n        const confirmDialog = page.locator('div[role=\"dialog\"]:has-text(\"确认重新生成\")')\n        await confirmDialog.waitFor({ state: 'visible', timeout: 2000 })\n        console.log('  Confirmation dialog appeared, clicking confirm...')\n        \n        // Click the confirm button in the dialog\n        const confirmButton = page.locator('button:has-text(\"确定\"), button:has-text(\"确认\")').last()\n        await confirmButton.click()\n        \n        // Wait for dialog to be completely hidden\n        await confirmDialog.waitFor({ state: 'hidden', timeout: 5000 })\n        \n        // Also wait for the modal backdrop to disappear\n        const modalBackdrop = page.locator('.fixed.inset-0.bg-black\\\\/50')\n        await modalBackdrop.waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {\n          console.log('  Modal backdrop already gone or not found')\n        })\n        \n        // Extra wait to ensure CSS transitions complete\n        await page.waitForTimeout(300)\n        \n        console.log('  Confirmed regeneration and dialog closed')\n      } catch (e) {\n        // Dialog didn't appear or already closed, continue\n        console.log('  No confirmation dialog, continuing...')\n      }\n      \n      // Wait for the card to show generating state\n      await page.waitForSelector('button:has-text(\"生成中...\")', { timeout: 5000 }).catch(() => {\n        // If \"生成中...\" doesn't appear, check for other loading indicators\n        console.log('  Waiting for generation state...')\n      })\n      \n      // Wait for regeneration to complete - ensure no cards are still generating\n      // (can't just check for any \"重新生成\" button as other cards already have one)\n      await expect(async () => {\n        const generatingButtons = await page.locator('button:has-text(\"生成中...\")').count()\n        expect(generatingButtons).toBe(0)\n      }).toPass({ timeout: 120000, intervals: [2000, 5000, 10000] })\n      \n      console.log('✓ Single card retry completed successfully\\n')\n      await page.screenshot({ path: 'test-results/e2e-single-card-retry.png' })\n    } else {\n      console.log('⚠️  No retry buttons found, skipping single card retry test\\n')\n    }\n    \n    // ====================================\n    // Step 10: Click \"生成图片\" to go to image generation page\n    // ====================================\n    console.log('➡️  Step 10: Clicking \"生成图片\" to go to image generation page...')\n\n    // Ensure no modal backdrop is blocking the UI\n    // This is important after the single card retry which may have shown a confirmation dialog\n    const modalBackdrop = page.locator('.fixed.inset-0').filter({ hasText: '' }).first()\n    const backdropCount = await page.locator('.fixed.inset-0').filter({ hasText: '' }).count()\n    \n    if (backdropCount > 0) {\n      const isBackdropVisible = await modalBackdrop.isVisible().catch(() => false)\n      if (isBackdropVisible) {\n        console.log('  Modal backdrop detected, attempting to close modal...')\n        \n        // Try pressing Escape to close any open modal\n        await page.keyboard.press('Escape')\n        await page.waitForTimeout(300)\n        \n        // Try clicking close button if exists\n        const closeButton = page.locator('button:has-text(\"取消\"), button[aria-label=\"Close\"]').first()\n        if (await closeButton.isVisible().catch(() => false)) {\n          await closeButton.click().catch(() => {})\n        }\n        \n        // Wait for backdrop to disappear\n        await page.waitForTimeout(500)\n        \n        // Final check - if backdrop still visible, wait longer\n        const stillVisible = await modalBackdrop.isVisible().catch(() => false)\n        if (stillVisible) {\n          console.log('  Backdrop still visible, waiting up to 3 seconds...')\n          await modalBackdrop.waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {\n            console.log('  Warning: Backdrop may still be present')\n          })\n        }\n        console.log('  Modal cleared')\n      }\n    } else {\n      console.log('  No modal backdrop detected')\n    }\n    \n    // Extra safety wait to ensure all animations complete\n    await page.waitForTimeout(1500)\n\n    const generateImagesNavBtn = page.locator('button:has-text(\"生成图片\")').first()\n\n    // Wait for button to be enabled (it's disabled until all descriptions are generated)\n    await generateImagesNavBtn.waitFor({ state: 'visible', timeout: 10000 })\n    // Allow enough time for the single card retry from Step 9 to complete\n    await expect(generateImagesNavBtn).toBeEnabled({ timeout: 30000 })\n    \n    // Ensure button is in viewport\n    await generateImagesNavBtn.scrollIntoViewIfNeeded()\n    \n    // Log current URL before clicking\n    const urlBeforeClick = page.url()\n    console.log(`  Current URL before click: ${urlBeforeClick}`)\n    \n    // Try normal click first\n    let clickSucceeded = false\n    try {\n      await generateImagesNavBtn.click({ timeout: 2000 })\n      console.log('  Button clicked successfully (normal click)')\n      clickSucceeded = true\n    } catch (e) {\n      console.log('  Normal click blocked by overlay')\n    }\n    \n    // Check if navigation started\n    await page.waitForTimeout(200)\n    const urlAfterFirstAttempt = page.url()\n    \n    if (!clickSucceeded || urlAfterFirstAttempt === urlBeforeClick) {\n      console.log('  Navigation did not start, using JavaScript to trigger navigation...')\n      // Extract project ID from current URL\n      const match = urlBeforeClick.match(/\\/project\\/([^/]+)\\//)\n      if (match) {\n        const projectId = match[1]\n        const targetUrl = `http://localhost:3000/project/${projectId}/preview`\n        console.log(`  Navigating to: ${targetUrl}`)\n        await page.goto(targetUrl, { waitUntil: 'domcontentloaded' })\n      } else {\n        throw new Error('Could not extract project ID from URL')\n      }\n    }\n    \n    // Wait for navigation to complete\n    console.log('  Waiting for preview page to load...')\n    await page.waitForURL(/\\/project\\/.*\\/preview/, { timeout: 10000 })\n    console.log('✓ Successfully navigated to preview page\\n')\n    \n    // ====================================\n    // Step 11: Select template (required before generating images)\n    // ====================================\n    console.log('🎨 Step 11: Selecting template...')\n    \n    // Click \"更换模板\" button to open template selection modal\n    // The button might be hidden on small screens, so try multiple selectors\n    const changeTemplateBtn = page.locator('button:has-text(\"更换模板\"), button[title=\"更换模板\"]').first()\n    await changeTemplateBtn.waitFor({ state: 'visible', timeout: 10000 })\n    await changeTemplateBtn.scrollIntoViewIfNeeded()\n    await changeTemplateBtn.click()\n    console.log('✓ Clicked \"更换模板\" button, opening template selection modal...')\n    \n    // Wait for template modal to open (check for modal title and preset templates section)\n    await page.waitForSelector('text=\"更换模板\"', { timeout: 5000 })\n    await page.waitForSelector('text=\"预设模板\"', { timeout: 5000 })\n    await page.waitForTimeout(500) // Wait for modal animation\n    \n    // Select the first preset template \n    let templateSelected = false\n    \n    \n    // Click the first preset template card in the grid (if name click didn't work)\n    if (!templateSelected) {\n      try {\n        // Find the preset templates section and click the first template card\n        // The preset templates are in a grid with class containing \"aspect-[4/3]\"\n        const presetSection = page.locator('h4:has-text(\"预设模板\")').locator('..')\n        const firstTemplateCard = presetSection.locator('div[class*=\"aspect-[4/3]\"]').first()\n        await firstTemplateCard.waitFor({ state: 'visible', timeout: 3000 })\n        await firstTemplateCard.click()\n        templateSelected = true\n        console.log('✓ Selected first preset template by clicking first card')\n      } catch (e) {\n        console.log('  Warning: Could not select template by card, trying alternative...')\n      }\n    }\n    \n    if (!templateSelected) {\n      throw new Error('Failed to select preset template')\n    }\n    \n    // Wait for template selection to complete dynamically\n    // The handleTemplateSelect function will:\n    // 1. Show \"正在上传模板...\" (isUploadingTemplate = true)\n    // 2. Upload template and sync project\n    // 3. Close modal (setIsTemplateModalOpen(false))\n    // 4. Show success toast \"模板更换成功\"\n    \n    console.log('  Waiting for template upload to complete...')\n    \n    // Wait for \"正在上传模板...\" to appear (indicates upload started)\n    const uploadingText = page.locator('text=\"正在上传模板...\"')\n    const uploadStarted = await uploadingText.isVisible({ timeout: 3000 }).catch(() => false)\n    if (uploadStarted) {\n      console.log('  Template upload started, waiting for completion...')\n    }\n    \n    // Wait for modal to close (most reliable indicator that selection is complete)\n    // Modal component returns null when isOpen=false, so the modal DOM disappears\n    // We check for the modal's unique content that only exists when modal is open\n    await expect(async () => {\n      // Check if modal backdrop or modal content is still visible\n      // The modal has a backdrop with class \"fixed inset-0 bg-black/50\"\n      // and the modal content has title \"更换模板\" in a specific structure\n      const modalBackdrop = page.locator('.fixed.inset-0.bg-black\\\\/50').first()\n      const modalContent = page.locator('h2:has-text(\"更换模板\")').first()\n      \n      const isBackdropVisible = await modalBackdrop.isVisible().catch(() => false)\n      const isContentVisible = await modalContent.isVisible().catch(() => false)\n      \n      if (isBackdropVisible || isContentVisible) {\n        throw new Error('Template selection modal still open')\n      }\n      return true\n    }).toPass({ \n      timeout: 30000, // Wait up to 30 seconds for upload and modal close\n      intervals: [1000, 2000, 3000] // Check every 1-3 seconds\n    })\n    \n    console.log('✓ Template upload completed and modal closed')\n    \n    // Optionally wait for success toast (non-blocking, just for verification)\n    try {\n      await page.waitForSelector('text=\"模板更换成功\"', { timeout: 3000 })\n      console.log('✓ Success toast appeared')\n    } catch (e) {\n      // Toast might have disappeared quickly, that's okay\n    }\n    \n    console.log('✓ Template selected successfully\\n')\n    \n    // ====================================\n    // Step 12: Click batch generate images button\n    // ====================================\n    console.log('🎨 Step 12: Clicking batch generate images button...')\n    \n    // Wait for image generation page to load (button text includes page count like \"批量生成图片 (3)\")\n    const generateImageBtn = page.locator('button').filter({ hasText: '批量生成图片' })\n    await generateImageBtn.waitFor({ state: 'visible', timeout: 10000 })\n    \n    if (await generateImageBtn.count() > 0) {\n      await generateImageBtn.first().click()\n      console.log('✓ Clicked batch generate images button\\n')\n      \n      // Wait for images to generate (should complete within 5 minutes)\n      console.log('⏳ Step 13: Waiting for images to generate (should complete within 5 minutes)...')\n      \n      // Get expected page count from the button text (e.g., \"批量生成图片 (3)\")\n      let pageCount = 3 // default\n      try {\n        const buttonText = await generateImageBtn.first().textContent()\n        const match = buttonText?.match(/\\((\\d+)\\)/)\n        if (match) {\n          pageCount = parseInt(match[1], 10)\n        }\n      } catch (e) {\n        // Fallback: try to count page thumbnails or cards\n        const thumbnails = page.locator('[data-page-index], .page-thumbnail, .slide-thumbnail')\n        const thumbnailCount = await thumbnails.count()\n        if (thumbnailCount > 0) {\n          pageCount = thumbnailCount\n        }\n      }\n      console.log(`  Expected ${pageCount} pages to generate images`)\n      \n      // Wait strategy: Image generation is NON-BLOCKING (no global loading overlay).\n      // The frontend uses pageGeneratingTasks to track per-page generation status.\n      // StatusBadge shows \"生成中\" (orange badge with animate-pulse) during generation.\n      // We wait for export button to be enabled (hasAllImages = all pages have generated_image_path).\n      // Use 15 minutes timeout (900000ms) to cover retries on API disconnects.\n      const startTime = Date.now()\n      const maxWaitTime = 900000 // 15 minutes total\n      \n      // Helper: Precise selector for \"生成中\" StatusBadge (orange background)\n      // StatusBadge structure: <span class=\"bg-orange-100 text-orange-600 animate-pulse ...\">生成中</span>\n      // We use CSS class selector which is more reliable than text matching\n      const generatingBadgeSelector = 'span.bg-orange-100.text-orange-600'\n      // Helper: Selector for failed status badges (red background)\n      const failedBadgeSelector = 'span.bg-red-100.text-red-600'\n      // Helper: Selector for completed status badges (green background)\n      const _completedBadgeSelector = 'span.bg-green-100.text-green-600'\n      // Helper: Image selector for generated slide images\n      // Generated images are stored at: /files/{project_id}/pages/{page_id}_v{version}.png\n      // Template images are at: /files/{project_id}/template/template.png (excluded)\n      // We match images in /pages/ directory OR with \"Slide\" in alt text\n      const slideImageSelector = 'img[src*=\"/pages/\"], img[alt*=\"Slide\"]:not([alt=\"Template\"])'\n      \n      // Step 13a: Wait for generation to START, then COMPLETE\n      console.log('  Step 13a: Waiting for image generation task to complete...')\n      \n      // First, wait a bit for the API call to start and status to change\n      await page.waitForTimeout(2000)\n      \n      // Check if generation has started (look for \"生成中\" badges OR skeleton loaders)\n      let generationStarted = false\n      for (let i = 0; i < 10; i++) { // Try for up to 20 seconds\n        const generatingBadges = page.locator(generatingBadgeSelector)\n        const skeletons = page.locator('.animate-shimmer') // Skeleton uses animate-shimmer\n        const generatingCount = await generatingBadges.count()\n        const skeletonCount = await skeletons.count()\n        \n        if (generatingCount > 0 || skeletonCount > 0) {\n          generationStarted = true\n          console.log(`  ✓ Generation started (${generatingCount} generating badges, ${skeletonCount} skeletons)`)\n          break\n        }\n        \n        // Also check if images are already generated (fast path - previous run cached)\n        const images = page.locator(slideImageSelector)\n        const imageCount = await images.count()\n        if (imageCount >= pageCount) {\n          console.log(`  ✓ Images already generated (${imageCount}/${pageCount})`)\n          generationStarted = true\n          break\n        }\n        \n        await page.waitForTimeout(2000)\n      }\n      \n      if (!generationStarted) {\n        console.log('  ⚠ Could not detect generation start, continuing anyway...')\n      }\n      \n      // Now wait for generation to complete (no more \"生成中\" badges)\n      await expect(async () => {\n        // Check for \"生成中\" StatusBadge\n        const generatingBadges = page.locator(generatingBadgeSelector)\n        const generatingCount = await generatingBadges.count()\n        \n        // Also check for failed status - if all pages failed, we should fail early\n        const failedBadges = page.locator(failedBadgeSelector)\n        const failedCount = await failedBadges.count()\n        \n        const elapsed = Math.floor((Date.now() - startTime) / 1000)\n        \n        // Log progress every 30 seconds\n        if (elapsed % 30 === 0 && elapsed > 0) {\n          console.log(`  [${elapsed}s] Still generating... (${generatingCount} in progress, ${failedCount} failed)`)\n        }\n        \n        // If all pages failed, fail early\n        if (failedCount >= pageCount && generatingCount === 0) {\n          throw new Error(`All ${pageCount} pages failed to generate images`)\n        }\n        \n        if (generatingCount > 0) {\n          throw new Error(`Image generation still in progress (${elapsed}s elapsed, ${generatingCount} pages generating)`)\n        }\n        \n        return true\n      }).toPass({ \n        timeout: maxWaitTime,\n        intervals: [3000, 5000, 5000] // Check every 3-5 seconds\n      })\n      \n      console.log('  ✓ Image generation task completed, waiting for UI to update...')\n      await page.waitForTimeout(3000) // Give UI time to sync state after task completion\n      \n      // Step 13b: Wait for export button to be enabled (all images synced to UI)\n      // This verifies hasAllImages = true (all pages have generated_image_path)\n      console.log('  Step 13b: Waiting for export button to be enabled...')\n      await expect(async () => {\n        // Try to trigger a refresh by clicking refresh button if available (helps sync state)\n        const refreshBtn = page.locator('button:has-text(\"刷新\")').first()\n        if (await refreshBtn.isVisible().catch(() => false)) {\n          await refreshBtn.click().catch(() => {}) // Non-blocking refresh\n          await page.waitForTimeout(1000) // Wait for refresh to complete\n        }\n        \n        const exportBtnCheck = page.locator('button:has-text(\"导出\")')\n        const isEnabled = await exportBtnCheck.isEnabled().catch(() => false)\n        \n        // Use precise selector for slide images (in aspect-video containers)\n        const images = page.locator(slideImageSelector)\n        const imageCount = await images.count()\n        \n        // Also check for failed pages\n        const failedBadges = page.locator(failedBadgeSelector)\n        const failedCount = await failedBadges.count()\n        \n        const elapsed = Math.floor((Date.now() - startTime) / 1000)\n        \n        // Log progress every 10 seconds\n        if (elapsed % 10 === 0 && elapsed > 0) {\n          console.log(`  [${elapsed}s] Export enabled: ${isEnabled}, Images: ${imageCount}/${pageCount}, Failed: ${failedCount}`)\n        }\n        \n        // If some pages failed but we have enough images, that's also acceptable for partial export\n        // However, for full test we want all images\n        if (failedCount > 0 && imageCount + failedCount >= pageCount) {\n          console.log(`  ⚠ ${failedCount} pages failed, ${imageCount} succeeded`)\n        }\n        \n        if (!isEnabled) {\n          throw new Error(`Export button not yet enabled (${elapsed}s elapsed, ${imageCount}/${pageCount} images, ${failedCount} failed)`)\n        }\n        \n        if (imageCount < pageCount) {\n          throw new Error(`Only ${imageCount}/${pageCount} images found (${elapsed}s elapsed, ${failedCount} failed)`)\n        }\n        \n        console.log(`  [${elapsed}s] ✓ Export button enabled and ${imageCount} images found`)\n        return true\n      }).toPass({ \n        timeout: 120000, // 2 minutes for state sync (after task completion)\n        intervals: [2000, 3000, 5000] // Check every 2-5 seconds\n      })\n      \n      // Final verification: export button should be enabled\n      const exportBtnCheck = page.locator('button:has-text(\"导出\")')\n      await expect(exportBtnCheck).toBeEnabled({ timeout: 5000 })\n      \n      console.log('✓ All images generated\\n')\n      await page.screenshot({ path: 'test-results/e2e-images-generated.png' })\n    } else {\n      throw new Error('Batch generate images button not found')\n    }\n    \n    // ====================================\n    // Step 14: Export PPT\n    // ====================================\n    console.log('📦 Step 14: Exporting PPT file...')\n    \n    // Setup download handler\n    const downloadPromise = page.waitForEvent('download', { timeout: 60000 })\n    \n    // Step 1: Wait for export button to be enabled (it's disabled until all images are generated)\n    const exportBtn = page.locator('button:has-text(\"导出\")')\n    await exportBtn.waitFor({ state: 'visible', timeout: 10000 })\n    await expect(exportBtn).toBeEnabled({ timeout: 5000 })\n    \n    await exportBtn.first().click()\n    console.log('✓ Clicked export button, opening menu...')\n    \n    // Wait for dropdown menu to appear\n    await page.waitForTimeout(500)\n    \n    // Step 2: Click \"导出为 PPTX\" in the dropdown menu\n    const exportPptxBtn = page.locator('button:has-text(\"导出为 PPTX\")')\n    await exportPptxBtn.waitFor({ state: 'visible', timeout: 5000 })\n    await exportPptxBtn.click()\n    console.log('✓ Clicked \"导出为 PPTX\" button\\n')\n    \n    // Wait for download to complete\n    console.log('⏳ Waiting for PPT file download...')\n    const download = await downloadPromise\n    \n    // Save file\n    const downloadPath = path.join('test-results', 'e2e-test-output.pptx')\n    await download.saveAs(downloadPath)\n    \n    // Verify file exists and is not empty\n    const fileExists = fs.existsSync(downloadPath)\n    expect(fileExists).toBeTruthy()\n    \n    const fileStats = fs.statSync(downloadPath)\n    expect(fileStats.size).toBeGreaterThan(1000) // At least 1KB\n    \n    console.log(`✓ PPT file downloaded successfully!`)\n    console.log(`  Path: ${downloadPath}`)\n    console.log(`  Size: ${(fileStats.size / 1024).toFixed(2)} KB\\n`)\n    \n    // Validate PPTX file content using python-pptx\n    console.log('🔍 Validating PPTX file content...')\n    const { execSync } = await import('child_process')\n    const { fileURLToPath } = await import('url')\n    try {\n      // Get current directory (ES module compatible)\n      const currentDir = path.dirname(fileURLToPath(import.meta.url))\n      const validateScript = path.join(currentDir, 'validate_pptx.py')\n      const result = execSync(\n        `python3 \"${validateScript}\" \"${downloadPath}\" 3 \"人工智能\" \"AI\"`,\n        { encoding: 'utf-8', stdio: 'pipe' }\n      )\n      console.log(`✓ ${result.trim()}\\n`)\n    } catch (error: any) {\n      console.warn(`⚠️  PPTX validation warning: ${error.stdout || error.message}`)\n      console.log('  (Continuing test, but PPTX content validation had issues)\\n')\n    }\n    \n    // ====================================\n    // Final verification\n    // ====================================\n    console.log('========================================')\n    console.log('✅ Full E2E test completed!')\n    console.log('========================================\\n')\n    \n    // Final screenshot\n    await page.screenshot({ \n      path: 'test-results/e2e-final-state.png',\n      fullPage: true \n    })\n  })\n})\n\ntest.describe('UI E2E - Simplified (skip long waits)', () => {\n  test.setTimeout(5 * 60 * 1000) // 5 minutes\n  \n  test('User flow verification: Only verify UI interactions, do not wait for AI generation', async ({ page }) => {\n    console.log('\\n🏃 Quick E2E test (verify UI flow, do not wait for generation)\\n')\n    \n    // Visit homepage (prevent HelpModal from appearing)\n    await page.addInitScript(() => {\n      localStorage.setItem('hasSeenHelpModal', 'true')\n    })\n    await page.goto('http://localhost:3000')\n    console.log('✓ Homepage loaded')\n\n    // Ensure \"一句话生成\" tab is selected (it's selected by default)\n    await page.click('button:has-text(\"一句话生成\")').catch(() => {\n      // If click fails, the tab might already be selected, which is fine\n    })\n    console.log('✓ Entered create page')\n    \n    // Wait for textarea to be visible (MarkdownTextarea uses contentEditable div with role=\"textbox\")\n    await page.waitForSelector('[role=\"textbox\"], textarea', { timeout: 10000 })\n\n    // Enter content\n    const ideaInput = page.locator('[role=\"textbox\"], textarea').first()\n    await ideaInput.click()\n    await ideaInput.pressSequentially('E2E test project')\n    console.log('✓ Entered content')\n    \n    // Click generate\n    await page.click('button:has-text(\"下一步\")')\n    console.log('✓ Submitted generation request')\n    \n    // Verify loading state appears or navigation happens (indicates request was sent)\n    // For quick test, we can accept either loading state OR successful navigation\n    try {\n      // Option 1: Wait for navigation to outline page (most reliable)\n      await page.waitForURL(/\\/project\\/.*\\/outline/, { timeout: 10000 })\n      console.log('✓ Navigation to outline page detected')\n    } catch {\n      // Option 2: Check for loading indicators\n      try {\n        await page.waitForSelector(\n          '.animate-spin, button[disabled], div:has-text(\"加载\"), div:has-text(\"生成中\")',\n          { timeout: 5000 }\n        )\n        console.log('✓ Loading state detected')\n      } catch {\n        // Option 3: Just wait a bit and assume request was sent\n        // This is acceptable for a quick test that doesn't wait for completion\n        await page.waitForTimeout(1000)\n        console.log('✓ Request submitted (assuming success)')\n      }\n    }\n    \n    console.log('\\n✅ UI flow verification passed!\\n')\n  })\n})\n\n"
  },
  {
    "path": "frontend/e2e/upload-folder-path.spec.ts",
    "content": "/**\n * E2E test for UPLOAD_FOLDER path resolution fix (#287).\n *\n * Bug: ai_service.py used os.environ.get('UPLOAD_FOLDER', '') which always\n * returned '' because UPLOAD_FOLDER lives in Flask app.config, not env vars.\n * Fix: use get_config().UPLOAD_FOLDER instead.\n *\n * Test strategy:\n *   1. Upload a material image to a project\n *   2. Set page description referencing the material via /files/ path\n *   3. Trigger image generation (will fail at AI provider level — that's fine)\n *   4. Verify backend logs show the file was FOUND, not \"Local file not found\"\n */\n\nimport { test, expect } from '@playwright/test'\nimport * as fs from 'fs'\nimport * as path from 'path'\n\nconst FRONTEND_DIR = process.cwd().endsWith('frontend')\n  ? process.cwd()\n  : path.join(process.cwd(), 'frontend')\nconst PROJECT_ROOT = path.resolve(FRONTEND_DIR, '..')\nconst FIXTURES = path.join(FRONTEND_DIR, 'e2e', 'fixtures')\nconst BACKEND_LOG = '/tmp/fix-upload-backend.log'\n\ntest.describe('UPLOAD_FOLDER path resolution (#287)', () => {\n  test('material image referenced in description is resolved correctly during image generation', async ({\n    request,\n  }) => {\n    // 1. Create a project\n    const createResp = await request.post('/api/projects', {\n      data: {\n        creation_type: 'idea',\n        idea_prompt: 'upload folder path test',\n        template_style: 'default',\n      },\n    })\n    if (!createResp.ok()) {\n      test.skip(true, 'Backend unavailable')\n      return\n    }\n    const projectId = (await createResp.json()).data?.project_id\n    expect(projectId).toBeTruthy()\n\n    // 2. Create a page\n    const pageResp = await request.post(`/api/projects/${projectId}/pages`, {\n      data: { order_index: 0, outline_content: { title: 'Test Slide' } },\n    })\n    expect(pageResp.ok()).toBe(true)\n    const pageId = (await pageResp.json()).data?.page_id\n    expect(pageId).toBeTruthy()\n\n    // 3. Upload a material image\n    const fixturePath = path.join(FIXTURES, 'slide_1.jpg')\n    if (!fs.existsSync(fixturePath)) {\n      test.skip(true, 'Fixture image not found')\n      return\n    }\n\n    const fileBuffer = fs.readFileSync(fixturePath)\n\n    const uploadResp = await request.post(\n      `/api/projects/${projectId}/materials/upload`,\n      { multipart: { file: { name: 'test-material.jpg', mimeType: 'image/jpeg', buffer: fileBuffer } } },\n    )\n    expect(uploadResp.ok()).toBe(true)\n    const materialData = (await uploadResp.json()).data\n    const materialPath: string = materialData?.relative_path || materialData?.file_path || ''\n    expect(materialPath).toBeTruthy()\n\n    // Build the /files/ URL that would appear in a description\n    const filesUrl = materialPath.startsWith('/files/')\n      ? materialPath\n      : `/files/${materialPath}`\n\n    // 4. Verify the material file is accessible via /files/ endpoint\n    const fileResp = await request.get(filesUrl)\n    expect(fileResp.ok()).toBe(true)\n\n    // 5. Set page description with material reference\n    const descResp = await request.put(\n      `/api/projects/${projectId}/pages/${pageId}/description`,\n      {\n        data: {\n          description_content: {\n            title: 'Test Slide',\n            text: `Use this reference image: ![material](${filesUrl})`,\n            text_content: [`Use this reference image: ![material](${filesUrl})`],\n            layout_suggestion: 'full-image',\n          },\n        },\n      },\n    )\n    expect(descResp.ok()).toBe(true)\n\n    // 6. Mark the log position before triggering generation\n    const logBefore = fs.existsSync(BACKEND_LOG)\n      ? fs.readFileSync(BACKEND_LOG, 'utf8').length\n      : 0\n\n    // 7. Trigger image generation (will fail at AI provider level — expected)\n    const genResp = await request.post(\n      `/api/projects/${projectId}/generate/images`,\n      { data: { max_workers: 1 } },\n    )\n    expect(genResp.ok()).toBe(true)\n    const taskId = (await genResp.json()).data?.task_id\n    expect(taskId).toBeTruthy()\n\n    // 8. Poll task until done (expect FAILED due to no AI provider)\n    let taskStatus = 'PROCESSING'\n    for (let i = 0; i < 30; i++) {\n      await new Promise((r) => setTimeout(r, 1000))\n      const taskResp = await request.get(\n        `/api/projects/${projectId}/tasks/${taskId}`,\n      )\n      if (!taskResp.ok()) continue\n      const task = (await taskResp.json()).data\n      taskStatus = task?.status\n      if (taskStatus === 'COMPLETED' || taskStatus === 'FAILED') break\n    }\n\n    // 9. Read new log lines and verify path resolution\n    const logAfter = fs.existsSync(BACKEND_LOG)\n      ? fs.readFileSync(BACKEND_LOG, 'utf8')\n      : ''\n    const newLogs = logAfter.slice(logBefore)\n\n    // The fix ensures the material file IS found — no \"Local file not found\" for our material\n    const materialFilename = path.basename(materialPath)\n    const fileNotFoundForMaterial = newLogs\n      .split('\\n')\n      .filter(\n        (line) =>\n          line.includes('Local file not found') &&\n          line.includes(materialFilename),\n      )\n\n    expect(\n      fileNotFoundForMaterial,\n      `Material file should be found by ai_service, but got \"Local file not found\" in logs`,\n    ).toHaveLength(0)\n\n    // Positive check: if the material filename appears in logs, it should be \"Loaded\", not \"not found\"\n    const materialLoadedLine = newLogs\n      .split('\\n')\n      .some(\n        (line) =>\n          line.includes('Loaded image from local path') &&\n          line.includes(materialFilename),\n      )\n    if (newLogs.includes(materialFilename)) {\n      expect(\n        materialLoadedLine || !fileNotFoundForMaterial.length,\n        `Material ${materialFilename} should be loaded, not missing`,\n      ).toBe(true)\n    }\n  })\n})\n"
  },
  {
    "path": "frontend/e2e/ux-polish-i18n.spec.ts",
    "content": "import { test, expect, Page } from '@playwright/test';\nimport { seedProjectWithImages } from './helpers/seed-project';\n\nconst BASE = process.env.BASE_URL || 'http://localhost:3000';\n\n/**\n * Mock test: Verify disabled button tooltips and i18n strings\n * via page.route() without hitting a real backend.\n */\ntest.describe('UX Polish – disabled button tooltips (mock)', () => {\n  test('export button shows tooltip when images are missing', async ({ page }) => {\n    // Set English locale to verify i18n tooltip content\n    await page.addInitScript(() => {\n      localStorage.setItem('banana-slides-language', 'en');\n    });\n\n    // Mock project with pages that have NO generated images\n    const mockProject = {\n      data: {\n        id: 'proj-1',\n        project_id: 'proj-1',\n        creation_type: 'idea',\n        idea_prompt: 'Test',\n        pages: [\n          { id: 'p1', order_index: 0, outline_content: { title: 'Page 1', points: [] }, description_content: { text: 'desc' } },\n          { id: 'p2', order_index: 1, outline_content: { title: 'Page 2', points: [] }, description_content: { text: 'desc' } },\n        ],\n      },\n    };\n\n    await page.route('**/api/projects/proj-1', (route) => {\n      route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockProject) });\n    });\n\n    await page.goto(`${BASE}/project/proj-1/preview`);\n    await page.waitForSelector('text=Page 1', { timeout: 5000 }).catch(() => {});\n\n    // The export button should be disabled and have a title attribute explaining why\n    const exportBtn = page.locator('button:has-text(\"PPTX\")').first();\n    if (await exportBtn.count() > 0) {\n      const title = await exportBtn.getAttribute('title');\n      // Should have the English tooltip explaining why export is disabled\n      expect(title).toContain('no images yet');\n    }\n  });\n\n  test('next button shows tooltip when descriptions are missing in detail editor', async ({ page }) => {\n    // Set English locale to verify i18n tooltip content\n    await page.addInitScript(() => {\n      localStorage.setItem('banana-slides-language', 'en');\n    });\n\n    const mockProject = {\n      data: {\n        id: 'proj-2',\n        project_id: 'proj-2',\n        creation_type: 'idea',\n        idea_prompt: 'Test',\n        pages: [\n          { id: 'p1', order_index: 0, outline_content: { title: 'Page 1', points: [] } },\n          { id: 'p2', order_index: 1, outline_content: { title: 'Page 2', points: [] }, description_content: { text: 'has desc' } },\n        ],\n      },\n    };\n\n    await page.route('**/api/projects/proj-2', (route) => {\n      route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockProject) });\n    });\n\n    await page.goto(`${BASE}/project/proj-2/detail`);\n    await page.waitForSelector('text=Page 1', { timeout: 5000 }).catch(() => {});\n\n    // Target the \"Generate Images\" / next-step button specifically (not the AI refine submit)\n    const nextBtn = page.locator('button[title*=\"descriptions\"]').first();\n    if (await nextBtn.count() > 0) {\n      const title = await nextBtn.getAttribute('title');\n      expect(title).toContain('missing descriptions');\n    }\n  });\n});\n\ntest.describe('UX Polish – i18n strings (mock)', () => {\n  test('project status text uses i18n (not hardcoded Chinese)', async ({ page }) => {\n    // Set English locale\n    await page.addInitScript(() => {\n      localStorage.setItem('banana-slides-language', 'en');\n    });\n\n    const mockProjects = {\n      data: {\n        projects: [\n          {\n            id: 'proj-en-1',\n            project_id: 'proj-en-1',\n            creation_type: 'idea',\n            idea_prompt: 'English test',\n            pages: [\n              { id: 'p1', page_id: 'p1', order_index: 0, outline_content: { title: 'Slide 1', points: [] }, description_content: { text: 'desc' }, generated_image_url: '/img.png' },\n            ],\n            created_at: '2026-01-01T00:00:00Z',\n            updated_at: '2026-01-01T00:00:00Z',\n          },\n        ],\n        total: 1,\n      },\n    };\n\n    await page.route('**/api/projects**', (route) => {\n      if (route.request().method() === 'GET') {\n        route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockProjects) });\n      } else {\n        route.continue();\n      }\n    });\n\n    await page.goto(`${BASE}/history`);\n    await expect(page.locator('text=Slide 1')).toBeVisible({ timeout: 5000 });\n\n    // The status badge should show English text, not Chinese\n    const pageContent = await page.textContent('body');\n    // In English mode, status should be \"Completed\" not \"已完成\"\n    expect(pageContent).toContain('Completed');\n    expect(pageContent).not.toContain('已完成');\n  });\n\n  test('settings page error messages use i18n', async ({ page }) => {\n    // Set English locale\n    await page.addInitScript(() => {\n      localStorage.setItem('banana-slides-language', 'en');\n    });\n\n    // Mock settings endpoint to fail\n    await page.route('**/api/settings', (route) => {\n      if (route.request().method() === 'GET') {\n        route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: { message: 'Server error' } }) });\n      } else {\n        route.continue();\n      }\n    });\n\n    await page.goto(`${BASE}/settings`);\n    // Wait for the settings page to render (heading appears even on error)\n    await expect(page.locator('h1, h2').first()).toBeVisible({ timeout: 5000 });\n\n    // The error toast should show English text\n    const toastText = await page.textContent('body');\n    // Should NOT contain hardcoded Chinese error like \"加载设置失败\"\n    expect(toastText).not.toContain('加载设置失败');\n  });\n});\n\n/**\n * Integration test: Verify i18n works with real backend\n */\ntest.describe('UX Polish – integration', () => {\n  test('settings page loads without hardcoded Chinese in English mode', async ({ page }) => {\n    await page.addInitScript(() => {\n      localStorage.setItem('banana-slides-language', 'en');\n    });\n\n    await page.goto(`${BASE}/settings`);\n    await expect(page.locator('h1, h2').first()).toBeVisible({ timeout: 5000 });\n\n    // Check that the page title is in English\n    const heading = page.locator('h1, h2').first();\n    if (await heading.count() > 0) {\n      const text = await heading.textContent();\n      expect(text).toContain('Settings');\n    }\n\n    // Check that action buttons are in English\n    const saveBtn = page.locator('button:has-text(\"Save\")').first();\n    if (await saveBtn.count() > 0) {\n      expect(await saveBtn.textContent()).toContain('Save');\n    }\n  });\n});\n"
  },
  {
    "path": "frontend/e2e/visual-regression.spec.ts",
    "content": "/**\n * Visual Regression Tests\n * \n * Tests critical UI components for visual regressions using screenshot comparison.\n * \n * Note: First run will create baseline screenshots. Subsequent runs will compare against baselines.\n * \n * To update baselines: npx playwright test visual-regression.spec.ts --update-snapshots\n */\n\nimport { test, expect } from '@playwright/test'\n\ntest.describe('Visual Regression Tests', () => {\n  test.beforeEach(async ({ page }) => {\n    // Navigate to the app\n    await page.goto('http://localhost:3000')\n  })\n  \n  test('Homepage visual regression', async ({ page }) => {\n    // Wait for page to fully load\n    await page.waitForLoadState('networkidle')\n    \n    // Take screenshot of homepage\n    await expect(page).toHaveScreenshot('homepage.png', {\n      fullPage: true,\n      maxDiffPixels: 100, // Allow small differences\n    })\n  })\n  \n  test('SlidePreview component visual regression', async ({ page }) => {\n    // This test requires a project to exist\n    // For now, we'll test the component in isolation if possible\n    \n    // Navigate to a project preview page (if available)\n    // Note: This may need to be adjusted based on your routing\n    try {\n      // Try to navigate to preview page (you may need to create a test project first)\n      await page.goto('http://localhost:3000/project/test-project-id/preview')\n      await page.waitForLoadState('networkidle')\n      \n      // Take screenshot of SlidePreview component\n      const slidePreview = page.locator('.slide-preview, [data-testid=\"slide-preview\"]').first()\n      \n      if (await slidePreview.count() > 0) {\n        await expect(slidePreview).toHaveScreenshot('slide-preview.png', {\n          maxDiffPixels: 200,\n        })\n      } else {\n        // If component not found, take full page screenshot\n        await expect(page).toHaveScreenshot('slide-preview-page.png', {\n          fullPage: true,\n          maxDiffPixels: 200,\n        })\n      }\n    } catch (error) {\n      // If preview page doesn't exist, skip this test\n      test.skip()\n    }\n  })\n  \n  test('Outline Editor visual regression', async ({ page }) => {\n    // Navigate to outline editor\n    try {\n      await page.goto('http://localhost:3000/project/test-project-id/outline')\n      await page.waitForLoadState('networkidle')\n      \n      // Take screenshot of outline editor\n      await expect(page).toHaveScreenshot('outline-editor.png', {\n        fullPage: true,\n        maxDiffPixels: 200,\n      })\n    } catch (error) {\n      test.skip()\n    }\n  })\n  \n  test('Description Editor visual regression', async ({ page }) => {\n    // Navigate to description editor\n    try {\n      await page.goto('http://localhost:3000/project/test-project-id/detail')\n      await page.waitForLoadState('networkidle')\n      \n      // Take screenshot of description editor\n      await expect(page).toHaveScreenshot('description-editor.png', {\n        fullPage: true,\n        maxDiffPixels: 200,\n      })\n    } catch (error) {\n      test.skip()\n    }\n  })\n  \n  test('Loading states visual regression', async ({ page }) => {\n    // Test loading spinner/state\n    await page.goto('http://localhost:3000')\n    \n    // Trigger a loading state (e.g., click create button)\n    // Ensure \"一句话生成\" tab is selected (it's selected by default)\n    const createButton = page.locator('button:has-text(\"一句话生成\")')\n    if (await createButton.count() > 0) {\n      await createButton.click().catch(() => {\n        // If click fails, the tab might already be selected, which is fine\n      })\n      \n      // Wait for loading state to appear\n      const loadingIndicator = page.locator('.loading, .spinner, [data-loading=\"true\"]')\n      if (await loadingIndicator.count() > 0) {\n        await expect(loadingIndicator.first()).toHaveScreenshot('loading-state.png', {\n          maxDiffPixels: 50,\n        })\n      }\n    }\n  })\n})\n\n"
  },
  {
    "path": "frontend/index.html",
    "content": "<!doctype html>\n<html lang=\"zh-CN\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/banana.svg\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes\" />\n    <meta name=\"apple-mobile-web-app-capable\" content=\"yes\" />\n    <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\" />\n    <meta name=\"format-detection\" content=\"telephone=no\" />\n    <title>蕉幻 | AI 原生 PPT 生成器</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n\n"
  },
  {
    "path": "frontend/nginx.conf",
    "content": "server {\n    listen 80;\n    server_name localhost;\n    root /usr/share/nginx/html;\n    index index.html;\n\n    # 允许上传大文件（解决413错误）\n    client_max_body_size 50M;\n\n    # Gzip 压缩\n    gzip on;\n    gzip_vary on;\n    gzip_min_length 1024;\n    gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;\n\n    # 前端路由支持（SPA）\n    location / {\n        try_files $uri $uri/ /index.html;\n    }\n\n    # API 代理到后端 - 使用 ^~ 确保优先匹配\n    location ^~ /api {\n        proxy_pass http://backend:5000;\n        proxy_http_version 1.1;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection 'upgrade';\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_cache_bypass $http_upgrade;\n        proxy_read_timeout 300s;\n        proxy_connect_timeout 300s;\n    }\n\n    # 文件服务代理 - 使用 ^~ 确保优先匹配，阻止后续正则匹配\n    location ^~ /files {\n        proxy_pass http://backend:5000;\n        proxy_http_version 1.1;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_read_timeout 300s;\n        proxy_connect_timeout 300s;\n        # 不缓存动态文件\n        add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n    }\n\n    # 健康检查端点\n    location /health {\n        proxy_pass http://backend:5000/health;\n        proxy_http_version 1.1;\n        proxy_set_header Host $host;\n    }\n\n    # 静态资源缓存 - 只匹配前端静态资源，不匹配 /files 和 /api\n    location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {\n        expires 1y;\n        add_header Cache-Control \"public, immutable\";\n    }\n}\n\n"
  },
  {
    "path": "frontend/package.json",
    "content": "{\n  \"name\": \"banana-slides-frontend\",\n  \"private\": true,\n  \"version\": \"0.1.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"build:check\": \"tsc && vite build\",\n    \"lint\": \"eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 20\",\n    \"lint:strict\": \"eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0\",\n    \"preview\": \"vite preview\",\n    \"test\": \"vitest\",\n    \"test:run\": \"vitest run\",\n    \"test:coverage\": \"vitest run --coverage\",\n    \"test:ui\": \"vitest --ui\",\n    \"test:e2e\": \"playwright test\",\n    \"test:e2e:ui\": \"playwright test --ui\",\n    \"test:e2e:headed\": \"playwright test --headed\"\n  },\n  \"dependencies\": {\n    \"@dnd-kit/core\": \"^6.1.0\",\n    \"@dnd-kit/sortable\": \"^8.0.0\",\n    \"@dnd-kit/utilities\": \"^3.2.2\",\n    \"axios\": \"^1.6.2\",\n    \"clsx\": \"^2.0.0\",\n    \"i18next\": \"^25.8.0\",\n    \"i18next-browser-languagedetector\": \"^8.2.0\",\n    \"katex\": \"^0.16.28\",\n    \"lucide-react\": \"^0.294.0\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-i18next\": \"^16.5.4\",\n    \"react-markdown\": \"^9.0.1\",\n    \"react-router-dom\": \"^6.20.0\",\n    \"rehype-katex\": \"^7.0.1\",\n    \"rehype-raw\": \"^7.0.0\",\n    \"rehype-sanitize\": \"^6.0.0\",\n    \"remark-breaks\": \"^4.0.0\",\n    \"remark-gfm\": \"^4.0.0\",\n    \"remark-math\": \"^6.0.0\",\n    \"tailwind-merge\": \"^2.1.0\",\n    \"zustand\": \"^4.4.7\"\n  },\n  \"devDependencies\": {\n    \"@playwright/test\": \"^1.40.1\",\n    \"@testing-library/jest-dom\": \"^6.1.5\",\n    \"@testing-library/react\": \"^14.1.2\",\n    \"@testing-library/user-event\": \"^14.5.1\",\n    \"@types/node\": \"^25.0.1\",\n    \"@types/react\": \"^18.2.43\",\n    \"@types/react-dom\": \"^18.2.17\",\n    \"@typescript-eslint/eslint-plugin\": \"^6.14.0\",\n    \"@typescript-eslint/parser\": \"^6.14.0\",\n    \"@vitejs/plugin-react\": \"^4.2.1\",\n    \"@vitest/coverage-v8\": \"^1.1.0\",\n    \"@vitest/ui\": \"^1.1.0\",\n    \"autoprefixer\": \"^10.4.16\",\n    \"eslint\": \"^8.55.0\",\n    \"eslint-plugin-react-hooks\": \"^4.6.0\",\n    \"eslint-plugin-react-refresh\": \"^0.4.5\",\n    \"jsdom\": \"^23.0.1\",\n    \"postcss\": \"^8.4.32\",\n    \"tailwindcss\": \"^3.3.6\",\n    \"typescript\": \"^5.2.2\",\n    \"vite\": \"^5.0.8\",\n    \"vitest\": \"^1.1.0\"\n  }\n}\n"
  },
  {
    "path": "frontend/playwright.config.ts",
    "content": "import { defineConfig, devices } from '@playwright/test'\n\n/**\n * Playwright E2E测试配置 - 前端 UI 测试\n * \n * @see https://playwright.dev/docs/test-configuration\n */\nexport default defineConfig({\n  // 测试目录\n  testDir: './e2e',\n  \n  // 测试文件匹配模式\n  testMatch: '**/*.spec.ts',\n  \n  // 并行运行测试\n  fullyParallel: true,\n  \n  // CI环境下失败立即停止\n  forbidOnly: !!process.env.CI,\n  \n  // 失败不重试\n  retries: 0,\n  \n  // 并行worker数量\n  workers: process.env.CI ? 1 : undefined,\n  \n  // 测试报告\n  reporter: [\n    ['html', { outputFolder: 'playwright-report' }],\n    ['list'],\n    ...(process.env.CI ? [['github'] as const] : []),\n  ],\n  \n  // 全局设置\n  use: {\n    // 基础URL\n    baseURL: process.env.BASE_URL || 'http://localhost:3000',\n    \n    // 截图设置\n    screenshot: 'only-on-failure',\n    \n    // 视频设置\n    video: 'retain-on-failure',\n    \n    // 追踪设置\n    trace: 'retain-on-failure',\n    \n    // 浏览器语言设置（E2E测试使用中文，匹配选择器）\n    locale: 'zh-CN',\n\n    // 超时设置\n    actionTimeout: 15000,\n    navigationTimeout: 30000,\n  },\n  \n  // 全局超时\n  timeout: 60000,\n  \n  // 预期超时\n  expect: {\n    timeout: 10000,\n  },\n  \n  // 项目配置（多浏览器测试）\n  projects: [\n    {\n      name: 'chromium',\n      use: { ...devices['Desktop Chrome'] },\n    },\n  ],\n  \n  // 本地开发时启动服务\n  webServer: process.env.CI ? undefined : {\n    command: 'cd .. && docker compose up -d && sleep 10',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120000,\n  },\n})\n\n"
  },
  {
    "path": "frontend/postcss.config.js",
    "content": "export default {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n}\n\n"
  },
  {
    "path": "frontend/src/App.tsx",
    "content": "import { useEffect } from 'react';\nimport { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';\nimport { Home } from './pages/Home';\nimport { Landing } from './pages/Landing';\nimport { History } from './pages/History';\nimport { OutlineEditor } from './pages/OutlineEditor';\nimport { DetailEditor } from './pages/DetailEditor';\nimport { SlidePreview } from './pages/SlidePreview';\nimport { SettingsPage } from './pages/Settings';\nimport { useProjectStore } from './store/useProjectStore';\nimport { useToast, AccessCodeGuard } from './components/shared';\n\nfunction App() {\n  const { currentProject, syncProject, error, setError } = useProjectStore();\n  const { show, ToastContainer } = useToast();\n\n  // 恢复项目状态\n  useEffect(() => {\n    const savedProjectId = localStorage.getItem('currentProjectId');\n    if (savedProjectId && !currentProject) {\n      syncProject();\n    }\n  }, [currentProject, syncProject]);\n\n  // 显示全局错误\n  useEffect(() => {\n    if (error) {\n      show({ message: error, type: 'error' });\n      setError(null);\n    }\n  }, [error, setError, show]);\n\n  return (\n    <AccessCodeGuard>\n      <BrowserRouter>\n        <Routes>\n          <Route path=\"/\" element={<Home />} />\n          <Route path=\"/landing\" element={<Landing />} />\n          <Route path=\"/history\" element={<History />} />\n          <Route path=\"/settings\" element={<SettingsPage />} />\n          <Route path=\"/project/:projectId/outline\" element={<OutlineEditor />} />\n          <Route path=\"/project/:projectId/detail\" element={<DetailEditor />} />\n          <Route path=\"/project/:projectId/preview\" element={<SlidePreview />} />\n          <Route path=\"*\" element={<Navigate to=\"/\" replace />} />\n        </Routes>\n        <ToastContainer />\n      </BrowserRouter>\n    </AccessCodeGuard>\n  );\n}\n\nexport default App;\n\n"
  },
  {
    "path": "frontend/src/api/client.ts",
    "content": "import axios from 'axios';\n\n// 开发环境：通过 Vite proxy 转发\n// 生产环境：通过 nginx proxy 转发\nconst API_BASE_URL = '';\n\n// 创建 axios 实例\nexport const apiClient = axios.create({\n  baseURL: API_BASE_URL,\n  timeout: 300000, // 5分钟超时（AI生成可能很慢）\n});\n\n// 请求拦截器\napiClient.interceptors.request.use(\n  (config) => {\n    // Attach access code header for backend enforcement\n    const accessCode = localStorage.getItem('banana-access-code');\n    if (accessCode && config.headers) {\n      config.headers['X-Access-Code'] = accessCode;\n    }\n\n    // 如果请求体是 FormData，删除 Content-Type 让浏览器自动设置\n    // 浏览器会自动添加正确的 Content-Type 和 boundary\n    if (config.data instanceof FormData) {\n      // 不设置 Content-Type，让浏览器自动处理\n      if (config.headers) {\n        delete config.headers['Content-Type'];\n      }\n    } else if (config.headers && !config.headers['Content-Type']) {\n      // 对于非 FormData 请求，默认设置为 JSON\n      config.headers['Content-Type'] = 'application/json';\n    }\n\n    return config;\n  },\n  (error) => {\n    return Promise.reject(error);\n  }\n);\n\n// 响应拦截器\napiClient.interceptors.response.use(\n  (response) => {\n    return response;\n  },\n  (error) => {\n    // 统一错误处理\n    if (error.response) {\n      // 服务器返回错误状态码\n      console.error('API Error:', error.response.data);\n    } else if (error.request) {\n      // 请求已发送但没有收到响应\n      console.error('Network Error:', error.request);\n    } else {\n      // 其他错误\n      console.error('Error:', error.message);\n    }\n    return Promise.reject(error);\n  }\n);\n\n// 图片URL处理工具\n// 使用相对路径，通过代理转发到后端\nexport const getImageUrl = (path?: string, timestamp?: string | number): string => {\n  if (!path) return '';\n  // 如果已经是完整URL，直接返回\n  if (path.startsWith('http://') || path.startsWith('https://')) {\n    return path;\n  }\n  // 使用相对路径（确保以 / 开头）\n  let url = path.startsWith('/') ? path : '/' + path;\n  \n  // 添加时间戳参数避免浏览器缓存（仅在提供时间戳时添加）\n  if (timestamp) {\n    const ts = typeof timestamp === 'string' \n      ? new Date(timestamp).getTime() \n      : timestamp;\n    url += `?v=${ts}`;\n  }\n  \n  return url;\n};\n\nexport default apiClient;\n\n"
  },
  {
    "path": "frontend/src/api/endpoints.ts",
    "content": "import { apiClient } from './client';\nimport type { Project, Task, ApiResponse, CreateProjectRequest, Page } from '@/types';\nimport type { Settings } from '../types/index';\n\n// ===== 访问口令 API =====\n\nexport const checkAccessCode = async (): Promise<ApiResponse<{ enabled: boolean }>> => {\n  const response = await apiClient.get<ApiResponse<{ enabled: boolean }>>('/api/access-code/check');\n  return response.data;\n};\n\nexport const verifyAccessCode = async (code: string): Promise<ApiResponse<{ valid: boolean }>> => {\n  const response = await apiClient.post<ApiResponse<{ valid: boolean }>>('/api/access-code/verify', { code });\n  return response.data;\n};\n\n// ===== 项目相关 API =====\n\n/**\n * 创建项目\n */\nexport const createProject = async (data: CreateProjectRequest): Promise<ApiResponse<Project>> => {\n  // 根据输入类型确定 creation_type\n  let creation_type = 'idea';\n  if (data.description_text) {\n    creation_type = 'descriptions';\n  } else if (data.outline_text) {\n    creation_type = 'outline';\n  }\n\n  const response = await apiClient.post<ApiResponse<Project>>('/api/projects', {\n    creation_type,\n    idea_prompt: data.idea_prompt,\n    outline_text: data.outline_text,\n    description_text: data.description_text,\n    template_style: data.template_style,\n    image_aspect_ratio: data.image_aspect_ratio,\n  });\n  return response.data;\n};\n\n/**\n * 上传模板图片\n */\nexport const uploadTemplate = async (\n  projectId: string,\n  templateImage: File\n): Promise<ApiResponse<{ template_image_url: string }>> => {\n  const formData = new FormData();\n  formData.append('template_image', templateImage);\n\n  const response = await apiClient.post<ApiResponse<{ template_image_url: string }>>(\n    `/api/projects/${projectId}/template`,\n    formData\n  );\n  return response.data;\n};\n\n/**\n * 获取项目列表（历史项目）\n */\nexport const listProjects = async (limit?: number, offset?: number): Promise<ApiResponse<{ projects: Project[]; total: number }>> => {\n  const params = new URLSearchParams();\n  if (limit !== undefined) params.append('limit', limit.toString());\n  if (offset !== undefined) params.append('offset', offset.toString());\n\n  const queryString = params.toString();\n  const url = `/api/projects${queryString ? `?${queryString}` : ''}`;\n  const response = await apiClient.get<ApiResponse<{ projects: Project[]; total: number }>>(url);\n  return response.data;\n};\n\n/**\n * 获取项目详情\n */\nexport const getProject = async (projectId: string): Promise<ApiResponse<Project>> => {\n  const response = await apiClient.get<ApiResponse<Project>>(`/api/projects/${projectId}`);\n  return response.data;\n};\n\n/**\n * 删除项目\n */\nexport const deleteProject = async (projectId: string): Promise<ApiResponse> => {\n  const response = await apiClient.delete<ApiResponse>(`/api/projects/${projectId}`);\n  return response.data;\n};\n\n/**\n * 更新项目\n */\nexport const updateProject = async (\n  projectId: string,\n  data: Partial<Project>\n): Promise<ApiResponse<Project>> => {\n  const response = await apiClient.put<ApiResponse<Project>>(`/api/projects/${projectId}`, data);\n  return response.data;\n};\n\n/**\n * 更新页面顺序\n */\nexport const updatePagesOrder = async (\n  projectId: string,\n  pageIds: string[]\n): Promise<ApiResponse<Project>> => {\n  const response = await apiClient.put<ApiResponse<Project>>(\n    `/api/projects/${projectId}`,\n    { pages_order: pageIds }\n  );\n  return response.data;\n};\n\n// ===== 大纲生成 =====\n\n/**\n * 生成大纲\n * @param projectId 项目ID\n * @param language 输出语言（可选，默认从 sessionStorage 获取）\n */\nexport const generateOutline = async (projectId: string, language?: OutputLanguage): Promise<ApiResponse> => {\n  const lang = language || await getStoredOutputLanguage();\n  const response = await apiClient.post<ApiResponse>(\n    `/api/projects/${projectId}/generate/outline`,\n    { language: lang }\n  );\n  return response.data;\n};\n\n/**\n * 流式生成大纲（SSE）\n * 返回 ReadableStream，每个 page 事件包含一个页面对象\n */\nexport interface OutlineStreamPage {\n  index: number;\n  title: string;\n  points: string[];\n  part?: string;\n}\n\nexport interface OutlineStreamCallbacks {\n  onPage: (page: OutlineStreamPage) => void;\n  onDone: (data: { total: number; pages: Page[] }) => void;\n  onError: (message: string) => void;\n}\n\nexport const generateOutlineStream = async (\n  projectId: string,\n  callbacks: OutlineStreamCallbacks,\n  language?: OutputLanguage,\n  lockPageCount?: boolean,\n): Promise<void> => {\n  const lang = language || await getStoredOutputLanguage();\n  const accessCode = localStorage.getItem('banana-access-code');\n\n  const response = await fetch(`/api/projects/${projectId}/generate/outline/stream`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      ...(accessCode ? { 'X-Access-Code': accessCode } : {}),\n    },\n    body: JSON.stringify({ language: lang, lock_page_count: lockPageCount }),\n  });\n\n  if (!response.ok || !response.body) {\n    callbacks.onError(`HTTP ${response.status}`);\n    return;\n  }\n\n  const reader = response.body.getReader();\n  const decoder = new TextDecoder();\n  let buffer = '';\n\n  let readResult = await reader.read();\n  while (!readResult.done) {\n    const { value } = readResult;\n\n    buffer += decoder.decode(value, { stream: true });\n\n    // Parse SSE events from buffer\n    const parts = buffer.split('\\n\\n');\n    buffer = parts.pop() || '';\n\n    for (const part of parts) {\n      const lines = part.split('\\n');\n      let eventType = '';\n      let eventData = '';\n\n      for (const line of lines) {\n        if (line.startsWith('event: ')) eventType = line.slice(7);\n        else if (line.startsWith('data: ')) eventData = line.slice(6);\n      }\n\n      if (!eventType || !eventData) continue;\n\n      try {\n        const parsed = JSON.parse(eventData);\n        if (eventType === 'page') callbacks.onPage(parsed);\n        else if (eventType === 'done') callbacks.onDone(parsed);\n        else if (eventType === 'error') callbacks.onError(parsed.message);\n      } catch {\n        // Skip malformed events\n      }\n    }\n\n    readResult = await reader.read();\n  }\n};\n\n// ===== 描述生成 =====\n\n/**\n * 从描述文本生成大纲和页面描述（一次性完成）\n * @param projectId 项目ID\n * @param descriptionText 描述文本（可选）\n * @param language 输出语言（可选，默认从 sessionStorage 获取）\n */\nexport const generateFromDescription = async (projectId: string, descriptionText?: string, language?: OutputLanguage): Promise<ApiResponse> => {\n  const lang = language || await getStoredOutputLanguage();\n  const response = await apiClient.post<ApiResponse>(\n    `/api/projects/${projectId}/generate/from-description`,\n    { \n      ...(descriptionText ? { description_text: descriptionText } : {}),\n      language: lang \n    }\n  );\n  return response.data;\n};\n\n/**\n * 批量生成描述（并行模式）\n * @param projectId 项目ID\n * @param language 输出语言（可选，默认从 sessionStorage 获取）\n */\nexport const generateDescriptions = async (projectId: string, language?: OutputLanguage, detailLevel?: string): Promise<ApiResponse> => {\n  const lang = language || await getStoredOutputLanguage();\n  const response = await apiClient.post<ApiResponse>(\n    `/api/projects/${projectId}/generate/descriptions`,\n    { language: lang, detail_level: detailLevel || 'default' }\n  );\n  return response.data;\n};\n\n/**\n * 流式生成描述（SSE）\n */\nexport interface DescriptionStreamEvent {\n  page_index: number;\n  page_id: string;\n  text: string;\n  extra_fields?: Record<string, string>;\n}\n\nexport interface DescriptionStreamCallbacks {\n  onDescription: (data: DescriptionStreamEvent) => void;\n  onDone: (data: { total: number; pages: Page[] }) => void;\n  onError: (message: string) => void;\n}\n\nexport const generateDescriptionsStream = async (\n  projectId: string,\n  callbacks: DescriptionStreamCallbacks,\n  language?: OutputLanguage,\n  detailLevel?: string,\n): Promise<void> => {\n  const lang = language || await getStoredOutputLanguage();\n  const accessCode = localStorage.getItem('banana-access-code');\n\n  const response = await fetch(`/api/projects/${projectId}/generate/descriptions/stream`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      ...(accessCode ? { 'X-Access-Code': accessCode } : {}),\n    },\n    body: JSON.stringify({ language: lang, detail_level: detailLevel || 'default' }),\n  });\n\n  if (!response.ok || !response.body) {\n    callbacks.onError(`HTTP ${response.status}`);\n    return;\n  }\n\n  const reader = response.body.getReader();\n  const decoder = new TextDecoder();\n  let buffer = '';\n\n  let readResult = await reader.read();\n  while (!readResult.done) {\n    const { value } = readResult;\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 lines = part.split('\\n');\n      let eventType = '';\n      let eventData = '';\n\n      for (const line of lines) {\n        if (line.startsWith('event: ')) eventType = line.slice(7);\n        else if (line.startsWith('data: ')) eventData = line.slice(6);\n      }\n\n      if (!eventType || !eventData) continue;\n\n      try {\n        const parsed = JSON.parse(eventData);\n        if (eventType === 'description') callbacks.onDescription(parsed);\n        else if (eventType === 'done') callbacks.onDone(parsed);\n        else if (eventType === 'error') callbacks.onError(parsed.message);\n      } catch {\n        // Skip malformed events\n      }\n    }\n\n    readResult = await reader.read();\n  }\n};\n\n/**\n * 生成单页描述\n */\nexport const generatePageDescription = async (\n  projectId: string,\n  pageId: string,\n  forceRegenerate: boolean = false,\n  language?: OutputLanguage,\n  detailLevel?: string\n): Promise<ApiResponse> => {\n  const lang = language || await getStoredOutputLanguage();\n  const response = await apiClient.post<ApiResponse>(\n    `/api/projects/${projectId}/pages/${pageId}/generate/description`,\n    { force_regenerate: forceRegenerate, language: lang, detail_level: detailLevel || 'default' }\n  );\n  return response.data;\n};\n\n/**\n * 重新生成 PPT 翻新项目的单页（重新解析原 PDF 并提取内容）\n */\nexport const regenerateRenovationPage = async (\n  projectId: string,\n  pageId: string,\n  keepLayout: boolean = false,\n  language?: OutputLanguage\n): Promise<ApiResponse> => {\n  const lang = language || await getStoredOutputLanguage();\n  const response = await apiClient.post<ApiResponse>(\n    `/api/projects/${projectId}/pages/${pageId}/regenerate-renovation`,\n    { keep_layout: keepLayout, language: lang }\n  );\n  return response.data;\n};\n\n/**\n * 根据用户要求修改大纲\n * @param projectId 项目ID\n * @param userRequirement 用户要求\n * @param previousRequirements 历史要求（可选）\n * @param language 输出语言（可选，默认从 sessionStorage 获取）\n */\nexport const refineOutline = async (\n  projectId: string,\n  userRequirement: string,\n  previousRequirements?: string[],\n  language?: OutputLanguage\n): Promise<ApiResponse<{ pages: Page[]; message: string }>> => {\n  const lang = language || await getStoredOutputLanguage();\n  const response = await apiClient.post<ApiResponse<{ pages: Page[]; message: string }>>(\n    `/api/projects/${projectId}/refine/outline`,\n    {\n      user_requirement: userRequirement,\n      previous_requirements: previousRequirements || [],\n      language: lang\n    }\n  );\n  return response.data;\n};\n\n/**\n * 根据用户要求修改页面描述\n * @param projectId 项目ID\n * @param userRequirement 用户要求\n * @param previousRequirements 历史要求（可选）\n * @param language 输出语言（可选，默认从 sessionStorage 获取）\n */\nexport const refineDescriptions = async (\n  projectId: string,\n  userRequirement: string,\n  previousRequirements?: string[],\n  language?: OutputLanguage\n): Promise<ApiResponse<{ pages: Page[]; message: string }>> => {\n  const lang = language || await getStoredOutputLanguage();\n  const response = await apiClient.post<ApiResponse<{ pages: Page[]; message: string }>>(\n    `/api/projects/${projectId}/refine/descriptions`,\n    {\n      user_requirement: userRequirement,\n      previous_requirements: previousRequirements || [],\n      language: lang\n    }\n  );\n  return response.data;\n};\n\n// ===== 图片生成 =====\n\n/**\n * 批量生成图片\n * @param projectId 项目ID\n * @param language 输出语言（可选，默认从 sessionStorage 获取）\n * @param pageIds 可选的页面ID列表，如果不提供则生成所有页面\n */\nexport const generateImages = async (projectId: string, language?: OutputLanguage, pageIds?: string[]): Promise<ApiResponse> => {\n  const lang = language || await getStoredOutputLanguage();\n  const response = await apiClient.post<ApiResponse>(\n    `/api/projects/${projectId}/generate/images`,\n    { language: lang, page_ids: pageIds }\n  );\n  return response.data;\n};\n\n/**\n * 生成单页图片\n */\nexport const generatePageImage = async (\n  projectId: string,\n  pageId: string,\n  forceRegenerate: boolean = false,\n  language?: OutputLanguage\n): Promise<ApiResponse> => {\n  const lang = language || await getStoredOutputLanguage();\n  const response = await apiClient.post<ApiResponse>(\n    `/api/projects/${projectId}/pages/${pageId}/generate/image`,\n    { force_regenerate: forceRegenerate, language: lang }\n  );\n  return response.data;\n};\n\n/**\n * 编辑图片（自然语言修改）\n */\nexport const editPageImage = async (\n  projectId: string,\n  pageId: string,\n  editPrompt: string,\n  contextImages?: {\n    useTemplate?: boolean;\n    descImageUrls?: string[];\n    uploadedFiles?: File[];\n  }\n): Promise<ApiResponse> => {\n  // 如果有上传的文件，使用 multipart/form-data\n  if (contextImages?.uploadedFiles && contextImages.uploadedFiles.length > 0) {\n    const formData = new FormData();\n    formData.append('edit_instruction', editPrompt);\n    formData.append('use_template', String(contextImages.useTemplate || false));\n    if (contextImages.descImageUrls && contextImages.descImageUrls.length > 0) {\n      formData.append('desc_image_urls', JSON.stringify(contextImages.descImageUrls));\n    }\n    // 添加上传的文件\n    contextImages.uploadedFiles.forEach((file) => {\n      formData.append('context_images', file);\n    });\n\n    const response = await apiClient.post<ApiResponse>(\n      `/api/projects/${projectId}/pages/${pageId}/edit/image`,\n      formData\n    );\n    return response.data;\n  } else {\n    // 使用 JSON\n    const response = await apiClient.post<ApiResponse>(\n      `/api/projects/${projectId}/pages/${pageId}/edit/image`,\n      {\n        edit_instruction: editPrompt,\n        context_images: {\n          use_template: contextImages?.useTemplate || false,\n          desc_image_urls: contextImages?.descImageUrls || [],\n        },\n      }\n    );\n    return response.data;\n  }\n};\n\n/**\n * 获取页面图片历史版本\n */\nexport const getPageImageVersions = async (\n  projectId: string,\n  pageId: string\n): Promise<ApiResponse<{ versions: any[] }>> => {\n  const response = await apiClient.get<ApiResponse<{ versions: any[] }>>(\n    `/api/projects/${projectId}/pages/${pageId}/image-versions`\n  );\n  return response.data;\n};\n\n/**\n * 设置当前使用的图片版本\n */\nexport const setCurrentImageVersion = async (\n  projectId: string,\n  pageId: string,\n  versionId: string\n): Promise<ApiResponse> => {\n  const response = await apiClient.post<ApiResponse>(\n    `/api/projects/${projectId}/pages/${pageId}/image-versions/${versionId}/set-current`\n  );\n  return response.data;\n};\n\n// ===== 页面操作 =====\n\n/**\n * 更新页面\n */\nexport const updatePage = async (\n  projectId: string,\n  pageId: string,\n  data: Partial<Page>\n): Promise<ApiResponse<Page>> => {\n  const response = await apiClient.put<ApiResponse<Page>>(\n    `/api/projects/${projectId}/pages/${pageId}`,\n    data\n  );\n  return response.data;\n};\n\n/**\n * 更新页面描述\n */\nexport const updatePageDescription = async (\n  projectId: string,\n  pageId: string,\n  descriptionContent: any,\n  language?: OutputLanguage\n): Promise<ApiResponse<Page>> => {\n  const lang = language || await getStoredOutputLanguage();\n  const response = await apiClient.put<ApiResponse<Page>>(\n    `/api/projects/${projectId}/pages/${pageId}/description`,\n    { description_content: descriptionContent, language: lang }\n  );\n  return response.data;\n};\n\n/**\n * 更新页面大纲\n */\nexport const updatePageOutline = async (\n  projectId: string,\n  pageId: string,\n  outlineContent: any,\n  language?: OutputLanguage\n): Promise<ApiResponse<Page>> => {\n  const lang = language || await getStoredOutputLanguage();\n  const response = await apiClient.put<ApiResponse<Page>>(\n    `/api/projects/${projectId}/pages/${pageId}/outline`,\n    { outline_content: outlineContent, language: lang }\n  );\n  return response.data;\n};\n\n/**\n * 删除页面\n */\nexport const deletePage = async (projectId: string, pageId: string): Promise<ApiResponse> => {\n  const response = await apiClient.delete<ApiResponse>(\n    `/api/projects/${projectId}/pages/${pageId}`\n  );\n  return response.data;\n};\n\n/**\n * 添加页面\n */\nexport const addPage = async (projectId: string, data: Partial<Page>): Promise<ApiResponse<Page>> => {\n  const response = await apiClient.post<ApiResponse<Page>>(\n    `/api/projects/${projectId}/pages`,\n    data\n  );\n  return response.data;\n};\n\n// ===== 任务查询 =====\n\n/**\n * 查询任务状态\n */\nexport const getTaskStatus = async (projectId: string, taskId: string): Promise<ApiResponse<Task>> => {\n  const response = await apiClient.get<ApiResponse<Task>>(`/api/projects/${projectId}/tasks/${taskId}`);\n  return response.data;\n};\n\n// ===== 导出 =====\n\n/**\n * Helper function to build query string with page_ids\n */\nconst buildPageIdsQuery = (pageIds?: string[]): string => {\n  if (!pageIds || pageIds.length === 0) return '';\n  const params = new URLSearchParams();\n  params.set('page_ids', pageIds.join(','));\n  return `?${params.toString()}`;\n};\n\n/**\n * 导出为PPTX\n * @param projectId 项目ID\n * @param pageIds 可选的页面ID列表，如果不提供则导出所有页面\n */\nexport const exportPPTX = async (\n  projectId: string,\n  pageIds?: string[]\n): Promise<ApiResponse<{ download_url: string; download_url_absolute?: string }>> => {\n  const url = `/api/projects/${projectId}/export/pptx${buildPageIdsQuery(pageIds)}`;\n  const response = await apiClient.get<\n    ApiResponse<{ download_url: string; download_url_absolute?: string }>\n  >(url);\n  return response.data;\n};\n\n/**\n * 导出为PDF\n * @param projectId 项目ID\n * @param pageIds 可选的页面ID列表，如果不提供则导出所有页面\n */\nexport const exportPDF = async (\n  projectId: string,\n  pageIds?: string[]\n): Promise<ApiResponse<{ download_url: string; download_url_absolute?: string }>> => {\n  const url = `/api/projects/${projectId}/export/pdf${buildPageIdsQuery(pageIds)}`;\n  const response = await apiClient.get<\n    ApiResponse<{ download_url: string; download_url_absolute?: string }>\n  >(url);\n  return response.data;\n};\n\n/**\n * 导出为图片（单张直接下载，多张打包ZIP）\n */\nexport const exportImages = async (\n  projectId: string,\n  pageIds?: string[]\n): Promise<ApiResponse<{ download_url: string; download_url_absolute?: string }>> => {\n  const url = `/api/projects/${projectId}/export/images${buildPageIdsQuery(pageIds)}`;\n  const response = await apiClient.get<\n    ApiResponse<{ download_url: string; download_url_absolute?: string }>\n  >(url);\n  return response.data;\n};\n\n/**\n * 导出为可编辑PPTX（异步任务）\n * @param projectId 项目ID\n * @param filename 可选的文件名\n * @param pageIds 可选的页面ID列表，如果不提供则导出所有页面\n */\nexport const exportEditablePPTX = async (\n  projectId: string,\n  filename?: string,\n  pageIds?: string[]\n): Promise<ApiResponse<{ task_id: string }>> => {\n  const response = await apiClient.post<\n    ApiResponse<{ task_id: string }>\n  >(`/api/projects/${projectId}/export/editable-pptx`, {\n    filename,\n    page_ids: pageIds\n  });\n  return response.data;\n};\n\n// ===== 素材生成 =====\n\n/**\n * 生成单张素材图片（不绑定具体页面）\n * 现在返回异步任务ID，需要通过getTaskStatus轮询获取结果\n */\nexport const generateMaterialImage = async (\n  projectId: string,\n  prompt: string,\n  refImage?: File | null,\n  extraImages?: File[],\n  aspectRatio?: string\n): Promise<ApiResponse<{ task_id: string; status: string }>> => {\n  const formData = new FormData();\n  formData.append('prompt', prompt);\n  if (aspectRatio) {\n    formData.append('aspect_ratio', aspectRatio);\n  }\n  if (refImage) {\n    formData.append('ref_image', refImage);\n  }\n\n  if (extraImages && extraImages.length > 0) {\n    extraImages.forEach((file) => {\n      formData.append('extra_images', file);\n    });\n  }\n\n  const response = await apiClient.post<ApiResponse<{ task_id: string; status: string }>>(\n    `/api/projects/${projectId}/materials/generate`,\n    formData\n  );\n  return response.data;\n};\n\n/**\n * 素材信息接口\n */\nexport interface Material {\n  id: string;\n  project_id?: string | null;\n  filename: string;\n  url: string;\n  relative_path: string;\n  created_at: string;\n  // 可选的附加信息：用于展示友好名称\n  prompt?: string;\n  original_filename?: string;\n  source_filename?: string;\n  name?: string;\n}\n\n/**\n * 获取素材列表\n * @param projectId 项目ID，可选\n *   - If provided and not 'all' or 'none': Get materials for specific project via /api/projects/{projectId}/materials\n *   - If 'all': Get all materials via /api/materials?project_id=all\n *   - If 'none': Get global materials (not bound to any project) via /api/materials?project_id=none\n *   - If not provided: Get all materials via /api/materials\n */\nexport const listMaterials = async (\n  projectId?: string\n): Promise<ApiResponse<{ materials: Material[]; count: number }>> => {\n  let url: string;\n\n  if (!projectId || projectId === 'all') {\n    // Get all materials using global endpoint\n    url = '/api/materials?project_id=all';\n  } else if (projectId === 'none') {\n    // Get global materials (not bound to any project)\n    url = '/api/materials?project_id=none';\n  } else {\n    // Get materials for specific project\n    url = `/api/projects/${projectId}/materials`;\n  }\n\n  const response = await apiClient.get<ApiResponse<{ materials: Material[]; count: number }>>(url);\n  return response.data;\n};\n\n/**\n * 上传素材图片\n * @param file 图片文件\n * @param projectId 可选的项目ID\n *   - If provided: Upload material bound to the project\n *   - If not provided or 'none': Upload as global material (not bound to any project)\n */\nexport const uploadMaterial = async (\n  file: File,\n  projectId?: string | null,\n  generateCaption?: boolean\n): Promise<ApiResponse<Material & { caption?: string }>> => {\n  const formData = new FormData();\n  formData.append('file', file);\n\n  let url: string;\n  if (!projectId || projectId === 'none') {\n    // Use global upload endpoint for materials not bound to any project\n    url = '/api/materials/upload';\n  } else {\n    // Use project-specific upload endpoint\n    url = `/api/projects/${projectId}/materials/upload`;\n  }\n\n  if (generateCaption) {\n    url += (url.includes('?') ? '&' : '?') + 'generate_caption=true';\n  }\n\n  const response = await apiClient.post<ApiResponse<Material & { caption?: string }>>(url, formData);\n  return response.data;\n};\n\n/**\n * 删除素材\n */\nexport const deleteMaterial = async (materialId: string): Promise<ApiResponse<{ id: string }>> => {\n  const response = await apiClient.delete<ApiResponse<{ id: string }>>(`/api/materials/${materialId}`);\n  return response.data;\n};\n\n/**\n * Download selected materials bundled as a zip archive.\n */\nexport const downloadMaterialsZip = async (\n  materialIds: string[]\n): Promise<ApiResponse<{ download_url: string }>> => {\n  const { data: blob } = await apiClient.post<Blob>(\n    '/api/materials/download',\n    { material_ids: materialIds },\n    { responseType: 'blob' },\n  );\n\n  const href = URL.createObjectURL(blob);\n  const link = Object.assign(document.createElement('a'), {\n    href,\n    download: 'materials.zip',\n  });\n  document.body.appendChild(link);\n  link.click();\n  document.body.removeChild(link);\n  URL.revokeObjectURL(href);\n\n  return { success: true, data: { download_url: '' } };\n};\n\n/**\n * 关联素材到项目（通过URL）\n * @param projectId 项目ID\n * @param materialUrls 素材URL列表\n */\nexport const associateMaterialsToProject = async (\n  projectId: string,\n  materialUrls: string[]\n): Promise<ApiResponse<{ updated_ids: string[]; count: number }>> => {\n  const response = await apiClient.post<ApiResponse<{ updated_ids: string[]; count: number }>>(\n    '/api/materials/associate',\n    { project_id: projectId, material_urls: materialUrls }\n  );\n  return response.data;\n};\n\n// ===== 用户模板 =====\n\nexport interface UserTemplate {\n  template_id: string;\n  name?: string;\n  template_image_url: string;\n  thumb_url?: string;  // Thumbnail URL for faster loading\n  created_at?: string;\n  updated_at?: string;\n}\n\n/**\n * 上传用户模板\n */\nexport const uploadUserTemplate = async (\n  templateImage: File,\n  name?: string\n): Promise<ApiResponse<UserTemplate>> => {\n  const formData = new FormData();\n  formData.append('template_image', templateImage);\n  if (name) {\n    formData.append('name', name);\n  }\n\n  const response = await apiClient.post<ApiResponse<UserTemplate>>(\n    '/api/user-templates',\n    formData\n  );\n  return response.data;\n};\n\n/**\n * 获取用户模板列表\n */\nexport const listUserTemplates = async (): Promise<ApiResponse<{ templates: UserTemplate[] }>> => {\n  const response = await apiClient.get<ApiResponse<{ templates: UserTemplate[] }>>(\n    '/api/user-templates'\n  );\n  return response.data;\n};\n\n/**\n * 删除用户模板\n */\nexport const deleteUserTemplate = async (templateId: string): Promise<ApiResponse> => {\n  const response = await apiClient.delete<ApiResponse>(`/api/user-templates/${templateId}`);\n  return response.data;\n};\n\n// ===== 参考文件相关 API =====\n\nexport interface ReferenceFile {\n  id: string;\n  project_id: string | null;\n  filename: string;\n  file_size: number;\n  file_type: string;\n  parse_status: 'pending' | 'parsing' | 'completed' | 'failed';\n  markdown_content: string | null;\n  error_message: string | null;\n  image_caption_failed_count?: number;  // Optional, calculated dynamically\n  created_at: string;\n  updated_at: string;\n}\n\n/**\n * 上传参考文件\n * @param file 文件\n * @param projectId 可选的项目ID（如果不提供或为'none'，则为全局文件）\n */\nexport const uploadReferenceFile = async (\n  file: File,\n  projectId?: string | null\n): Promise<ApiResponse<{ file: ReferenceFile }>> => {\n  const formData = new FormData();\n  formData.append('file', file);\n  if (projectId && projectId !== 'none') {\n    formData.append('project_id', projectId);\n  }\n\n  const response = await apiClient.post<ApiResponse<{ file: ReferenceFile }>>(\n    '/api/reference-files/upload',\n    formData\n  );\n  return response.data;\n};\n\n/**\n * 获取参考文件信息\n * @param fileId 文件ID\n */\nexport const getReferenceFile = async (fileId: string): Promise<ApiResponse<{ file: ReferenceFile }>> => {\n  const response = await apiClient.get<ApiResponse<{ file: ReferenceFile }>>(\n    `/api/reference-files/${fileId}`\n  );\n  return response.data;\n};\n\n/**\n * 列出项目的参考文件\n * @param projectId 项目ID（'global' 或 'none' 表示列出全局文件）\n */\nexport const listProjectReferenceFiles = async (\n  projectId: string\n): Promise<ApiResponse<{ files: ReferenceFile[] }>> => {\n  const response = await apiClient.get<ApiResponse<{ files: ReferenceFile[] }>>(\n    `/api/reference-files/project/${projectId}`\n  );\n  return response.data;\n};\n\n/**\n * 删除参考文件\n * @param fileId 文件ID\n */\nexport const deleteReferenceFile = async (fileId: string): Promise<ApiResponse<{ message: string }>> => {\n  const response = await apiClient.delete<ApiResponse<{ message: string }>>(\n    `/api/reference-files/${fileId}`\n  );\n  return response.data;\n};\n\n/**\n * 触发文件解析\n * @param fileId 文件ID\n */\nexport const triggerFileParse = async (fileId: string): Promise<ApiResponse<{ file: ReferenceFile; message: string }>> => {\n  const response = await apiClient.post<ApiResponse<{ file: ReferenceFile; message: string }>>(\n    `/api/reference-files/${fileId}/parse`\n  );\n  return response.data;\n};\n\n/**\n * 将参考文件关联到项目\n * @param fileId 文件ID\n * @param projectId 项目ID\n */\nexport const associateFileToProject = async (\n  fileId: string,\n  projectId: string\n): Promise<ApiResponse<{ file: ReferenceFile }>> => {\n  const response = await apiClient.post<ApiResponse<{ file: ReferenceFile }>>(\n    `/api/reference-files/${fileId}/associate`,\n    { project_id: projectId }\n  );\n  return response.data;\n};\n\n/**\n * 从项目中移除参考文件（不删除文件本身）\n * @param fileId 文件ID\n */\nexport const dissociateFileFromProject = async (\n  fileId: string\n): Promise<ApiResponse<{ file: ReferenceFile; message: string }>> => {\n  const response = await apiClient.post<ApiResponse<{ file: ReferenceFile; message: string }>>(\n    `/api/reference-files/${fileId}/dissociate`\n  );\n  return response.data;\n};\n\n// ===== 输出语言设置 =====\n\nexport type OutputLanguage = 'zh' | 'ja' | 'en' | 'auto';\n\nexport interface OutputLanguageOption {\n  value: OutputLanguage;\n  label: string;\n}\n\nexport const OUTPUT_LANGUAGE_OPTIONS: OutputLanguageOption[] = [\n  { value: 'zh', label: '中文' },\n  { value: 'ja', label: '日本語' },\n  { value: 'en', label: 'English' },\n  { value: 'auto', label: '自动' },\n];\n\n/**\n * 获取默认输出语言设置（从服务器环境变量读取）\n *\n * 注意：这只返回服务器配置的默认语言。\n * 实际的语言选择应由前端在 sessionStorage 中管理，\n * 并在每次生成请求时通过 language 参数传递。\n */\nexport const getDefaultOutputLanguage = async (): Promise<ApiResponse<{ language: OutputLanguage }>> => {\n  const response = await apiClient.get<ApiResponse<{ language: OutputLanguage }>>(\n    '/api/output-language'\n  );\n  return response.data;\n};\n\n/**\n * 从后端 Settings 获取用户的输出语言偏好\n * 如果获取失败，返回默认值 'zh'\n */\nexport const getStoredOutputLanguage = async (): Promise<OutputLanguage> => {\n  try {\n    const response = await apiClient.get<ApiResponse<{ language: OutputLanguage }>>('/api/output-language');\n    return response.data.data?.language || 'zh';\n  } catch (error) {\n    console.warn('Failed to load output language from settings, using default', error);\n    return 'zh';\n  }\n};\n\n/**\n * 获取系统设置\n */\nexport const getSettings = async (): Promise<ApiResponse<Settings>> => {\n  const response = await apiClient.get<ApiResponse<Settings>>('/api/settings');\n  return response.data;\n};\n\n/**\n * 更新系统设置\n */\nexport const updateSettings = async (\n  data: Partial<Omit<Settings, 'id' | 'api_key_length' | 'mineru_token_length' | 'baidu_api_key_length' | 'created_at' | 'updated_at'>> & { \n    api_key?: string;\n    mineru_token?: string;\n    baidu_api_key?: string;\n    text_api_key?: string;\n    image_api_key?: string;\n    image_caption_api_key?: string;\n    lazyllm_api_keys?: Record<string, string>;\n  }\n): Promise<ApiResponse<Settings>> => {\n  const response = await apiClient.put<ApiResponse<Settings>>('/api/settings', data);\n  return response.data;\n};\n\n/**\n * 重置系统设置\n */\nexport const resetSettings = async (): Promise<ApiResponse<Settings>> => {\n  const response = await apiClient.post<ApiResponse<Settings>>('/api/settings/reset');\n  return response.data;\n};\n\n/**\n * 验证 API key 是否可用\n */\nexport const verifyApiKey = async (): Promise<ApiResponse<{ available: boolean; message: string }>> => {\n  const response = await apiClient.post<ApiResponse<{ available: boolean; message: string }>>('/api/settings/verify');\n  return response.data;\n};\n\n/**\n * 可选的测试设置类型\n */\nexport interface TestSettingsOverride {\n  api_key?: string;\n  api_base_url?: string;\n  text_model?: string;\n  image_model?: string;\n  image_caption_model?: string;\n  image_caption_model_source?: string;\n  mineru_api_base?: string;\n  mineru_token?: string;\n  baidu_api_key?: string;\n  ai_provider_format?: string;\n  image_resolution?: string;\n  enable_text_reasoning?: boolean;\n  text_thinking_budget?: number;\n  enable_image_reasoning?: boolean;\n  image_thinking_budget?: number;\n}\n\n/**\n * 测试百度 OCR 服务（异步）\n * @param settings 可选的设置覆盖（未保存的设置）\n * @returns 返回任务ID，需要通过 getTestStatus 轮询结果\n */\nexport const testBaiduOcr = async (settings?: TestSettingsOverride): Promise<ApiResponse<{ task_id: string; status: string }>> => {\n  const response = await apiClient.post<ApiResponse<{ task_id: string; status: string }>>('/api/settings/tests/baidu-ocr', settings || {});\n  return response.data;\n};\n\n/**\n * 测试文本生成模型（异步）\n * @param settings 可选的设置覆盖（未保存的设置）\n * @returns 返回任务ID，需要通过 getTestStatus 轮询结果\n */\nexport const testTextModel = async (settings?: TestSettingsOverride): Promise<ApiResponse<{ task_id: string; status: string }>> => {\n  const response = await apiClient.post<ApiResponse<{ task_id: string; status: string }>>('/api/settings/tests/text-model', settings || {});\n  return response.data;\n};\n\n/**\n * 测试图片识别模型（异步）\n * @param settings 可选的设置覆盖（未保存的设置）\n * @returns 返回任务ID，需要通过 getTestStatus 轮询结果\n */\nexport const testCaptionModel = async (settings?: TestSettingsOverride): Promise<ApiResponse<{ task_id: string; status: string }>> => {\n  const response = await apiClient.post<ApiResponse<{ task_id: string; status: string }>>('/api/settings/tests/caption-model', settings || {});\n  return response.data;\n};\n\n/**\n * 测试百度图像修复（异步）\n * @param settings 可选的设置覆盖（未保存的设置）\n * @returns 返回任务ID，需要通过 getTestStatus 轮询结果\n */\nexport const testBaiduInpaint = async (settings?: TestSettingsOverride): Promise<ApiResponse<{ task_id: string; status: string }>> => {\n  const response = await apiClient.post<ApiResponse<{ task_id: string; status: string }>>('/api/settings/tests/baidu-inpaint', settings || {});\n  return response.data;\n};\n\n/**\n * 测试图像生成模型（异步）\n * @param settings 可选的设置覆盖（未保存的设置）\n * @returns 返回任务ID，需要通过 getTestStatus 轮询结果\n */\nexport const testImageModel = async (settings?: TestSettingsOverride): Promise<ApiResponse<{ task_id: string; status: string }>> => {\n  const response = await apiClient.post<ApiResponse<{ task_id: string; status: string }>>('/api/settings/tests/image-model', settings || {});\n  return response.data;\n};\n\n/**\n * 测试 MinerU PDF 解析（异步）\n * @param settings 可选的设置覆盖（未保存的设置）\n * @returns 返回任务ID，需要通过 getTestStatus 轮询结果\n */\nexport const testMineruPdf = async (settings?: TestSettingsOverride): Promise<ApiResponse<{ task_id: string; status: string }>> => {\n  const response = await apiClient.post<ApiResponse<{ task_id: string; status: string }>>('/api/settings/tests/mineru-pdf', settings || {});\n  return response.data;\n};\n\n/**\n * 查询测试任务状态\n * @param taskId 任务ID\n * @returns 任务状态信息\n */\nexport const getTestStatus = async (taskId: string): Promise<ApiResponse<{\n  status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';\n  result?: any;\n  error?: string;\n  message?: string;\n}>> => {\n  const response = await apiClient.get<ApiResponse<any>>(`/api/settings/tests/${taskId}/status`);\n  return response.data;\n};\n\n\n// ===== PPT 翻新相关 API =====\n\n/**\n * 创建 PPT 翻新项目\n * 上传 PDF/PPTX 文件，后端异步解析内容并填充大纲+描述\n */\nexport const createPptRenovationProject = async (\n  file: File,\n  options?: {\n    keepLayout?: boolean;\n    templateStyle?: string;\n    language?: string;\n  }\n): Promise<ApiResponse<{ project_id: string; task_id: string; page_count: number }>> => {\n  const formData = new FormData();\n  formData.append('file', file);\n  if (options?.keepLayout) {\n    formData.append('keep_layout', 'true');\n  }\n  if (options?.templateStyle) {\n    formData.append('template_style', options.templateStyle);\n  }\n  if (options?.language) {\n    formData.append('language', options.language);\n  }\n\n  const response = await apiClient.post<ApiResponse<{ project_id: string; task_id: string; page_count: number }>>(\n    '/api/projects/renovation',\n    formData\n  );\n  return response.data;\n};\n\n/**\n * 从图片提取风格描述（通用，不绑定项目）\n */\nexport const extractStyleFromImage = async (\n  imageFile: File\n): Promise<ApiResponse<{ style_description: string }>> => {\n  const formData = new FormData();\n  formData.append('image', imageFile);\n\n  const response = await apiClient.post<ApiResponse<{ style_description: string }>>(\n    '/api/extract-style',\n    formData\n  );\n  return response.data;\n};\n"
  },
  {
    "path": "frontend/src/components/history/ProjectCard.tsx",
    "content": "import React, { useState, useEffect } from 'react';\nimport { Clock, FileText, ChevronRight, Trash2 } from 'lucide-react';\nimport { useT } from '@/hooks/useT';\nimport { Card } from '@/components/shared';\nimport { getProjectTitle, getFirstPageImage, formatDate, getStatusText, getStatusColor } from '@/utils/projectUtils';\nimport type { Project } from '@/types';\n\n// ProjectCard 组件自包含翻译\nconst projectCardI18n = {\n  zh: {\n    projectCard: { pages: \"{{count}} 页\", page: \"第 {{num}} 页\" }\n  },\n  en: {\n    projectCard: { pages: \"{{count}} pages\", page: \"Page {{num}}\" }\n  }\n};\n\nexport interface ProjectCardProps {\n  project: Project;\n  isSelected: boolean;\n  isEditing: boolean;\n  editingTitle: string;\n  onSelect: (project: Project) => void;\n  onToggleSelect: (projectId: string) => void;\n  onDelete: (e: React.MouseEvent, project: Project) => void;\n  onStartEdit: (e: React.MouseEvent, project: Project) => void;\n  onTitleChange: (title: string) => void;\n  onTitleKeyDown: (e: React.KeyboardEvent, projectId: string) => void;\n  onSaveEdit: (projectId: string) => void;\n  isBatchMode: boolean;\n}\n\nexport const ProjectCard: React.FC<ProjectCardProps> = ({\n  project,\n  isSelected,\n  isEditing,\n  editingTitle,\n  onSelect,\n  onToggleSelect,\n  onDelete,\n  onStartEdit,\n  onTitleChange,\n  onTitleKeyDown,\n  onSaveEdit,\n  isBatchMode,\n}) => {\n  const t = useT(projectCardI18n);\n  // 检测屏幕尺寸，只在非手机端加载图片（必须在早期返回之前声明hooks）\n  const [shouldLoadImage, setShouldLoadImage] = useState(false);\n  \n  useEffect(() => {\n    const checkScreenSize = () => {\n      // sm breakpoint is 640px\n      setShouldLoadImage(window.innerWidth >= 640);\n    };\n    \n    checkScreenSize();\n    window.addEventListener('resize', checkScreenSize);\n    \n    return () => window.removeEventListener('resize', checkScreenSize);\n  }, []);\n\n  const projectId = project.id || project.project_id;\n  if (!projectId) return null;\n\n  const title = getProjectTitle(project);\n  const pageCount = project.pages?.length || 0;\n  const statusText = getStatusText(project);\n  const statusColor = getStatusColor(project);\n  \n  const firstPageImage = shouldLoadImage ? getFirstPageImage(project) : null;\n\n  return (\n    <Card\n      className={`p-3 md:p-6 transition-all ${\n        isSelected \n          ? 'border-2 border-banana-500 bg-banana-50 dark:bg-background-secondary' \n          : 'hover:shadow-lg border border-gray-200 dark:border-border-primary'\n      } ${isBatchMode ? 'cursor-default' : 'cursor-pointer'}`}\n      onClick={() => onSelect(project)}\n    >\n      <div className=\"flex items-start gap-3 md:gap-4\">\n        {/* 复选框 */}\n        <div className=\"pt-1 flex-shrink-0\" onClick={(e) => e.stopPropagation()}>\n          <input\n            type=\"checkbox\"\n            checked={isSelected}\n            onChange={() => onToggleSelect(projectId)}\n            className=\"w-4 h-4 text-banana-600 border-gray-300 dark:border-border-primary rounded focus:ring-banana-500 cursor-pointer\"\n          />\n        </div>\n        \n        {/* 中间：项目信息 */}\n        <div className=\"flex-1 min-w-0\">\n          <div className=\"flex items-center gap-2 md:gap-3 mb-2 flex-wrap\">\n            {isEditing ? (\n              <input\n                type=\"text\"\n                value={editingTitle}\n                onChange={(e) => onTitleChange(e.target.value)}\n                onKeyDown={(e) => onTitleKeyDown(e, projectId)}\n                onBlur={() => onSaveEdit(projectId)}\n                autoFocus\n                className=\"text-base md:text-lg font-semibold text-gray-900 dark:text-foreground-primary px-2 py-1 border border-banana-500 rounded focus:outline-none focus:ring-2 focus:ring-banana-500 flex-1 min-w-0\"\n                onClick={(e) => e.stopPropagation()}\n              />\n            ) : (\n              <h3 \n                className={`text-base md:text-lg font-semibold text-gray-900 dark:text-foreground-primary truncate flex-1 min-w-0 ${\n                  isBatchMode \n                    ? 'cursor-default' \n                    : 'cursor-pointer hover:text-banana-600 transition-colors'\n                }`}\n                onClick={(e) => onStartEdit(e, project)}\n                title={isBatchMode ? undefined : t('common.edit')}\n              >\n                {title}\n              </h3>\n            )}\n            <span className={`px-2 py-1 rounded text-xs font-medium whitespace-nowrap flex-shrink-0 ${statusColor}`}>\n              {statusText}\n            </span>\n          </div>\n          <div className=\"flex items-center gap-3 md:gap-4 text-xs md:text-sm text-gray-500 dark:text-foreground-tertiary flex-wrap\">\n            <span className=\"flex items-center gap-1\">\n              <FileText size={14} />\n              {t('projectCard.pages', { count: pageCount })}\n            </span>\n            <span className=\"flex items-center gap-1\">\n              <Clock size={14} />\n              {formatDate(project.updated_at || project.created_at)}\n            </span>\n          </div>\n        </div>\n        \n        {/* 右侧：图片预览 */}\n        <div className=\"hidden sm:block w-40 h-24 md:w-64 md:h-36 rounded-lg overflow-hidden bg-gray-100 dark:bg-background-secondary border border-gray-200 dark:border-border-primary flex-shrink-0\">\n          {firstPageImage ? (\n            <img\n              src={firstPageImage}\n              alt={t('projectCard.page', { num: 1 })}\n              className=\"w-full h-full object-cover\"\n            />\n          ) : (\n            <div className=\"w-full h-full flex items-center justify-center text-gray-400\">\n              <FileText size={20} className=\"md:w-6 md:h-6\" />\n            </div>\n          )}\n        </div>\n        \n        {/* 右侧：操作按钮 */}\n        <div className=\"flex flex-col sm:flex-row items-center gap-2 sm:gap-3 flex-shrink-0\">\n          <button\n            onClick={(e) => onDelete(e, project)}\n            className=\"p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors\"\n            title={t('common.delete')}\n          >\n            <Trash2 size={16} className=\"md:w-[18px] md:h-[18px]\" />\n          </button>\n          <ChevronRight size={18} className=\"text-gray-400 md:w-5 md:h-5\" />\n        </div>\n      </div>\n    </Card>\n  );\n};\n\n"
  },
  {
    "path": "frontend/src/components/outline/OutlineCard.tsx",
    "content": "import React, { useState, useEffect, useRef, useCallback } from 'react';\nimport { GripVertical, Edit2, Trash2, Check, X } from 'lucide-react';\nimport { useT } from '@/hooks/useT';\nimport { useImagePaste } from '@/hooks/useImagePaste';\nimport { Card, useConfirm, Markdown, ShimmerOverlay } from '@/components/shared';\nimport { MarkdownTextarea, type MarkdownTextareaRef } from '@/components/shared/MarkdownTextarea';\nimport type { Page } from '@/types';\n\n// OutlineCard 组件自包含翻译\nconst outlineCardI18n = {\n  zh: {\n    outlineCard: {\n      page: \"第 {{num}} 页\", chapter: \"章节\", titleLabel: \"标题\",\n      keyPointsPlaceholder: \"要点（每行一个，支持粘贴图片）\", confirmDeletePage: \"确定要删除这一页吗？\",\n      confirmDeleteTitle: \"确认删除\",\n      uploadingImage: \"正在上传图片...\",\n      coverPage: \"封面\",\n      coverPageTooltip: \"第一页为封面页，通常包含标题和副标题\"\n    }\n  },\n  en: {\n    outlineCard: {\n      page: \"Page {{num}}\", chapter: \"Chapter\", titleLabel: \"Title\",\n      keyPointsPlaceholder: \"Key points (one per line, paste images supported)\", confirmDeletePage: \"Are you sure you want to delete this page?\",\n      confirmDeleteTitle: \"Confirm Delete\",\n      uploadingImage: \"Uploading image...\",\n      coverPage: \"Cover\",\n      coverPageTooltip: \"This is the cover page, usually containing the title and subtitle\"\n    }\n  }\n};\n\ninterface OutlineCardProps {\n  page: Page;\n  index: number;\n  projectId?: string;\n  showToast: (props: { message: string; type: 'success' | 'error' | 'info' | 'warning' }) => void;\n  onUpdate: (data: Partial<Page>) => void;\n  onDelete: () => void;\n  onClick: () => void;\n  isSelected: boolean;\n  dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;\n  isAiRefining?: boolean;\n}\n\nexport const OutlineCard: React.FC<OutlineCardProps> = ({\n  page,\n  index,\n  projectId,\n  showToast,\n  onUpdate,\n  onDelete,\n  onClick,\n  isSelected,\n  dragHandleProps,\n  isAiRefining = false,\n}) => {\n  const t = useT(outlineCardI18n);\n  const { confirm, ConfirmDialog } = useConfirm();\n  const outline = page.outline_content ?? { title: '', points: [] as string[] };\n  const [isEditing, setIsEditing] = useState(false);\n  const [editTitle, setEditTitle] = useState(outline.title);\n  const [editPoints, setEditPoints] = useState(outline.points.join('\\n'));\n  const [editPart, setEditPart] = useState(page.part || '');\n  const textareaRef = useRef<MarkdownTextareaRef>(null);\n\n  // Callback to insert at cursor position in the textarea\n  const insertAtCursor = useCallback((markdown: string) => {\n    textareaRef.current?.insertAtCursor(markdown);\n  }, []);\n\n  const { handlePaste, handleFiles, isUploading } = useImagePaste({\n    projectId,\n    setContent: setEditPoints,\n    showToast: showToast,\n    insertAtCursor,\n  });\n\n  // 当 page prop 变化时，同步更新本地编辑状态（如果不在编辑模式）\n  useEffect(() => {\n    if (!isEditing) {\n      setEditTitle(outline.title);\n      setEditPoints(outline.points.join('\\n'));\n      setEditPart(page.part || '');\n    }\n  }, [outline.title, outline.points, page.part, isEditing]);\n\n  const handleSave = () => {\n    onUpdate({\n      outline_content: {\n        title: editTitle,\n        points: editPoints.split('\\n').filter((p) => p.trim()),\n      },\n      part: editPart.trim() || undefined,\n    });\n    setIsEditing(false);\n  };\n\n  const handleCancel = () => {\n    setEditTitle(outline.title);\n    setEditPoints(outline.points.join('\\n'));\n    setEditPart(page.part || '');\n    setIsEditing(false);\n  };\n\n  return (\n    <Card\n      className={`p-4 relative ${\n        isSelected ? 'border-2 border-banana-500 shadow-yellow' : ''\n      }`}\n      onClick={!isEditing ? onClick : undefined}\n    >\n      <ShimmerOverlay show={isAiRefining} />\n\n      <div className=\"flex items-start gap-3 relative z-10\">\n        {/* 拖拽手柄 */}\n        <div\n          {...dragHandleProps}\n          className=\"flex-shrink-0 cursor-move text-gray-400 hover:text-gray-600 pt-1\"\n        >\n          <GripVertical size={20} />\n        </div>\n\n        {/* 内容区 */}\n        <div className=\"flex-1 min-w-0\">\n          {/* 页码和章节 */}\n          <div className=\"flex items-center gap-2 mb-2\">\n            <span className=\"text-sm font-semibold text-gray-900 dark:text-foreground-primary\">\n              {t('outlineCard.page', { num: index + 1 })}\n            </span>\n            {index === 0 && !isEditing && (\n              <span\n                className=\"text-xs px-1.5 py-0.5 bg-banana-100 dark:bg-banana-900/30 text-banana-700 dark:text-banana-400 rounded\"\n                title={t('outlineCard.coverPageTooltip')}\n              >\n                {t('outlineCard.coverPage')}\n              </span>\n            )}\n            {isEditing ? (\n              <input\n                type=\"text\"\n                value={editPart}\n                onChange={(e) => setEditPart(e.target.value)}\n                onClick={(e) => e.stopPropagation()}\n                className=\"text-xs px-2 py-0.5 w-24 border border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded focus:outline-none focus:ring-1 focus:ring-blue-500\"\n                placeholder={t('outlineCard.chapter')}\n              />\n            ) : (\n              page.part && (\n                <span className=\"text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded\">\n                  {page.part}\n                </span>\n              )\n            )}\n          </div>\n\n          {isEditing ? (\n            /* 编辑模式 */\n            <div className=\"space-y-3\" onClick={(e) => e.stopPropagation()}>\n              <input\n                type=\"text\"\n                value={editTitle}\n                onChange={(e) => setEditTitle(e.target.value)}\n                className=\"w-full px-3 py-2 border border-gray-300 dark:border-border-primary bg-white dark:bg-background-secondary text-gray-900 dark:text-foreground-primary rounded-lg focus:outline-none focus:ring-2 focus:ring-banana-500\"\n                placeholder={t('outlineCard.titleLabel')}\n              />\n              <div>\n                <MarkdownTextarea\n                  ref={textareaRef}\n                  value={editPoints}\n                  onChange={setEditPoints}\n                  onPaste={handlePaste}\n                  onFiles={handleFiles}\n                  rows={5}\n                  placeholder={t('outlineCard.keyPointsPlaceholder')}\n                />\n              </div>\n              <div className=\"flex justify-end gap-2\">\n                <button\n                  onClick={handleCancel}\n                  className=\"px-3 py-1.5 text-sm text-gray-700 dark:text-foreground-secondary hover:bg-gray-100 dark:hover:bg-background-hover rounded-lg transition-colors\"\n                >\n                  <X size={16} className=\"inline mr-1\" />\n                  {t('common.cancel')}\n                </button>\n                <button\n                  onClick={handleSave}\n                  disabled={isUploading}\n                  className=\"px-3 py-1.5 text-sm bg-banana-500 text-black dark:text-white rounded-lg hover:bg-banana-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed\"\n                >\n                  <Check size={16} className=\"inline mr-1\" />\n                  {t('common.save')}\n                </button>\n              </div>\n            </div>\n          ) : (\n            /* 查看模式 */\n            <div>\n              <h4 className=\"font-semibold text-gray-900 dark:text-foreground-primary mb-2\">\n                {outline.title}\n              </h4>\n              <div className=\"text-gray-600 dark:text-foreground-tertiary\">\n                <Markdown>{outline.points.join('\\n')}</Markdown>\n              </div>\n            </div>\n          )}\n        </div>\n\n        {/* 操作按钮 */}\n        {!isEditing && (\n          <div className=\"flex-shrink-0 flex gap-2\">\n            <button\n              onClick={(e) => {\n                e.stopPropagation();\n                setIsEditing(true);\n              }}\n              className=\"p-1.5 text-gray-500 dark:text-foreground-tertiary hover:text-banana-600 hover:bg-banana-50 dark:hover:bg-background-hover rounded transition-colors\"\n            >\n              <Edit2 size={16} />\n            </button>\n            <button\n              onClick={(e) => {\n                e.stopPropagation();\n                confirm(\n                  t('outlineCard.confirmDeletePage'),\n                  onDelete,\n                  { title: t('outlineCard.confirmDeleteTitle'), variant: 'danger' }\n                );\n              }}\n              className=\"p-1.5 text-gray-500 dark:text-foreground-tertiary hover:text-red-600 hover:bg-red-50 rounded transition-colors\"\n            >\n              <Trash2 size={16} />\n            </button>\n          </div>\n        )}\n      </div>\n      {ConfirmDialog}\n    </Card>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/preview/DescriptionCard.tsx",
    "content": "import React, { useState, useRef, useCallback } from 'react';\nimport { Edit2, FileText, RefreshCw, Tag, Layout, Image, Focus, MessageSquare, ImageOff } from 'lucide-react';\nimport { useT } from '@/hooks/useT';\nimport { useImagePaste } from '@/hooks/useImagePaste';\nimport { Card, ContextualStatusBadge, Button, Modal, Skeleton, Markdown } from '@/components/shared';\nimport { MarkdownTextarea, type MarkdownTextareaRef } from '@/components/shared/MarkdownTextarea';\nimport { useDescriptionGeneratingState } from '@/hooks/useGeneratingState';\nimport type { Page, DescriptionContent } from '@/types';\n\n// DescriptionCard 组件自包含翻译\nconst descriptionCardI18n = {\n  zh: {\n    descriptionCard: {\n      page: \"第 {{num}} 页\", regenerate: \"重新生成\",\n      descriptionTitle: \"编辑页面描述\", description: \"描述\",\n      noDescription: \"还没有生成描述\",\n      uploadingImage: \"正在上传图片...\",\n      descriptionPlaceholder: \"输入页面描述, 可包含页面文字、素材、排版设计等信息，支持粘贴图片\",\n      coverPage: \"封面\",\n      coverPageTooltip: \"第一页为封面页，默认保持简洁风格\",\n      notInImagePrompt: \"不影响图片生成\"\n    }\n  },\n  en: {\n    descriptionCard: {\n      page: \"Page {{num}}\", regenerate: \"Regenerate\",\n      descriptionTitle: \"Edit Descriptions\", description: \"Description\",\n      noDescription: \"No description generated yet\",\n      uploadingImage: \"Uploading image...\",\n      descriptionPlaceholder: \"Enter page description, can include page text, materials, layout design, etc., support pasting images\",\n      coverPage: \"Cover\",\n      coverPageTooltip: \"This is the cover page, default to keep simple style\",\n      notInImagePrompt: \"Not used in image generation\"\n    }\n  }\n};\n\nexport interface DescriptionCardProps {\n  page: Page;\n  index: number;\n  projectId?: string;\n  extraFieldNames?: string[];\n  imagePromptFields?: string[];\n  showToast: (props: { message: string; type: 'success' | 'error' | 'info' | 'warning' }) => void;\n  onUpdate: (data: Partial<Page>) => void;\n  onRegenerate: () => void;\n  isAiRefining?: boolean;\n}\n\n// 从 description_content 提取文本内容（提取到组件外部供 memo 比较器使用）\nconst getDescriptionText = (descContent: DescriptionContent | undefined): string => {\n  if (!descContent) return '';\n  if ('text' in descContent) {\n    return descContent.text;\n  } else if ('text_content' in descContent && Array.isArray(descContent.text_content)) {\n    return descContent.text_content.join('\\n');\n  }\n  return '';\n};\n\n// 提取 extra_fields，向后兼容 layout_suggestion\nconst getExtraFields = (descContent: DescriptionContent | undefined): Record<string, string> => {\n  if (!descContent) return {};\n  if (descContent.extra_fields) return descContent.extra_fields;\n  // 向后兼容：旧数据只有 layout_suggestion\n  if (descContent.layout_suggestion) return { '排版建议': descContent.layout_suggestion };\n  return {};\n};\n\n// 用于 memo 比较的序列化 key\nconst getExtraFieldsKey = (descContent: DescriptionContent | undefined): string => {\n  const fields = getExtraFields(descContent);\n  return JSON.stringify(fields);\n};\n\nexport const DescriptionCard: React.FC<DescriptionCardProps> = React.memo(({\n  page,\n  index,\n  projectId,\n  extraFieldNames = [],\n  imagePromptFields,\n  showToast,\n  onUpdate,\n  onRegenerate,\n  isAiRefining = false,\n}) => {\n  const t = useT(descriptionCardI18n);\n\n  const text = getDescriptionText(page.description_content);\n  const extraFields = getExtraFields(page.description_content);\n\n  const [isEditing, setIsEditing] = useState(false);\n  const [editContent, setEditContent] = useState('');\n  const [editExtraFields, setEditExtraFields] = useState<Record<string, string>>({});\n  const textareaRef = useRef<MarkdownTextareaRef>(null);\n  const extraFieldRefs = useRef<Record<string, MarkdownTextareaRef | null>>({});\n\n  // Active field target for image paste — switched via onFocus\n  const activeSetContent = useRef<(updater: (prev: string) => string) => void>(setEditContent);\n  const activeInsertAtCursor = useRef<((markdown: string) => void) | undefined>(\n    () => textareaRef.current?.insertAtCursor('')\n  );\n\n  const { handlePaste, handleFiles, isUploading } = useImagePaste({\n    projectId,\n    setContent: (updater) => activeSetContent.current(updater),\n    showToast: showToast,\n    insertAtCursor: (md) => activeInsertAtCursor.current?.(md),\n  });\n\n  // Focus handlers to switch paste target\n  const focusMainDesc = useCallback(() => {\n    activeSetContent.current = setEditContent;\n    activeInsertAtCursor.current = (md: string) => textareaRef.current?.insertAtCursor(md);\n  }, []);\n\n  const focusExtraField = useCallback((fieldName: string) => {\n    activeSetContent.current = (updater) =>\n      setEditExtraFields(prev => ({ ...prev, [fieldName]: updater(prev[fieldName] || '') }));\n    activeInsertAtCursor.current = (md: string) => extraFieldRefs.current[fieldName]?.insertAtCursor(md);\n  }, []);\n\n  // 通过 page.status 驱动骨架屏，与图片生成的 GENERATING 状态互不干扰\n  const generating = useDescriptionGeneratingState(page, isAiRefining);\n\n  const handleEdit = () => {\n    // 在打开编辑对话框时，从当前的 page 获取最新值\n    const currentText = getDescriptionText(page.description_content);\n    const currentExtraFields = getExtraFields(page.description_content);\n    setEditContent(currentText);\n    setEditExtraFields({ ...currentExtraFields });\n    setIsEditing(true);\n  };\n\n  const handleSave = () => {\n    // 保存时包含 text 和 extra_fields\n    const filteredFields: Record<string, string> = {};\n    for (const [key, value] of Object.entries(editExtraFields)) {\n      if (value.trim()) {\n        filteredFields[key] = value;\n      }\n    }\n    onUpdate({\n      description_content: {\n        text: editContent,\n        ...(Object.keys(filteredFields).length > 0 ? { extra_fields: filteredFields } : {}),\n      } as DescriptionContent,\n    });\n    setIsEditing(false);\n  };\n\n  // 合并已有和配置中的字段名（按配置顺序，附加已有但不在配置中的）\n  const allFieldNames = [...new Set([...extraFieldNames, ...Object.keys(extraFields)])];\n\n  return (\n    <>\n      <Card className=\"p-0 overflow-hidden flex flex-col\">\n        {/* 标题栏 */}\n        <div className=\"bg-banana-50 dark:bg-background-hover px-4 py-3 border-b border-gray-100 dark:border-border-primary\">\n          <div className=\"flex items-center justify-between\">\n            <div className=\"flex items-center gap-2\">\n              <span className=\"font-semibold text-gray-900 dark:text-foreground-primary\">{t('descriptionCard.page', { num: index + 1 })}</span>\n              {index === 0 && (\n                <span\n                  className=\"text-xs px-1.5 py-0.5 bg-banana-100 dark:bg-banana-900/30 text-banana-700 dark:text-banana-400 rounded\"\n                  title={t('descriptionCard.coverPageTooltip')}\n                >\n                  {t('descriptionCard.coverPage')}\n                </span>\n              )}\n              {page.part && (\n                <span className=\"text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded\">\n                  {page.part}\n                </span>\n              )}\n            </div>\n            <ContextualStatusBadge page={page} context=\"description\" />\n          </div>\n        </div>\n\n        {/* 内容 */}\n        <div className=\"p-4 flex-1 max-h-96 overflow-y-auto desc-card-scroll\" data-testid=\"description-card-content\">\n          {generating ? (\n            <div className=\"space-y-2\">\n              <Skeleton className=\"h-4 w-full\" />\n              <Skeleton className=\"h-4 w-full\" />\n              <Skeleton className=\"h-4 w-3/4\" />\n              <div className=\"text-center py-4 text-gray-500 dark:text-foreground-tertiary text-sm\">\n                {t('common.generating')}\n              </div>\n            </div>\n          ) : text ? (\n            <div className=\"text-sm text-gray-700 dark:text-foreground-secondary\">\n              <Markdown>{text}</Markdown>\n              {allFieldNames.map(name => {\n                const value = extraFields[name];\n                if (!value) return null;\n                const FIELD_ICONS: Record<string, typeof Tag> = { '视觉元素': Image, '视觉焦点': Focus, '排版布局': Layout, '演讲者备注': MessageSquare };\n                const FieldIcon = FIELD_ICONS[name] || Tag;\n                const notInImagePrompt = imagePromptFields && !imagePromptFields.includes(name);\n                return (\n                  <div key={name} className=\"mt-3 pt-3 border-t border-gray-100 dark:border-border-primary\">\n                    <div className=\"flex items-center gap-1.5 text-xs text-gray-500 dark:text-foreground-tertiary mb-1\">\n                      <FieldIcon size={12} />\n                      <span className=\"font-medium\">{name}</span>\n                      {notInImagePrompt && (\n                        <span className=\"relative group/nip\">\n                          <ImageOff size={11} className=\"opacity-50\" />\n                          <span className=\"absolute left-1/2 -translate-x-1/2 bottom-full mb-1.5 w-max max-w-40 px-2 py-1 text-[10px] leading-snug text-gray-600 dark:text-foreground-secondary bg-white dark:bg-background-primary border border-gray-200 dark:border-border-primary rounded-md shadow-md opacity-0 pointer-events-none group-hover/nip:opacity-100 transition-opacity z-50\">\n                            {t('descriptionCard.notInImagePrompt')}\n                          </span>\n                        </span>\n                      )}\n                    </div>\n                    <div className=\"text-xs text-gray-500 dark:text-foreground-tertiary\"><Markdown>{value}</Markdown></div>\n                  </div>\n                );\n              })}\n            </div>\n          ) : (\n            <div className=\"text-center py-8 text-gray-400 dark:text-foreground-tertiary\">\n              <div className=\"flex text-3xl mb-2 justify-center\"><FileText className=\"text-gray-400 dark:text-foreground-tertiary\" size={48} /></div>\n              <p className=\"text-sm\">{t('descriptionCard.noDescription')}</p>\n            </div>\n          )}\n        </div>\n\n        {/* 操作栏 */}\n        <div className=\"border-t border-gray-100 dark:border-border-primary px-4 py-3 flex justify-end gap-2 mt-auto\">\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            icon={<Edit2 size={16} />}\n            onClick={handleEdit}\n            disabled={generating}\n          >\n            {t('common.edit')}\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            icon={<RefreshCw size={16} className={generating ? 'animate-spin' : ''} />}\n            onClick={onRegenerate}\n            disabled={generating}\n          >\n            {generating ? t('common.generating') : t('descriptionCard.regenerate')}\n          </Button>\n        </div>\n      </Card>\n\n      {/* 编辑对话框 */}\n      <Modal\n        isOpen={isEditing}\n        onClose={() => setIsEditing(false)}\n        title={t('descriptionCard.descriptionTitle')}\n        size=\"lg\"\n      >\n        <div className=\"space-y-4\">\n          <MarkdownTextarea\n            ref={textareaRef}\n            label={t('descriptionCard.description')}\n            value={editContent}\n            onChange={setEditContent}\n            onPaste={handlePaste}\n            onFiles={handleFiles}\n            onFocus={focusMainDesc}\n            rows={6}\n            placeholder={t('descriptionCard.descriptionPlaceholder')}\n          />\n          {/* 额外字段编辑 */}\n          {allFieldNames.map(name => (\n            <MarkdownTextarea\n              key={name}\n              ref={el => { extraFieldRefs.current[name] = el; }}\n              label={name}\n              value={editExtraFields[name] || ''}\n              onChange={v => setEditExtraFields(prev => ({ ...prev, [name]: v }))}\n              onPaste={handlePaste}\n              onFiles={handleFiles}\n              onFocus={() => focusExtraField(name)}\n              showUploadButton={false}\n              rows={2}\n              placeholder={name}\n            />\n          ))}\n          <div className=\"flex justify-end gap-3 pt-4\">\n            <Button variant=\"ghost\" onClick={() => setIsEditing(false)}>\n              {t('common.cancel')}\n            </Button>\n            <Button variant=\"primary\" onClick={handleSave} disabled={isUploading}>\n              {t('common.save')}\n            </Button>\n          </div>\n        </div>\n      </Modal>\n    </>\n  );\n}, (prev, next) =>\n  prev.index === next.index &&\n  prev.isAiRefining === next.isAiRefining &&\n  prev.projectId === next.projectId &&\n  prev.page.id === next.page.id &&\n  prev.page.status === next.page.status &&\n  prev.page.part === next.page.part &&\n  getDescriptionText(prev.page.description_content) === getDescriptionText(next.page.description_content) &&\n  getExtraFieldsKey(prev.page.description_content) === getExtraFieldsKey(next.page.description_content) &&\n  JSON.stringify(prev.extraFieldNames) === JSON.stringify(next.extraFieldNames) &&\n  JSON.stringify(prev.imagePromptFields) === JSON.stringify(next.imagePromptFields)\n);\n"
  },
  {
    "path": "frontend/src/components/preview/SlideCard.tsx",
    "content": "import React from 'react';\nimport { Edit2, Trash2 } from 'lucide-react';\nimport { useT } from '@/hooks/useT';\nimport { StatusBadge, Skeleton, useConfirm } from '@/components/shared';\nimport { getImageUrl } from '@/api/client';\nimport type { Page } from '@/types';\n\n// SlideCard 组件自包含翻译\nconst slideCardI18n = {\n  zh: {\n    slideCard: {\n      notGenerated: \"未生成\",\n      confirmDeletePage: \"确定要删除这一页吗？\",\n      confirmDeleteTitle: \"确认删除\",\n      coverPage: \"封面\",\n      coverPageTooltip: \"第一页为封面页，通常包含标题和副标题\"\n    }\n  },\n  en: {\n    slideCard: {\n      notGenerated: \"Not Generated\",\n      confirmDeletePage: \"Are you sure you want to delete this page?\",\n      confirmDeleteTitle: \"Confirm Delete\",\n      coverPage: \"Cover\",\n      coverPageTooltip: \"This is the cover page, usually containing the title and subtitle\"\n    }\n  }\n};\n\ninterface SlideCardProps {\n  page: Page;\n  index: number;\n  isSelected: boolean;\n  onClick: () => void;\n  onEdit: () => void;\n  onDelete: () => void;\n  isGenerating?: boolean;\n  aspectRatio?: string;\n}\n\nexport const SlideCard: React.FC<SlideCardProps> = ({\n  page,\n  index,\n  isSelected,\n  onClick,\n  onEdit,\n  onDelete,\n  isGenerating = false,\n  aspectRatio = '16:9',\n}) => {\n  const t = useT(slideCardI18n);\n  const { confirm, ConfirmDialog } = useConfirm();\n  const imageUrl = page.generated_image_path\n    ? getImageUrl(page.generated_image_path, page.updated_at)\n    : '';\n  \n  const generating = isGenerating || page.status === 'QUEUED' || page.status === 'GENERATING';\n\n  return (\n    <div\n      className={`group cursor-pointer transition-all ${\n        isSelected ? 'ring-2 ring-banana-500' : ''\n      }`}\n      onClick={onClick}\n    >\n      {/* 缩略图 */}\n      <div className=\"relative bg-gray-100 dark:bg-background-secondary rounded-lg overflow-hidden mb-2\" style={{ aspectRatio: aspectRatio.replace(':', '/') }}>\n        {generating ? (\n          <Skeleton className=\"w-full h-full\" />\n        ) : page.generated_image_path ? (\n          <>\n            <img\n              src={imageUrl}\n              alt={`Slide ${index + 1}`}\n              className=\"w-full h-full object-cover\"\n            />\n            {/* 悬停操作 */}\n            <div className=\"absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2\">\n              <button\n                onClick={(e) => {\n                  e.stopPropagation();\n                  onEdit();\n                }}\n                className=\"p-2 bg-white dark:bg-background-secondary rounded-lg hover:bg-banana-50 dark:hover:bg-background-hover transition-colors\"\n              >\n                <Edit2 size={18} />\n              </button>\n              <button\n                onClick={(e) => {\n                  e.stopPropagation();\n                  confirm(\n                    t('slideCard.confirmDeletePage'),\n                    onDelete,\n                    { title: t('slideCard.confirmDeleteTitle'), variant: 'danger' }\n                  );\n                }}\n                className=\"p-2 bg-white dark:bg-background-secondary rounded-lg hover:bg-red-50 transition-colors\"\n              >\n                <Trash2 size={18} className=\"text-red-600\" />\n              </button>\n            </div>\n          </>\n        ) : (\n          <div className=\"w-full h-full flex items-center justify-center text-gray-400\">\n            <div className=\"text-center\">\n              <div className=\"text-3xl mb-1\">🍌</div>\n              <div className=\"text-xs\">{t('slideCard.notGenerated')}</div>\n            </div>\n          </div>\n        )}\n        \n        {/* 状态标签 */}\n        <div className=\"absolute bottom-2 right-2\">\n          <StatusBadge status={page.status} />\n        </div>\n      </div>\n\n      {/* 标题 */}\n      <div className=\"flex items-center gap-2\">\n        <span\n          className={`text-sm font-medium ${\n            isSelected ? 'text-banana-600' : 'text-gray-700 dark:text-foreground-secondary'\n          }`}\n        >\n          {index + 1}. {page.outline_content?.title}\n        </span>\n        {index === 0 && (\n          <span\n            className=\"text-xs px-1.5 py-0.5 bg-banana-100 dark:bg-banana-900/30 text-banana-700 dark:text-banana-400 rounded flex-shrink-0\"\n            title={t('slideCard.coverPageTooltip')}\n          >\n            {t('slideCard.coverPage')}\n          </span>\n        )}\n      </div>\n      {ConfirmDialog}\n    </div>\n  );\n};\n\n"
  },
  {
    "path": "frontend/src/components/shared/AccessCodeGuard.tsx",
    "content": "import { useState, useEffect, type ReactNode } from 'react';\nimport { checkAccessCode, verifyAccessCode } from '@/api/endpoints';\nimport { useT } from '@/hooks/useT';\nimport { Button } from './Button';\nimport { Input } from './Input';\n\nconst STORAGE_KEY = 'banana-access-code';\n\nconst translations = {\n  zh: {\n    title: '请输入访问口令',\n    placeholder: '输入口令',\n    submit: '确认',\n    error: '口令错误，请重试',\n    networkError: '网络错误，请稍后重试',\n    connectError: '无法连接到后端服务',\n    connectHint: '请检查后端服务是否正常运行',\n    retry: '重试',\n  },\n  en: {\n    title: 'Enter Access Code',\n    placeholder: 'Enter code',\n    submit: 'Submit',\n    error: 'Invalid code, please try again',\n    networkError: 'Network error, please try later',\n    connectError: 'Cannot connect to backend service',\n    connectHint: 'Please check if the backend service is running',\n    retry: 'Retry',\n  },\n};\n\nexport function AccessCodeGuard({ children }: { children: ReactNode }) {\n  const t = useT(translations);\n  const [status, setStatus] = useState<'loading' | 'prompt' | 'pass' | 'connectError'>('loading');\n  const [code, setCode] = useState('');\n  const [error, setError] = useState('');\n  const [verifying, setVerifying] = useState(false);\n\n  const checkAccess = async () => {\n    setStatus('loading');\n    try {\n      const res = await checkAccessCode();\n      if (!res.data.enabled) { setStatus('pass'); return; }\n      const saved = localStorage.getItem(STORAGE_KEY);\n      if (saved) {\n        const v = await verifyAccessCode(saved);\n        if (v.data.valid) { setStatus('pass'); return; }\n        localStorage.removeItem(STORAGE_KEY);\n      }\n      setStatus('prompt');\n    } catch {\n      localStorage.removeItem(STORAGE_KEY);\n      setStatus('connectError');\n    }\n  };\n\n  useEffect(() => { checkAccess(); }, []);\n\n  const handleSubmit = async () => {\n    if (!code.trim()) return;\n    setVerifying(true);\n    setError('');\n    try {\n      const res = await verifyAccessCode(code.trim());\n      if (res.data.valid) {\n        localStorage.setItem(STORAGE_KEY, code.trim());\n        setStatus('pass');\n      } else {\n        setError(t('error'));\n      }\n    } catch (e: unknown) {\n      const status = (e as { response?: { status?: number } })?.response?.status;\n      setError(status === 403 ? t('error') : t('networkError'));\n    } finally {\n      setVerifying(false);\n    }\n  };\n\n  if (status === 'loading') return null;\n  if (status === 'pass') return <>{children}</>;\n\n  if (status === 'connectError') {\n    return (\n      <div className=\"min-h-screen flex items-center justify-center bg-background-primary\">\n        <div className=\"w-80 p-6 rounded-2xl bg-white dark:bg-background-secondary shadow-lg border border-gray-200 dark:border-border-primary text-center\">\n          <p className=\"text-gray-600 dark:text-foreground-secondary mb-1\">{t('connectError')}</p>\n          <p className=\"text-sm text-gray-400 dark:text-foreground-tertiary mb-4\">{t('connectHint')}</p>\n          <Button className=\"w-full\" onClick={checkAccess}>{t('retry')}</Button>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"min-h-screen flex items-center justify-center bg-background-primary\">\n      <div className=\"w-80 p-6 rounded-2xl bg-white dark:bg-background-secondary shadow-lg border border-gray-200 dark:border-border-primary\">\n        <h2 className=\"text-lg font-semibold text-center mb-4 text-gray-900 dark:text-foreground-primary\">\n          {t('title')}\n        </h2>\n        <form onSubmit={e => { e.preventDefault(); handleSubmit(); }} className=\"space-y-4\">\n          <Input\n            type=\"password\"\n            placeholder={t('placeholder')}\n            value={code}\n            onChange={e => setCode(e.target.value)}\n            error={error}\n            autoFocus\n          />\n          <Button type=\"submit\" className=\"w-full\" loading={verifying}>\n            {t('submit')}\n          </Button>\n        </form>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/shared/AiRefineInput.tsx",
    "content": "import React, { useState, memo } from 'react';\nimport { Sparkles, History, ChevronDown, ChevronUp, Send } from 'lucide-react';\nimport { useT } from '@/hooks/useT';\n\n// AiRefineInput 组件自包含翻译\nconst aiRefineI18n = {\n  zh: {\n    aiRefine: {\n      ctrlEnterSubmit: \"（Ctrl+Enter 提交）\", history: \"历史\",\n      viewHistory: \"查看 {{count}} 条历史修改\", previousRequirements: \"之前的修改要求：\",\n      submitTooltip: \"提交 (Ctrl+Enter)\"\n    }\n  },\n  en: {\n    aiRefine: {\n      ctrlEnterSubmit: \"(Ctrl+Enter to submit)\", history: \"History\",\n      viewHistory: \"View {{count}} previous edits\", previousRequirements: \"Previous edit requests:\",\n      submitTooltip: \"Submit (Ctrl+Enter)\"\n    }\n  }\n};\n\nexport interface AiRefineInputProps {\n  /** 标题文字 */\n  title: string;\n  /** 输入框占位文字 */\n  placeholder: string;\n  /** 提交回调函数，接收当前要求和历史要求，返回 Promise */\n  onSubmit: (requirement: string, previousRequirements: string[]) => Promise<void>;\n  /** 是否禁用（例如没有内容可修改时） */\n  disabled?: boolean;\n  /** 自定义类名 */\n  className?: string;\n  /** 状态变化回调，通知父组件当前是否正在提交 */\n  onStatusChange?: (isSubmitting: boolean) => void;\n}\n\nconst AiRefineInputComponent: React.FC<AiRefineInputProps> = ({\n  title,\n  placeholder,\n  onSubmit,\n  disabled = false,\n  className = '',\n  onStatusChange,\n}) => {\n  const t = useT(aiRefineI18n);\n  const [requirement, setRequirement] = useState('');\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const [history, setHistory] = useState<string[]>([]);\n  const [showHistory, setShowHistory] = useState(false);\n\n  const handleSubmit = async () => {\n    if (!requirement.trim() || isSubmitting || disabled) return;\n\n    const currentRequirement = requirement.trim();\n    setIsSubmitting(true);\n    onStatusChange?.(true); // 通知父组件开始提交\n    try {\n      await onSubmit(currentRequirement, history);\n      // 成功后将当前要求添加到历史\n      setHistory(prev => [...prev, currentRequirement]);\n      // 清空输入框\n      setRequirement('');\n    } finally {\n      setIsSubmitting(false);\n      onStatusChange?.(false); // 通知父组件提交结束\n    }\n  };\n\n  // 处理 Ctrl+Enter 快捷键\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {\n      e.preventDefault();\n      handleSubmit();\n    }\n  };\n\n  if (disabled) {\n    return null;\n  }\n\n  // 判断是否为紧凑模式（没有标题时）\n  const isCompactMode = !title;\n\n  return (\n    <div className={isCompactMode ? `group ${className}` : `group bg-gradient-to-r from-purple-50 to-blue-50 rounded-lg p-3 md:p-4 border border-purple-200 ${className}`}>\n      {/* 标题和历史按钮 - 仅非紧凑模式显示 */}\n      {!isCompactMode && (\n        <div className=\"flex items-center justify-between mb-2 md:mb-3\">\n          <div className=\"flex items-center gap-2\">\n            <Sparkles size={16} className=\"text-purple-600 md:w-[18px] md:h-[18px]\" />\n            <h3 className=\"text-xs md:text-sm font-semibold text-gray-800 dark:text-foreground-primary\">{title}</h3>\n            <span className=\"text-xs text-gray-500 dark:text-foreground-tertiary hidden sm:inline\">{t('aiRefine.ctrlEnterSubmit')}</span>\n          </div>\n          {history.length > 0 && (\n            <button\n              onClick={() => setShowHistory(!showHistory)}\n              className=\"flex items-center gap-1 text-xs text-purple-600 hover:text-purple-800 transition-colors\"\n            >\n              <History size={14} />\n              <span className=\"hidden sm:inline\">{t('aiRefine.history')} ({history.length})</span>\n              <span className=\"sm:hidden\">{history.length}</span>\n              {showHistory ? <ChevronUp size={14} /> : <ChevronDown size={14} />}\n            </button>\n          )}\n        </div>\n      )}\n      \n      {/* 历史记录展示 */}\n      {showHistory && history.length > 0 && (\n        <div className={`${isCompactMode ? 'mb-2' : 'mb-3'} p-2 bg-white dark:bg-background-secondary rounded border ${isCompactMode ? 'border-gray-200 dark:border-border-primary shadow-sm dark:shadow-background-primary/30' : 'bg-white/60 border-purple-100'} max-h-32 overflow-y-auto`}>\n          <div className=\"text-xs text-gray-500 dark:text-foreground-tertiary mb-1\">{t('aiRefine.previousRequirements')}</div>\n          <ul className=\"space-y-1\">\n            {history.map((req, idx) => (\n              <li key={idx} className=\"text-xs text-gray-700 dark:text-foreground-secondary flex items-start gap-1\">\n                <span className=\"text-purple-400 flex-shrink-0\">{idx + 1}.</span>\n                <span className=\"break-all\">{req}</span>\n              </li>\n            ))}\n          </ul>\n        </div>\n      )}\n      \n      <div className=\"flex gap-2 items-center relative\">\n        {/* 紧凑模式下显示图标和历史按钮 */}\n        {isCompactMode && (\n          <>\n            <Sparkles size={16} className={`flex-shrink-0 transition-colors ${isSubmitting ? 'text-purple-500' : 'text-purple-600'}`} />\n            {history.length > 0 && (\n              <button\n                onClick={() => setShowHistory(!showHistory)}\n                className=\"flex items-center gap-1 text-xs text-gray-500 dark:text-foreground-tertiary hover:text-purple-600 transition-colors flex-shrink-0\"\n                title={t('aiRefine.viewHistory', { count: history.length })}\n              >\n                <History size={14} />\n                <span className=\"hidden sm:inline\">{history.length}</span>\n              </button>\n            )}\n          </>\n        )}\n        \n        <div className=\"flex-1 relative\">\n          <input\n            type=\"text\"\n            value={requirement}\n            onChange={(e) => setRequirement(e.target.value)}\n            onKeyDown={handleKeyDown}\n            placeholder={placeholder}\n            className={`w-full px-3 py-1.5 text-sm border ${isCompactMode ? 'border-gray-200 dark:border-border-primary' : 'border-gray-300 dark:border-border-primary'} rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all ${\n              isSubmitting ? 'animate-gradient-x bg-gradient-to-r from-purple-100 via-purple-200 to-purple-100 bg-[length:200%_100%]' : 'bg-white dark:bg-background-secondary'\n            }`}\n            disabled={isSubmitting}\n          />\n          {isSubmitting && (\n            <div className=\"absolute inset-0 rounded-lg overflow-hidden pointer-events-none\">\n              <div className=\"absolute inset-0 bg-gradient-to-r from-transparent via-purple-300/30 to-transparent animate-shimmer\" \n                   style={{ backgroundSize: '200% 100%' }} />\n            </div>\n          )}\n        </div>\n        \n        {/* 提交按钮 - 移动端始终显示，桌面端鼠标悬停时显示 */}\n        <button\n          onClick={handleSubmit}\n          disabled={!requirement.trim() || isSubmitting}\n          className={`flex-shrink-0 w-9 h-9 flex items-center justify-center rounded-lg transition-all ${\n            !requirement.trim() || isSubmitting\n              ? 'bg-gray-200 text-gray-400 cursor-not-allowed'\n              : 'bg-purple-500 text-white hover:bg-purple-600 active:scale-95'\n          } md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100`}\n          title={t('aiRefine.submitTooltip')}\n        >\n          <Send size={16} className={isSubmitting ? 'animate-pulse' : ''} />\n        </button>\n      </div>\n    </div>\n  );\n};\n\n// 使用 memo 包装组件，避免父组件频繁重渲染时影响输入框\n// 只有当 props 真正变化时才重新渲染\nexport const AiRefineInput = memo(AiRefineInputComponent);\n\n"
  },
  {
    "path": "frontend/src/components/shared/Button.tsx",
    "content": "import React from 'react';\nimport { cn } from '@/utils';\n\ninterface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n  variant?: 'primary' | 'secondary' | 'ghost';\n  size?: 'sm' | 'md' | 'lg';\n  loading?: boolean;\n  icon?: React.ReactNode;\n}\n\nexport const Button: React.FC<ButtonProps> = ({\n  children,\n  variant = 'primary',\n  size = 'md',\n  loading = false,\n  icon,\n  className,\n  disabled,\n  ...props\n}) => {\n  const baseStyles = 'inline-flex items-center justify-center font-semibold rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-banana-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:opacity-50 disabled:cursor-not-allowed touch-manipulation';\n\n  const variants = {\n    primary: 'bg-gradient-to-r from-banana-500 to-banana-600 text-black hover:shadow-yellow hover:-translate-y-0.5 active:translate-y-0 shadow-md',\n    secondary: 'bg-white dark:bg-background-secondary border border-banana-500 text-black dark:text-foreground-primary hover:bg-banana-50 dark:hover:bg-background-hover',\n    ghost: 'bg-transparent text-gray-700 dark:text-foreground-secondary hover:bg-gray-100 dark:hover:bg-background-secondary',\n  };\n  \n  const sizes = {\n    sm: 'h-8 px-3 text-sm',\n    md: 'h-10 px-6 text-base',\n    lg: 'h-12 px-8 text-lg',\n  };\n\n  return (\n    <button\n      className={cn(\n        baseStyles,\n        variants[variant],\n        sizes[size],\n        className\n      )}\n      disabled={disabled || loading}\n      {...props}\n    >\n      {loading && (\n        <svg\n          className=\"animate-spin -ml-1 mr-2 h-4 w-4\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n          fill=\"none\"\n          viewBox=\"0 0 24 24\"\n        >\n          <circle\n            className=\"opacity-25\"\n            cx=\"12\"\n            cy=\"12\"\n            r=\"10\"\n            stroke=\"currentColor\"\n            strokeWidth=\"4\"\n          />\n          <path\n            className=\"opacity-75\"\n            fill=\"currentColor\"\n            d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"\n          />\n        </svg>\n      )}\n      {!loading && icon && (\n        <span className={children ? 'mr-2' : ''}>{icon}</span>\n      )}\n      {children}\n    </button>\n  );\n};\n\n"
  },
  {
    "path": "frontend/src/components/shared/Card.tsx",
    "content": "import React from 'react';\nimport { cn } from '@/utils';\n\ninterface CardProps extends React.HTMLAttributes<HTMLDivElement> {\n  hoverable?: boolean;\n}\n\nexport const Card: React.FC<CardProps> = ({\n  children,\n  hoverable = false,\n  className,\n  ...props\n}) => {\n  return (\n    <div\n      className={cn(\n        'bg-white dark:bg-background-secondary rounded-card shadow-md border border-gray-100 dark:border-border-primary',\n        hoverable && 'hover:shadow-lg hover:-translate-y-1 hover:border-banana-500 transition-all duration-200 cursor-pointer',\n        className\n      )}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n};\n\n"
  },
  {
    "path": "frontend/src/components/shared/ConfirmDialog.tsx",
    "content": "import React, { useState, useCallback } from 'react';\nimport { AlertTriangle } from 'lucide-react';\nimport { Modal } from './Modal';\nimport { Button } from './Button';\n\ninterface ConfirmDialogProps {\n  isOpen: boolean;\n  onClose: () => void;\n  onConfirm: (checkboxValue?: boolean) => void;\n  title?: string;\n  message: string;\n  confirmText?: string;\n  cancelText?: string;\n  variant?: 'danger' | 'warning' | 'info';\n  checkboxLabel?: string;\n  checkboxDefaultChecked?: boolean;\n}\n\nexport const ConfirmDialog: React.FC<ConfirmDialogProps> = ({\n  isOpen,\n  onClose,\n  onConfirm,\n  title = '确认操作',\n  message,\n  confirmText = '确定',\n  cancelText = '取消',\n  variant = 'warning',\n  checkboxLabel,\n  checkboxDefaultChecked = false,\n}) => {\n  const [checkboxChecked, setCheckboxChecked] = useState(checkboxDefaultChecked);\n\n  const handleConfirm = () => {\n    onConfirm(checkboxLabel ? checkboxChecked : undefined);\n    onClose();\n  };\n\n  const variantStyles = {\n    danger: 'text-red-600 dark:text-red-400',\n    warning: 'text-yellow-600 dark:text-yellow-400',\n    info: 'text-blue-600 dark:text-blue-400',\n  };\n\n  return (\n    <Modal isOpen={isOpen} onClose={onClose} title={title} size=\"sm\">\n      <div className=\"space-y-4\">\n        <div className=\"flex items-start gap-4\">\n          <AlertTriangle\n            size={24}\n            className={`flex-shrink-0 mt-0.5 ${variantStyles[variant]}`}\n          />\n          <p className=\"text-gray-700 dark:text-foreground-secondary flex-1\">{message}</p>\n        </div>\n        {checkboxLabel && (\n          <label className=\"flex items-center gap-2 cursor-pointer\">\n            <input\n              type=\"checkbox\"\n              checked={checkboxChecked}\n              onChange={(e) => setCheckboxChecked(e.target.checked)}\n              className=\"w-4 h-4 rounded border-gray-300 dark:border-gray-600\"\n            />\n            <span className=\"text-sm text-gray-700 dark:text-foreground-secondary\">{checkboxLabel}</span>\n          </label>\n        )}\n        <div className=\"flex justify-end gap-3 pt-4\">\n          <Button variant=\"ghost\" onClick={onClose}>\n            {cancelText}\n          </Button>\n          <Button\n            variant={variant === 'danger' ? 'primary' : 'secondary'}\n            onClick={handleConfirm}\n          >\n            {confirmText}\n          </Button>\n        </div>\n      </div>\n    </Modal>\n  );\n};\n\n// Hook for easy confirmation dialogs\nexport const useConfirm = () => {\n  const [isOpen, setIsOpen] = useState(false);\n  const [config, setConfig] = useState<{\n    message: string;\n    title?: string;\n    confirmText?: string;\n    cancelText?: string;\n    variant?: 'danger' | 'warning' | 'info';\n    checkboxLabel?: string;\n    checkboxDefaultChecked?: boolean;\n    onConfirm: (checkboxValue?: boolean) => void;\n  } | null>(null);\n\n  const confirm = useCallback(\n    (\n      message: string,\n      onConfirm: (checkboxValue?: boolean) => void,\n      options?: {\n        title?: string;\n        confirmText?: string;\n        cancelText?: string;\n        variant?: 'danger' | 'warning' | 'info';\n        checkboxLabel?: string;\n        checkboxDefaultChecked?: boolean;\n      }\n    ) => {\n      setConfig({\n        message,\n        onConfirm,\n        title: options?.title,\n        confirmText: options?.confirmText,\n        cancelText: options?.cancelText,\n        variant: options?.variant || 'warning',\n        checkboxLabel: options?.checkboxLabel,\n        checkboxDefaultChecked: options?.checkboxDefaultChecked,\n      });\n      setIsOpen(true);\n    },\n    []\n  );\n\n  const close = useCallback(() => {\n    setIsOpen(false);\n    setConfig(null);\n  }, []);\n\n  const handleConfirm = useCallback((checkboxValue?: boolean) => {\n    if (config?.onConfirm) {\n      config.onConfirm(checkboxValue);\n    }\n    close();\n  }, [config, close]);\n\n  return {\n    confirm,\n    ConfirmDialog: config ? (\n      <ConfirmDialog\n        isOpen={isOpen}\n        onClose={close}\n        onConfirm={handleConfirm}\n        message={config.message}\n        title={config.title}\n        confirmText={config.confirmText}\n        cancelText={config.cancelText}\n        variant={config.variant}\n        checkboxLabel={config.checkboxLabel}\n        checkboxDefaultChecked={config.checkboxDefaultChecked}\n      />\n    ) : null,\n  };\n};\n\n"
  },
  {
    "path": "frontend/src/components/shared/ContextualStatusBadge.tsx",
    "content": "import React from 'react';\nimport { cn } from '@/utils';\nimport type { Page } from '@/types';\nimport { usePageStatus, type PageStatusContext } from '@/hooks/usePageStatus';\n\ninterface ContextualStatusBadgeProps {\n  page: Page;\n  /** 上下文：description（描述页）、image（图片页）、full（完整状态） */\n  context?: PageStatusContext;\n  /** 是否显示详细描述（悬停提示） */\n  showDescription?: boolean;\n}\n\n/**\n * 根据上下文智能显示状态的徽章\n * \n * - 在描述编辑页面：只显示描述相关状态\n * - 在图片预览页面：显示图片生成状态\n * - 其他场景：显示完整页面状态\n */\nexport const ContextualStatusBadge: React.FC<ContextualStatusBadgeProps> = ({\n  page,\n  context = 'full',\n  showDescription = true,\n}) => {\n  const { status, label, description } = usePageStatus(page, context);\n\n  const statusConfig: Record<string, string> = {\n    DRAFT: 'bg-gray-100 dark:bg-background-secondary text-gray-600 dark:text-foreground-tertiary',\n    GENERATING_DESCRIPTION: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 animate-pulse',\n    DESCRIPTION_GENERATED: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',\n    QUEUED: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 animate-pulse',\n    GENERATING: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 animate-pulse',\n    COMPLETED: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',\n    FAILED: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',\n  };\n\n  return (\n    <span\n      className={cn(\n        'inline-flex items-center px-2.5 py-0.5 rounded text-xs font-medium',\n        statusConfig[status]\n      )}\n      title={showDescription ? description : undefined}\n    >\n      {label}\n    </span>\n  );\n};\n\n"
  },
  {
    "path": "frontend/src/components/shared/ExportTasksPanel.tsx",
    "content": "import React, { useState, useEffect } from 'react';\nimport { Download, X, Trash2, FileText, Clock, CheckCircle, XCircle, Loader2, AlertTriangle, HelpCircle, Settings } from 'lucide-react';\nimport { useExportTasksStore, type ExportTask, type ExportTaskType } from '@/store/useExportTasksStore';\nimport { useT } from '@/hooks/useT';\nimport type { Page } from '@/types';\nimport { Button } from './Button';\nimport { cn } from '@/utils';\n\n// Export 组件自包含翻译\nconst exportI18n = {\n  zh: {\n    export: {\n      tasks: \"导出任务\", inProgress: \"{{count}} 进行中\", clearHistory: \"清除\",\n      exportPptx: \"PPTX\", exportPdf: \"PDF\", exportEditablePptx: \"可编辑 PPTX\", exportImages: \"图片\",\n      allPages: \"全部\", pageRange: \"第{{start}}-{{end}}页\", singlePage: \"第{{num}}页\", pagesCount: \"{{count}}页\",\n      warnings: \"{{count}} 条警告\", clickToView: \"点击查看\", warningsTitle: \"导出警告\",\n      warningsCount: \"导出警告 ({{count}} 条)\", detailInfo: \"详细信息\",\n      styleExtractionFailed: \"样式提取失败 ({{count}} 个)\", textRenderFailed: \"文本渲染失败 ({{count}} 个)\",\n      moreItems: \"... 还有 {{count}} 条\", exportFailed: \"导出失败\", preparing: \"准备中...\",\n      settingsTip: \"可在「项目设置 → 导出设置」中调整配置或开启「返回半成品」选项\"\n    },\n    shared: { historyRecords: \"历史记录\" }\n  },\n  en: {\n    export: {\n      tasks: \"Export Tasks\", inProgress: \"{{count}} in progress\", clearHistory: \"Clear\",\n      exportPptx: \"PPTX\", exportPdf: \"PDF\", exportEditablePptx: \"Editable PPTX\", exportImages: \"Images\",\n      allPages: \"All\", pageRange: \"Pages {{start}}-{{end}}\", singlePage: \"Page {{num}}\", pagesCount: \"{{count}} pages\",\n      warnings: \"{{count}} warnings\", clickToView: \"Click to view\", warningsTitle: \"Export Warnings\",\n      warningsCount: \"Export Warnings ({{count}})\", detailInfo: \"Details\",\n      styleExtractionFailed: \"Style extraction failed ({{count}})\", textRenderFailed: \"Text render failed ({{count}})\",\n      moreItems: \"... {{count}} more\", exportFailed: \"Export Failed\", preparing: \"Preparing...\",\n      settingsTip: \"Adjust settings in \\\"Project Settings → Export Settings\\\" or enable \\\"Allow Partial Results\\\"\"\n    },\n    shared: { historyRecords: \"History Records\" }\n  }\n};\n\nconst getPageRangeText = (pageIds: string[] | undefined, pages: Page[], t: (key: string, options?: any) => string): string => {\n  if (!pageIds || pageIds.length === 0) {\n    return t('export.allPages');\n  }\n  \n  const indices: number[] = [];\n  pageIds.forEach(pageId => {\n    const index = pages.findIndex(p => (p.id || p.page_id) === pageId);\n    if (index >= 0) {\n      indices.push(index);\n    }\n  });\n  \n  if (indices.length === 0) {\n    return t('export.pagesCount', { count: pageIds.length });\n  }\n  \n  indices.sort((a, b) => a - b);\n  const minIndex = indices[0];\n  const maxIndex = indices[indices.length - 1];\n  \n  if (indices.length === maxIndex - minIndex + 1) {\n    if (minIndex === maxIndex) {\n      return t('export.singlePage', { num: minIndex + 1 });\n    }\n    return t('export.pageRange', { start: minIndex + 1, end: maxIndex + 1 });\n  } else {\n    return t('export.pagesCount', { count: pageIds.length });\n  }\n};\n\nconst TaskStatusIcon: React.FC<{ status: ExportTask['status'] }> = ({ status }) => {\n  switch (status) {\n    case 'PENDING':\n      return <Clock size={16} className=\"text-gray-400\" />;\n    case 'PROCESSING':\n    case 'RUNNING':\n      return <Loader2 size={16} className=\"text-banana-500 animate-spin\" />;\n    case 'COMPLETED':\n      return <CheckCircle size={16} className=\"text-green-500\" />;\n    case 'FAILED':\n      return <XCircle size={16} className=\"text-red-500\" />;\n    default:\n      return null;\n  }\n};\n\nconst WarningsModal: React.FC<{\n  isOpen: boolean;\n  onClose: () => void;\n  warnings: string[];\n  warningDetails?: any;\n}> = ({ isOpen, onClose, warnings, warningDetails }) => {\n  const t = useT(exportI18n);\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-black/50\" onClick={onClose} />\n      \n      <div className=\"relative bg-white dark:bg-background-secondary rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[80vh] flex flex-col\">\n        <div className=\"flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-border-primary bg-amber-50\">\n          <div className=\"flex items-center gap-2\">\n            <AlertTriangle size={18} className=\"text-amber-500\" />\n            <h3 className=\"text-base font-semibold text-amber-800\">\n              {t('export.warningsCount', { count: warnings.length })}\n            </h3>\n          </div>\n          <button\n            onClick={onClose}\n            className=\"p-1 hover:bg-amber-100 rounded transition-colors\"\n          >\n            <X size={18} className=\"text-gray-500 dark:text-foreground-tertiary\" />\n          </button>\n        </div>\n        \n        <div className=\"flex-1 overflow-y-auto p-4\">\n          <div className=\"space-y-2\">\n            {warnings.map((warning, idx) => (\n              <div\n                key={idx}\n                className=\"flex items-start gap-2 p-2 bg-amber-50 border border-amber-200 rounded text-sm text-amber-800\"\n              >\n                <span className=\"text-amber-500 mt-0.5\">•</span>\n                <span className=\"break-words\">{warning}</span>\n              </div>\n            ))}\n          </div>\n          \n          {warningDetails && (\n            <div className=\"mt-4 pt-4 border-t border-gray-200 dark:border-border-primary\">\n              <h4 className=\"text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-2\">{t('export.detailInfo')}</h4>\n              \n              {warningDetails.style_extraction_failed?.length > 0 && (\n                <div className=\"mb-3\">\n                  <p className=\"text-xs text-gray-500 dark:text-foreground-tertiary mb-1\">\n                    {t('export.styleExtractionFailed', { count: warningDetails.style_extraction_failed.length })}\n                  </p>\n                  <div className=\"text-xs text-gray-600 dark:text-foreground-tertiary bg-gray-50 dark:bg-background-primary p-2 rounded max-h-32 overflow-y-auto\">\n                    {warningDetails.style_extraction_failed.slice(0, 10).map((item: any, idx: number) => (\n                      <div key={idx} className=\"truncate\" title={item.reason}>\n                        • {item.element_id}: {item.reason}\n                      </div>\n                    ))}\n                    {warningDetails.style_extraction_failed.length > 10 && (\n                      <div className=\"text-gray-400 mt-1\">\n                        {t('export.moreItems', { count: warningDetails.style_extraction_failed.length - 10 })}\n                      </div>\n                    )}\n                  </div>\n                </div>\n              )}\n              \n              {warningDetails.text_render_failed?.length > 0 && (\n                <div className=\"mb-3\">\n                  <p className=\"text-xs text-gray-500 dark:text-foreground-tertiary mb-1\">\n                    {t('export.textRenderFailed', { count: warningDetails.text_render_failed.length })}\n                  </p>\n                  <div className=\"text-xs text-gray-600 dark:text-foreground-tertiary bg-gray-50 dark:bg-background-primary p-2 rounded max-h-32 overflow-y-auto\">\n                    {warningDetails.text_render_failed.slice(0, 10).map((item: any, idx: number) => (\n                      <div key={idx} className=\"truncate\" title={item.reason}>\n                        • \"{item.text}\": {item.reason}\n                      </div>\n                    ))}\n                  </div>\n                </div>\n              )}\n            </div>\n          )}\n        </div>\n        \n        <div className=\"px-4 py-3 border-t border-gray-200 dark:border-border-primary bg-gray-50 dark:bg-background-primary\">\n          <button\n            onClick={onClose}\n            className=\"w-full px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 dark:text-foreground-secondary rounded-md text-sm font-medium transition-colors\"\n          >\n            {t('common.close')}\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n};\n\nconst TaskItem: React.FC<{ task: ExportTask; pages: Page[]; onRemove: () => void }> = ({ task, pages, onRemove }) => {\n  const t = useT(exportI18n);\n  const [showWarningsModal, setShowWarningsModal] = useState(false);\n  \n  const taskTypeLabels: Record<ExportTaskType, string> = {\n    'pptx': t('export.exportPptx'),\n    'pdf': t('export.exportPdf'),\n    'editable-pptx': t('export.exportEditablePptx'),\n    'images': t('export.exportImages'),\n  };\n  \n  const formatTime = (isoString: string) => {\n    const date = new Date(isoString);\n    return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });\n  };\n\n  const pageRangeText = getPageRangeText(task.pageIds, pages, t);\n\n  const getProgressPercent = () => {\n    if (!task.progress) return 0;\n    if (task.progress.percent !== undefined) return task.progress.percent;\n    if (task.progress.total > 0) {\n      return Math.round((task.progress.completed / task.progress.total) * 100);\n    }\n    return 0;\n  };\n\n  const progressPercent = getProgressPercent();\n  const isProcessing = task.status === 'PROCESSING' || task.status === 'RUNNING' || task.status === 'PENDING';\n  \n  const hasWarnings = task.status === 'COMPLETED' && task.progress?.warnings && task.progress.warnings.length > 0;\n\n  return (\n    <div className=\"flex items-start gap-3 py-2.5 px-3 hover:bg-gray-50 dark:hover:bg-background-hover rounded-lg transition-colors\">\n      <div className=\"mt-0.5\">\n        <TaskStatusIcon status={task.status} />\n      </div>\n      \n      <div className=\"flex-1 min-w-0\">\n        <div className=\"flex items-center gap-2 mb-1\">\n          <span className=\"text-sm font-medium text-gray-700 dark:text-foreground-secondary truncate\">\n            {taskTypeLabels[task.type]}\n          </span>\n          <span className=\"text-xs text-gray-500 dark:text-foreground-tertiary\">\n            {pageRangeText}\n          </span>\n          <span className=\"text-xs text-gray-400\">\n            {formatTime(task.createdAt)}\n          </span>\n        </div>\n        \n        {isProcessing && (\n          <div className=\"mt-2 space-y-1.5\">\n            {task.progress ? (\n              <>\n                <div className=\"flex items-center justify-between\">\n                  <span className=\"text-xs font-semibold text-banana-600\">\n                    {progressPercent > 0 ? `${progressPercent}%` : t('export.preparing')}\n                  </span>\n                  {task.progress.current_step && (\n                    <span className=\"text-xs text-gray-500 dark:text-foreground-tertiary truncate max-w-[140px]\" title={task.progress.current_step}>\n                      {task.progress.current_step}\n                    </span>\n                  )}\n                </div>\n                \n                <div className=\"h-2.5 bg-gray-200 rounded-full overflow-hidden shadow-inner\">\n                  <div\n                    className=\"h-full bg-gradient-to-r from-banana-500 to-banana-600 transition-all duration-500 ease-out\"\n                    style={{ width: `${progressPercent}%` }}\n                  />\n                </div>\n                \n                {task.progress.messages && task.progress.messages.length > 0 && (\n                  <div className=\"mt-1.5 space-y-0.5\">\n                    {task.progress.messages.slice(-2).map((msg, idx) => (\n                      <div key={idx} className=\"text-xs text-gray-500 dark:text-foreground-tertiary truncate\" title={msg}>\n                        {msg}\n                      </div>\n                    ))}\n                  </div>\n                )}\n              </>\n            ) : (\n              <div className=\"flex items-center gap-2\">\n                <div className=\"h-2.5 w-full bg-gray-200 rounded-full overflow-hidden\">\n                  <div className=\"h-full bg-banana-500 animate-pulse\" style={{ width: '30%' }} />\n                </div>\n                <span className=\"text-xs text-gray-500 dark:text-foreground-tertiary whitespace-nowrap\">{t('common.pending')}</span>\n              </div>\n            )}\n          </div>\n        )}\n        \n        {task.status === 'FAILED' && task.errorMessage && (\n          <div className=\"mt-2 space-y-2\">\n            <div className=\"p-2 bg-red-50 border border-red-200 rounded\">\n              <div className=\"flex items-start gap-2\">\n                <XCircle size={14} className=\"text-red-500 flex-shrink-0 mt-0.5\" />\n                <div className=\"flex-1 min-w-0\">\n                  <p className=\"text-xs text-red-700 font-medium\">{t('export.exportFailed')}</p>\n                  <p className=\"text-xs text-red-600 mt-1 whitespace-pre-wrap break-words\">\n                    {task.errorMessage}\n                  </p>\n                </div>\n              </div>\n            </div>\n\n            {task.progress?.help_text && (\n              <div className=\"p-2 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded\">\n                <div className=\"flex items-start gap-2\">\n                  <HelpCircle size={14} className=\"text-blue-500 flex-shrink-0 mt-0.5\" />\n                  <p className=\"text-xs text-blue-700\">\n                    {task.progress.help_text}\n                  </p>\n                </div>\n              </div>\n            )}\n\n            <div className=\"flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-foreground-tertiary\">\n              <Settings size={12} />\n              <span>{t('export.settingsTip')}</span>\n            </div>\n          </div>\n        )}\n        \n        {hasWarnings && (\n          <>\n            <button\n              onClick={() => setShowWarningsModal(true)}\n              className=\"mt-1.5 w-full text-left px-2 py-1.5 bg-amber-50 border border-amber-200 rounded hover:bg-amber-100 transition-colors\"\n            >\n              <div className=\"flex items-center gap-1.5\">\n                <AlertTriangle size={12} className=\"text-amber-500 flex-shrink-0\" />\n                <span className=\"text-xs font-medium text-amber-700\">\n                  {t('export.warnings', { count: task.progress?.warnings?.length ?? 0 })}\n                </span>\n                <span className=\"text-[11px] text-amber-500 ml-auto\">\n                  {t('export.clickToView')}\n                </span>\n              </div>\n            </button>\n            \n            <WarningsModal\n              isOpen={showWarningsModal}\n              onClose={() => setShowWarningsModal(false)}\n              warnings={task.progress?.warnings ?? []}\n              warningDetails={task.progress?.warning_details}\n            />\n          </>\n        )}\n      </div>\n      \n      <div className=\"flex items-center gap-1 flex-shrink-0\">\n        {task.status === 'COMPLETED' && task.downloadUrl && (\n          <Button\n            variant=\"primary\"\n            size=\"sm\"\n            icon={<Download size={14} />}\n            onClick={() => window.open(task.downloadUrl, '_blank')}\n            className=\"text-xs px-2 py-1\"\n          >\n            {t('common.download')}\n          </Button>\n        )}\n        \n        <button\n          onClick={onRemove}\n          className=\"p-1 text-gray-400 hover:text-gray-600 transition-colors\"\n          title={t('common.delete')}\n        >\n          <X size={14} />\n        </button>\n      </div>\n    </div>\n  );\n};\n\ninterface ExportTasksPanelProps {\n  projectId?: string;\n  pages?: Page[];\n  className?: string;\n}\n\nexport const ExportTasksPanel: React.FC<ExportTasksPanelProps> = ({ projectId, pages = [], className }) => {\n  const t = useT(exportI18n);\n  const [isExpanded, setIsExpanded] = useState(true);\n  const { tasks, removeTask, clearCompleted, restoreActiveTasks } = useExportTasksStore();\n  \n  const filteredTasks = projectId \n    ? tasks.filter(task => task.projectId === projectId)\n    : tasks;\n  \n  const activeTasks = filteredTasks.filter(\n    task => task.status === 'PENDING' || task.status === 'PROCESSING' || task.status === 'RUNNING'\n  );\n  const completedTasks = filteredTasks.filter(\n    task => task.status === 'COMPLETED' || task.status === 'FAILED'\n  );\n  \n  useEffect(() => {\n    restoreActiveTasks();\n  }, []);\n  \n  useEffect(() => {\n    if (activeTasks.length > 0 && !isExpanded) {\n      setIsExpanded(true);\n    }\n  }, [activeTasks.length, isExpanded]);\n  \n  if (filteredTasks.length === 0) {\n    return null;\n  }\n  \n  return (\n    <div className={cn(\n      \"bg-white dark:bg-background-secondary rounded-lg shadow-lg border border-gray-200 dark:border-border-primary overflow-hidden\",\n      className\n    )}>\n      <button\n        onClick={() => setIsExpanded(!isExpanded)}\n        className=\"w-full px-4 py-3 flex items-center bg-gray-50 dark:bg-background-primary hover:bg-gray-100 dark:hover:bg-background-hover transition-colors\"\n      >\n        <div className=\"flex items-center gap-2\">\n          <FileText size={18} className=\"text-gray-600 dark:text-foreground-tertiary\" />\n          <span className=\"text-sm font-medium text-gray-700 dark:text-foreground-secondary\">\n            {t('export.tasks')}\n          </span>\n          {activeTasks.length > 0 && (\n            <span className=\"px-1.5 py-0.5 text-xs bg-banana-100 text-banana-700 rounded-full\">\n              {t('export.inProgress', { count: activeTasks.length })}\n            </span>\n          )}\n        </div>\n      </button>\n      \n      {isExpanded && (\n        <div className=\"max-h-96 overflow-y-auto\">\n          {activeTasks.length > 0 && (\n            <div className=\"p-2 border-b border-gray-100 dark:border-border-primary\">\n              {activeTasks.map(task => (\n                <TaskItem \n                  key={task.id} \n                  task={task}\n                  pages={pages}\n                  onRemove={() => removeTask(task.id)}\n                />\n              ))}\n            </div>\n          )}\n          \n          {completedTasks.length > 0 && (\n            <div className=\"p-2\">\n              <div className=\"flex items-center justify-between px-3 py-1 mb-1\">\n                <span className=\"text-xs text-gray-400\">{t('shared.historyRecords')}</span>\n                <button\n                  onClick={clearCompleted}\n                  className=\"text-xs text-gray-400 hover:text-gray-600 flex items-center gap-1\"\n                >\n                  <Trash2 size={12} />\n                  {t('export.clearHistory')}\n                </button>\n              </div>\n              {completedTasks.map(task => (\n                <TaskItem \n                  key={task.id} \n                  task={task}\n                  pages={pages}\n                  onRemove={() => removeTask(task.id)}\n                />\n              ))}\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/shared/FilePreviewModal.tsx",
    "content": "import React, { useState, useEffect, useRef } from 'react';\nimport { Modal, Markdown, Loading, useToast } from '@/components/shared';\nimport { useT } from '@/hooks/useT';\nimport { getReferenceFile, type ReferenceFile } from '@/api/endpoints';\n\n// FilePreviewModal 组件自包含翻译\nconst filePreviewI18n = {\n  zh: {\n    filePreview: {\n      title: \"文件预览\", loading: \"加载文件内容中...\",\n      notParsed: \"文件尚未解析完成，无法预览\", loadFailed: \"加载文件内容失败\"\n    }\n  },\n  en: {\n    filePreview: {\n      title: \"File Preview\", loading: \"Loading file content...\",\n      notParsed: \"File not yet parsed, cannot preview\", loadFailed: \"Failed to load file content\"\n    }\n  }\n};\n\ninterface FilePreviewModalProps {\n  fileId: string | null;\n  onClose: () => void;\n}\n\nexport const FilePreviewModal: React.FC<FilePreviewModalProps> = ({\n  fileId,\n  onClose,\n}) => {\n  const t = useT(filePreviewI18n);\n  const [file, setFile] = useState<ReferenceFile | null>(null);\n  const [content, setContent] = useState<string | null>(null);\n  const [isLoading, setIsLoading] = useState(false);\n  const { show } = useToast();\n  \n  // 使用 ref 保存函数引用，避免依赖项变化导致无限循环\n  const onCloseRef = useRef(onClose);\n  const showRef = useRef(show);\n  \n  useEffect(() => {\n    onCloseRef.current = onClose;\n    showRef.current = show;\n  }, [onClose, show]);\n\n  useEffect(() => {\n    if (!fileId) {\n      setFile(null);\n      setContent(null);\n      setIsLoading(false);\n      return;\n    }\n\n    const loadFile = async () => {\n      setIsLoading(true);\n      try {\n        const response = await getReferenceFile(fileId);\n        if (response.data?.file) {\n          const fileData = response.data.file;\n          \n          // 检查文件是否已解析完成\n          if (fileData.parse_status !== 'completed') {\n            showRef.current({\n              message: t('filePreview.notParsed'),\n              type: 'info',\n            });\n            onCloseRef.current();\n            return;\n          }\n\n          setFile(fileData);\n          setContent(fileData.markdown_content || t('common.noData'));\n        }\n      } catch (error: any) {\n        console.error('Load file content failed:', error);\n        showRef.current({\n          message: error?.response?.data?.error?.message || error.message || t('filePreview.loadFailed'),\n          type: 'error',\n        });\n        setFile(null);\n        setContent(null);\n      } finally {\n        setIsLoading(false);\n      }\n    };\n\n    loadFile();\n  }, [fileId]); // 只依赖 fileId\n\n  return (\n    <Modal\n      isOpen={fileId !== null}\n      onClose={onClose}\n      title={file?.filename || t('filePreview.title')}\n      size=\"xl\"\n    >\n      {isLoading ? (\n        <div className=\"text-center py-8\">\n          <Loading message={t('filePreview.loading')} />\n        </div>\n      ) : content ? (\n        <div className=\"prose max-w-none overflow-x-hidden\">\n          <Markdown>{content}</Markdown>\n        </div>\n      ) : (\n        <div className=\"text-center py-8 text-gray-500 dark:text-foreground-tertiary\">\n          <p>{t('common.noData')}</p>\n        </div>\n      )}\n    </Modal>\n  );\n};\n\n"
  },
  {
    "path": "frontend/src/components/shared/Footer.tsx",
    "content": "import React from 'react';\nimport { Github } from 'lucide-react';\n\nconst GITHUB_REPO = 'Anionex/banana-slides';\nconst GITHUB_URL = `https://github.com/${GITHUB_REPO}`;\n\nexport const Footer: React.FC = () => {\n  const currentYear = new Date().getFullYear();\n\n  return (\n    <footer className=\"relative w-full py-6 px-4 mt-auto\">\n      <div className=\"max-w-5xl mx-auto\">\n        <div className=\"flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-6 text-sm text-gray-500 dark:text-foreground-tertiary\">\n          {/* Copyright */}\n          <div className=\"flex items-center gap-1.5\">\n            <span>© {currentYear}</span>\n            <span className=\"font-medium bg-gradient-to-r from-banana-600 to-orange-500 bg-clip-text text-transparent\">\n              蕉幻 Banana Slides\n            </span>\n          </div>\n\n          {/* Divider - 仅在大屏显示 */}\n          <span className=\"hidden sm:inline text-gray-300 dark:text-border-primary\">·</span>\n\n          {/* GitHub Link */}\n          <a\n            href={GITHUB_URL}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"inline-flex items-center gap-1.5\"\n          >\n            <Github size={16} />\n            <span>GitHub</span>\n          </a>\n        </div>\n      </div>\n    </footer>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/shared/GithubBadge.tsx",
    "content": "import React, { useEffect, useState } from 'react';\nimport { Github, Star, GitFork } from 'lucide-react';\n\nconst GITHUB_REPO = 'Anionex/banana-slides';\nconst GITHUB_URL = `https://github.com/${GITHUB_REPO}`;\n\ninterface GithubStats {\n  stars: number;\n  forks: number;\n}\n\nconst CACHE_KEY = 'github-stats-cache-v2';\nconst CACHE_DURATION = 3600 * 1000; // 1 hour\n\nexport const GithubBadge: React.FC = () => {\n  const [stats, setStats] = useState<GithubStats>({\n    stars: 0,\n    forks: 0,\n  });\n\n  useEffect(() => {\n    const fetchStats = async () => {\n      // Check cache\n      try {\n        const cached = localStorage.getItem(CACHE_KEY);\n        if (cached) {\n          const { data, timestamp } = JSON.parse(cached);\n          if (Date.now() - timestamp < CACHE_DURATION) {\n            setStats(data);\n            return;\n          }\n        }\n      } catch (e) {\n        console.warn('Failed to read github stats cache', e);\n      }\n\n      // Fetch from API\n      try {\n        const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}`);\n        if (!res.ok) throw new Error('Failed to fetch repo info');\n        const data = await res.json();\n\n        const newStats = {\n          stars: data.stargazers_count,\n          forks: data.forks_count,\n        };\n\n        setStats(newStats);\n        localStorage.setItem(CACHE_KEY, JSON.stringify({\n          data: newStats,\n          timestamp: Date.now(),\n        }));\n      } catch (error) {\n        console.error('Error fetching GitHub stats:', error);\n      }\n    };\n\n    fetchStats();\n  }, []);\n\n  const formatCount = (count: number) => {\n    if (count >= 1000) {\n      return (count / 1000).toFixed(1).replace(/\\.0$/, '') + 'k';\n    }\n    return count.toString();\n  };\n\n  return (\n    <a\n      href={GITHUB_URL}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      className=\"hidden sm:flex items-center gap-2 px-2 py-1 rounded-md\"\n      title=\"View on GitHub\"\n    >\n      {/* 左侧：GitHub Logo */}\n      <div className=\"flex items-center justify-center text-gray-700 dark:text-gray-200\">\n        <Github size={36} />\n      </div>\n\n      {/* 右侧：上下结构 (Stars & Forks) */}\n      <div className=\"flex flex-col text-[10px] leading-none gap-1 font-medium text-gray-600 dark:text-gray-400\">\n        {/* Stars */}\n        <div className=\"flex items-center gap-1\">\n          <Star size={16} />\n          <span>{formatCount(stats.stars)}</span>\n        </div>\n        \n        {/* Forks */}\n        <div className=\"flex items-center gap-1\">\n          <GitFork size={16} />\n          <span>{formatCount(stats.forks)}</span>\n        </div>\n      </div>\n    </a>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/shared/GithubRepoCard.tsx",
    "content": "import React, { useState, useEffect } from 'react';\nimport { Star, GitFork } from 'lucide-react';\n\nconst GITHUB_REPO = 'Anionex/banana-slides';\nconst GITHUB_URL = `https://github.com/${GITHUB_REPO}`;\n\ninterface RepoStats {\n  stars: number;\n  forks: number;\n}\n\nexport const GithubRepoCard: React.FC = () => {\n  const [stats, setStats] = useState<RepoStats | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    const fetchStats = async () => {\n      try {\n        // 先尝试从 localStorage 读取缓存\n        const cached = localStorage.getItem('github_repo_stats');\n        const cacheTime = localStorage.getItem('github_repo_stats_time');\n        const now = Date.now();\n\n        // 缓存有效期 10 分钟\n        if (cached && cacheTime && now - parseInt(cacheTime) < 10 * 60 * 1000) {\n          setStats(JSON.parse(cached));\n          setLoading(false);\n          return;\n        }\n\n        const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}`);\n        if (response.ok) {\n          const data = await response.json();\n          const newStats = {\n            stars: data.stargazers_count,\n            forks: data.forks_count,\n          };\n          setStats(newStats);\n          // 缓存结果\n          localStorage.setItem('github_repo_stats', JSON.stringify(newStats));\n          localStorage.setItem('github_repo_stats_time', now.toString());\n        }\n      } catch (error) {\n        console.error('Failed to fetch GitHub stats:', error);\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    fetchStats();\n  }, []);\n\n  const formatNumber = (num: number): string => {\n    if (num >= 1000) {\n      return (num / 1000).toFixed(1).replace(/\\.0$/, '') + 'k';\n    }\n    return num.toString();\n  };\n\n  return (\n    <a\n      href={GITHUB_URL}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      className=\"hidden sm:flex items-center gap-2 px-3 py-1.5 bg-gray-50 dark:bg-background-tertiary hover:bg-gray-100 dark:hover:bg-background-hover border border-gray-200 dark:border-border-primary rounded-lg transition-all duration-200 hover:shadow-sm hover:border-gray-300 dark:hover:border-border-hover group\"\n      title=\"View on GitHub\"\n    >\n      {/* GitHub 图标 */}\n      <svg\n        className=\"w-4 h-4 text-gray-700 dark:text-foreground-secondary group-hover:text-gray-900 dark:group-hover:text-white transition-colors\"\n        fill=\"currentColor\"\n        viewBox=\"0 0 24 24\"\n      >\n        <path d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\" />\n      </svg>\n\n      {/* 分隔线 */}\n      <div className=\"w-px h-4 bg-gray-300 dark:bg-border-primary\" />\n\n      {/* Star 数量 */}\n      <div className=\"flex items-center gap-1 text-gray-600 dark:text-foreground-tertiary group-hover:text-gray-800 dark:group-hover:text-foreground-secondary transition-colors\">\n        <Star size={14} className=\"text-yellow-500\" fill=\"currentColor\" />\n        <span className=\"text-xs font-medium\">\n          {loading ? '...' : stats ? formatNumber(stats.stars) : '-'}\n        </span>\n      </div>\n\n      {/* Fork 数量 */}\n      <div className=\"flex items-center gap-1 text-gray-600 dark:text-foreground-tertiary group-hover:text-gray-800 dark:group-hover:text-foreground-secondary transition-colors\">\n        <GitFork size={14} />\n        <span className=\"text-xs font-medium\">\n          {loading ? '...' : stats ? formatNumber(stats.forks) : '-'}\n        </span>\n      </div>\n    </a>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/shared/HelpModal.tsx",
    "content": "import React, { useState } from 'react';\nimport { Sparkles, FileText, Palette, MessageSquare, Download, ChevronLeft, ChevronRight, ExternalLink, Settings, Check } from 'lucide-react';\nimport { useNavigate } from 'react-router-dom';\nimport { Modal } from './Modal';\nimport { Button } from './Button';\nimport { useT } from '@/hooks/useT';\nimport { useTranslation } from 'react-i18next';\n\n// ---------------------------------------------------------------------------\n// i18n\n// ---------------------------------------------------------------------------\nconst i18nDict = {\n  zh: {\n    guide: {\n      brand: '蕉幻 · Banana Slides',\n      setup: '快速开始',\n      setupSub: '完成基础配置，开启 AI 创作之旅',\n      features: '功能介绍',\n      featuresSub: '探索如何使用 AI 快速创建精美 PPT',\n      gallery: '结果案例',\n      gallerySub: '以下是使用蕉幻生成的 PPT 案例展示',\n      galleryMore: '查看更多使用案例',\n      hi: '欢迎使用蕉幻！',\n      hiSub: '在开始前，让我们先完成基础配置',\n      s1: '配置 API Key',\n      s1d: '前往设置页面，配置项目需要使用的API服务，包括：',\n      s1i: ['您的 AI 服务提供商的 API Base 和 API Key', '配置文本、图像生成模型(banana pro)和图像描述模型', '若需要文件解析功能，请配置 MinerU Token', '若需要可编辑导出功能，请配置MinerU TOKEN 和 Baidu API KEY'],\n      s2: '保存并测试',\n      s2d: '配置完成后，务必点击「保存设置」按钮，然后在页面底部进行服务测试，确保各项服务正常工作。',\n      s3: '开始创作',\n      s3d: '配置成功后，返回首页即可开始使用 AI 生成精美的 PPT！',\n      s4: '*问题反馈',\n      s4d: '若使用过程中遇到问题，可在github issue提出',\n      issueLink: '前往Github issue',\n      settingsBtn: '前往设置页面',\n      hint: '提示',\n      hintBody: '如果您还没有 API Key，可以前往对应服务商官网注册获取。配置完成后，建议先进行服务测试，避免后续使用出现问题。',\n      prev: '上一页',\n      next: '下一页',\n      cases: { softwareDev: '软件开发最佳实践', deepseek: 'DeepSeek-V3.2技术展示', prefabFood: '预制菜智能产线装备研发和产业化', moneyHistory: '钱的演变：从贝壳到纸币的旅程' },\n      feat: {\n        paths: { t: '灵活多样的创作路径', d: '支持想法、大纲、页面描述三种起步方式，满足不同创作习惯。', items: ['一句话生成：输入一个主题，AI 自动生成结构清晰的大纲和逐页内容描述', '自然语言编辑：支持以 Vibe 形式口头修改大纲或描述，AI 实时响应调整', '大纲/描述模式：既可一键批量生成，也可手动调整细节'] },\n        parse: { t: '强大的素材解析能力', d: '上传多种格式文件，自动解析内容，为生成提供丰富素材。', items: ['多格式支持：上传 PDF/Docx/MD/Txt 等文件，后台自动解析内容', '智能提取：自动识别文本中的关键点、图片链接和图表信息', '风格参考：支持上传参考图片或模板，定制 PPT 风格'] },\n        vibe: { t: '「Vibe」式自然语言修改', d: '不再受限于复杂的菜单按钮，直接通过自然语言下达修改指令。', items: ['局部重绘：对不满意的区域进行口头式修改（如「把这个图换成饼图」）', '整页优化：基于 nano banana pro🍌 生成高清、风格统一的页面'] },\n        export: { t: '开箱即用的格式导出', d: '一键导出标准格式，直接演示无需调整。', items: ['多格式支持：一键导出标准 PPTX 或 PDF 文件', '完美适配：默认 16:9 比例，排版无需二次调整'] },\n      },\n    },\n  },\n  en: {\n    guide: {\n      brand: 'Banana Slides',\n      setup: 'Quick Start',\n      setupSub: 'Complete basic configuration and start your AI creation journey',\n      features: 'Features',\n      featuresSub: 'Explore how to use AI to quickly create beautiful PPT',\n      gallery: 'Showcases',\n      gallerySub: 'Here are PPT examples generated with Banana Slides',\n      galleryMore: 'View more examples',\n      hi: 'Welcome to Banana Slides!',\n      hiSub: \"Let's complete the basic configuration before you start\",\n      s1: 'Configure API Key',\n      s1d: 'Go to settings page to configure the API services needed for the project, including:',\n      s1i: [\"Your AI service provider's API Base and API Key\", 'Configure text, image generation model (banana pro) and image caption model', 'If you need file parsing, configure MinerU Token', 'If you need editable export, configure MinerU TOKEN and Baidu API KEY'],\n      s2: 'Save and Test',\n      s2d: 'After configuration, be sure to click \"Save Settings\" button, then test services at the bottom of the page to ensure everything works properly.',\n      s3: 'Start Creating',\n      s3d: 'After successful configuration, return to home page to start using AI to generate beautiful PPT!',\n      s4: '*Feedback',\n      s4d: 'If you encounter issues while using, please raise them on GitHub issues',\n      issueLink: 'Go to GitHub Issues',\n      settingsBtn: 'Go to Settings',\n      hint: 'Tip',\n      hintBody: \"If you don't have an API Key yet, you can register on the corresponding service provider's website. After configuration, it's recommended to test services first to avoid issues later.\",\n      prev: 'Previous',\n      next: 'Next',\n      cases: { softwareDev: 'Software Development Best Practices', deepseek: 'DeepSeek-V3.2 Technical Showcase', prefabFood: 'Prefab Food Intelligent Production Line R&D', moneyHistory: 'The Evolution of Money: From Shells to Paper' },\n      feat: {\n        paths: { t: 'Flexible Creation Paths', d: 'Support idea, outline, and page description as starting points to meet different creative habits.', items: ['One-line generation: Enter a topic, AI automatically generates a clear outline and page-by-page content description', 'Natural language editing: Support Vibe-style verbal modification of outlines or descriptions, AI responds in real-time', 'Outline/Description mode: Either batch generate with one click, or manually adjust details'] },\n        parse: { t: 'Powerful Material Parsing', d: 'Upload multiple format files, automatically parse content to provide rich materials for generation.', items: ['Multi-format support: Upload PDF/Docx/MD/Txt files, backend automatically parses content', 'Smart extraction: Automatically identify key points, image links and chart information in text', 'Style reference: Support uploading reference images or templates to customize PPT style'] },\n        vibe: { t: '\"Vibe\" Style Natural Language Editing', d: 'No longer limited by complex menu buttons, directly issue modification commands through natural language.', items: ['Partial redraw: Make verbal modifications to unsatisfying areas (e.g., \"Change this chart to a pie chart\")', 'Full page optimization: Generate HD, style-consistent pages based on nano banana pro🍌'] },\n        export: { t: 'Ready-to-Use Format Export', d: 'One-click export to standard formats, present directly without adjustments.', items: ['Multi-format support: One-click export to standard PPTX or PDF files', 'Perfect fit: Default 16:9 ratio, no secondary layout adjustments needed'] },\n      },\n    },\n  },\n};\n\n// ---------------------------------------------------------------------------\n// Static data\n// ---------------------------------------------------------------------------\nconst SHOWCASES = [\n  { img: 'https://github.com/user-attachments/assets/d58ce3f7-bcec-451d-a3b9-ca3c16223644', key: 'softwareDev' },\n  { img: 'https://github.com/user-attachments/assets/c64cd952-2cdf-4a92-8c34-0322cbf3de4e', key: 'deepseek' },\n  { img: 'https://github.com/user-attachments/assets/383eb011-a167-4343-99eb-e1d0568830c7', key: 'prefabFood' },\n  { img: 'https://github.com/user-attachments/assets/1a63afc9-ad05-4755-8480-fc4aa64987f1', key: 'moneyHistory' },\n];\n\nconst FEATURES: { key: string; icon: React.ReactNode }[] = [\n  { key: 'paths', icon: <Sparkles className=\"text-yellow-500\" size={24} /> },\n  { key: 'parse', icon: <FileText className=\"text-blue-500\" size={24} /> },\n  { key: 'vibe', icon: <MessageSquare className=\"text-green-500\" size={24} /> },\n  { key: 'export', icon: <Download className=\"text-purple-500\" size={24} /> },\n];\n\n// ---------------------------------------------------------------------------\n// Page renderers\n// ---------------------------------------------------------------------------\n/** Retrieve an array value from i18nDict by dot-path (useT only handles strings). */\nfunction tList(lang: 'zh' | 'en', path: string): string[] {\n  const dict = i18nDict[lang] as Record<string, unknown>;\n  let cur: unknown = dict;\n  for (const seg of path.split('.')) {\n    if (cur && typeof cur === 'object' && seg in (cur as Record<string, unknown>)) {\n      cur = (cur as Record<string, unknown>)[seg];\n    } else {\n      return [];\n    }\n  }\n  return Array.isArray(cur) ? cur : [];\n}\n\ntype PageRenderer = (ctx: {\n  t: ReturnType<typeof useT>;\n  lang: 'zh' | 'en';\n  navigate: ReturnType<typeof useNavigate>;\n  onClose: () => void;\n  showcaseIdx: number;\n  setShowcaseIdx: (i: number) => void;\n  expandedFeat: number | null;\n  setExpandedFeat: (i: number | null) => void;\n}) => React.ReactNode;\n\nconst renderSetupPage: PageRenderer = ({ t, lang, navigate, onClose }) => {\n  const steps = [\n    { num: '1', bg: 'bg-banana-500', content: (\n      <div className=\"flex-1 space-y-2\">\n        <h4 className=\"font-semibold text-gray-800 dark:text-foreground-primary\">{t('guide.s1')}</h4>\n        <p className=\"text-sm text-gray-600 dark:text-foreground-tertiary\">{t('guide.s1d')}</p>\n        <ul className=\"text-sm text-gray-600 dark:text-foreground-tertiary space-y-1 pl-4\">\n          {tList(lang, 'guide.s1i').map((item, i) => (\n            <li key={i}>• {item}</li>\n          ))}\n        </ul>\n      </div>\n    ), highlight: true },\n    { num: '2', bg: 'bg-orange-500', content: (\n      <div className=\"flex-1 space-y-2\">\n        <h4 className=\"font-semibold text-gray-800 dark:text-foreground-primary\">{t('guide.s2')}</h4>\n        <p className=\"text-sm text-gray-600 dark:text-foreground-tertiary\">{t('guide.s2d')}</p>\n      </div>\n    ) },\n    { num: <Check size={18} />, bg: 'bg-green-500', content: (\n      <div className=\"flex-1 space-y-2\">\n        <h4 className=\"font-semibold text-gray-800 dark:text-foreground-primary\">{t('guide.s3')}</h4>\n        <p className=\"text-sm text-gray-600 dark:text-foreground-tertiary\">{t('guide.s3d')}</p>\n      </div>\n    ) },\n  ];\n\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"text-center space-y-3\">\n        <div className=\"inline-flex items-center justify-center mr-4\">\n          <img src=\"/logo.png\" alt=\"Banana Slides Logo\" className=\"h-16 w-16 object-contain\" />\n        </div>\n        <h3 className=\"text-2xl font-bold text-gray-800 dark:text-foreground-primary\">{t('guide.hi')}</h3>\n        <p className=\"text-sm text-gray-600 dark:text-foreground-tertiary\">{t('guide.hiSub')}</p>\n      </div>\n\n      <div className=\"space-y-4\">\n        {steps.map((s, i) => (\n          <div\n            key={i}\n            className={`flex gap-4 p-4 rounded-xl border ${\n              s.highlight\n                ? 'bg-gradient-to-r from-banana-50 dark:from-background-primary to-orange-50 border-banana-200'\n                : 'bg-white dark:bg-background-secondary border-gray-200 dark:border-border-primary'\n            }`}\n          >\n            <div className={`flex-shrink-0 w-8 h-8 ${s.bg} text-white rounded-full flex items-center justify-center font-bold`}>\n              {s.num}\n            </div>\n            {s.content}\n          </div>\n        ))}\n      </div>\n\n      <div className=\"flex gap-4 p-4 bg-white dark:bg-background-secondary rounded-xl border border-gray-200 dark:border-border-primary\">\n        <div className=\"flex-shrink-0 w-8 h-8 bg-red-500 text-white rounded-full flex items-center justify-center font-bold\">4</div>\n        <div className=\"flex-1 space-y-2\">\n          <h4 className=\"font-semibold text-gray-800 dark:text-foreground-primary\">{t('guide.s4')}</h4>\n          <p className=\"text-sm text-gray-600 dark:text-foreground-tertiary\">{t('guide.s4d')}</p>\n        </div>\n        <a href=\"https://github.com/Anionex/banana-slides/issues\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"inline-flex items-center gap-1.5 text-sm text-banana-600 hover:text-banana-700 font-medium\">\n          <ExternalLink size={14} />\n          {t('guide.issueLink')}\n        </a>\n      </div>\n\n      <div className=\"flex justify-center pt-2\">\n        <Button onClick={() => { onClose(); navigate('/settings'); }} className=\"bg-banana-500 hover:bg-banana-600 text-black dark:text-white shadow-lg\" icon={<Settings size={18} />}>\n          {t('guide.settingsBtn')}\n        </Button>\n      </div>\n\n      <div className=\"bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-3\">\n        <p className=\"text-xs text-blue-800\">\n          💡 <strong>{t('guide.hint')}</strong>：{t('guide.hintBody')}\n        </p>\n      </div>\n    </div>\n  );\n};\n\nconst renderFeaturesPage: PageRenderer = ({ t, lang, expandedFeat, setExpandedFeat }) => (\n  <div className=\"space-y-3\">\n    {FEATURES.map((f, idx) => (\n      <div\n        key={f.key}\n        className={`border rounded-xl transition-all cursor-pointer ${\n          expandedFeat === idx\n            ? 'border-banana-300 bg-banana-50/50 shadow-sm dark:shadow-background-primary/30'\n            : 'border-gray-200 dark:border-border-primary hover:border-gray-300 dark:hover:border-gray-500 hover:bg-gray-50 dark:hover:bg-background-hover'\n        }`}\n        onClick={() => setExpandedFeat(expandedFeat === idx ? null : idx)}\n      >\n        <div className=\"flex items-center gap-3 p-4\">\n          <div className=\"flex-shrink-0 w-10 h-10 bg-white dark:bg-background-secondary rounded-lg shadow-sm dark:shadow-background-primary/30 flex items-center justify-center\">\n            {f.icon}\n          </div>\n          <div className=\"flex-1 min-w-0\">\n            <h4 className=\"text-base font-semibold text-gray-800 dark:text-foreground-primary\">{t(`guide.feat.${f.key}.t`)}</h4>\n            <p className=\"text-sm text-gray-500 dark:text-foreground-tertiary truncate\">{t(`guide.feat.${f.key}.d`)}</p>\n          </div>\n          <ChevronRight size={18} className={`text-gray-400 transition-transform flex-shrink-0 ${expandedFeat === idx ? 'rotate-90' : ''}`} />\n        </div>\n        {expandedFeat === idx && (\n          <div className=\"px-4 pb-4 pt-0\">\n            <div className=\"pl-13 space-y-2\">\n              {tList(lang, `guide.feat.${f.key}.items`).map((line, li) => (\n                <div key={li} className=\"flex items-start gap-2 text-sm text-gray-600 dark:text-foreground-tertiary\">\n                  <span className=\"text-banana-500 mt-1\">•</span>\n                  <span>{line}</span>\n                </div>\n              ))}\n            </div>\n          </div>\n        )}\n      </div>\n    ))}\n  </div>\n);\n\nconst renderGalleryPage: PageRenderer = ({ t, showcaseIdx, setShowcaseIdx }) => {\n  const prev = () => setShowcaseIdx(showcaseIdx === 0 ? SHOWCASES.length - 1 : showcaseIdx - 1);\n  const next = () => setShowcaseIdx(showcaseIdx === SHOWCASES.length - 1 ? 0 : showcaseIdx + 1);\n\n  return (\n    <div className=\"space-y-4\">\n      <p className=\"text-sm text-gray-600 dark:text-foreground-tertiary text-center\">{t('guide.gallerySub')}</p>\n\n      <div className=\"relative\">\n        <div className=\"aspect-video bg-gray-100 dark:bg-background-secondary rounded-xl overflow-hidden shadow-lg\">\n          <img src={SHOWCASES[showcaseIdx].img} alt={t(`guide.cases.${SHOWCASES[showcaseIdx].key}`)} className=\"w-full h-full object-cover\" />\n        </div>\n        <button onClick={prev} className=\"absolute left-2 top-1/2 -translate-y-1/2 w-10 h-10 bg-white/90 hover:bg-white rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110\">\n          <ChevronLeft size={20} />\n        </button>\n        <button onClick={next} className=\"absolute right-2 top-1/2 -translate-y-1/2 w-10 h-10 bg-white/90 hover:bg-white rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110\">\n          <ChevronRight size={20} />\n        </button>\n      </div>\n\n      <div className=\"text-center\">\n        <h3 className=\"text-lg font-semibold text-gray-800 dark:text-foreground-primary\">{t(`guide.cases.${SHOWCASES[showcaseIdx].key}`)}</h3>\n      </div>\n\n      <div className=\"flex justify-center gap-2\">\n        {SHOWCASES.map((_, i) => (\n          <button key={i} onClick={() => setShowcaseIdx(i)} className={`w-2 h-2 rounded-full transition-all ${i === showcaseIdx ? 'bg-banana-500 w-6' : 'bg-gray-300 hover:bg-gray-400'}`} />\n        ))}\n      </div>\n\n      <div className=\"grid grid-cols-4 gap-2 mt-4\">\n        {SHOWCASES.map((sc, i) => (\n          <button key={i} onClick={() => setShowcaseIdx(i)} className={`aspect-video rounded-lg overflow-hidden border-2 transition-all ${i === showcaseIdx ? 'border-banana-500 ring-2 ring-banana-200' : 'border-transparent hover:border-gray-300 dark:hover:border-gray-500'}`}>\n            <img src={sc.img} alt={t(`guide.cases.${sc.key}`)} className=\"w-full h-full object-cover\" />\n          </button>\n        ))}\n      </div>\n\n      <div className=\"text-center pt-4\">\n        <a href=\"https://github.com/Anionex/banana-slides/issues/2\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"inline-flex items-center gap-1.5 text-sm text-banana-600 hover:text-banana-700 font-medium\">\n          <ExternalLink size={14} />\n          {t('guide.galleryMore')}\n        </a>\n      </div>\n    </div>\n  );\n};\n\n// ---------------------------------------------------------------------------\n// Pages definition\n// ---------------------------------------------------------------------------\ninterface PageDef {\n  titleKey: string;\n  subtitleKey: string;\n  render: PageRenderer;\n}\n\nconst PAGES: PageDef[] = [\n  { titleKey: 'guide.setup', subtitleKey: 'guide.setupSub', render: renderSetupPage },\n  { titleKey: 'guide.features', subtitleKey: 'guide.featuresSub', render: renderFeaturesPage },\n  { titleKey: 'guide.gallery', subtitleKey: 'guide.gallerySub', render: renderGalleryPage },\n];\n\n// ---------------------------------------------------------------------------\n// Component\n// ---------------------------------------------------------------------------\ninterface HelpModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n}\n\nexport const HelpModal: React.FC<HelpModalProps> = ({ isOpen, onClose }) => {\n  const t = useT(i18nDict);\n  const { i18n } = useTranslation();\n  const lang: 'zh' | 'en' = i18n.language?.startsWith('zh') ? 'zh' : 'en';\n  const navigate = useNavigate();\n  const [pageIdx, setPageIdx] = useState(0);\n  const [showcaseIdx, setShowcaseIdx] = useState(0);\n  const [expandedFeat, setExpandedFeat] = useState<number | null>(null);\n\n  const page = PAGES[pageIdx];\n\n  return (\n    <Modal isOpen={isOpen} onClose={onClose} title=\"\" size=\"lg\">\n      <div className=\"space-y-6\">\n        {/* header */}\n        <div className=\"text-center pb-4 border-b border-gray-100 dark:border-border-primary\">\n          <div className=\"inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-banana-50 dark:from-background-primary to-orange-50 rounded-full mb-3\">\n            <Palette size={18} className=\"text-banana-600\" />\n            <span className=\"text-sm font-medium text-gray-700 dark:text-foreground-secondary\">{t('guide.brand')}</span>\n          </div>\n          <h2 className=\"text-2xl font-bold text-gray-800 dark:text-foreground-primary\">{t(page.titleKey)}</h2>\n          <p className=\"text-sm text-gray-500 dark:text-foreground-tertiary mt-1\">{t(page.subtitleKey)}</p>\n        </div>\n\n        {/* dots */}\n        <div className=\"flex justify-center gap-2\">\n          {PAGES.map((p, i) => (\n            <button\n              key={i}\n              onClick={() => setPageIdx(i)}\n              className={`h-2 rounded-full transition-all ${i === pageIdx ? 'bg-banana-500 w-8' : 'bg-gray-300 hover:bg-gray-400 w-2'}`}\n              title={t(p.titleKey)}\n            />\n          ))}\n        </div>\n\n        {/* body */}\n        <div className=\"min-h-[400px]\">\n          {page.render({ t, lang, navigate, onClose, showcaseIdx, setShowcaseIdx, expandedFeat, setExpandedFeat })}\n        </div>\n\n        {/* footer */}\n        <div className=\"pt-4 border-t flex justify-between items-center\">\n          <div className=\"flex items-center gap-2\">\n            {pageIdx > 0 && (\n              <Button variant=\"ghost\" onClick={() => setPageIdx(pageIdx - 1)} icon={<ChevronLeft size={16} />} size=\"sm\">\n                {t('guide.prev')}\n              </Button>\n            )}\n          </div>\n\n          <a href=\"https://github.com/Anionex/banana-slides\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-sm text-gray-500 dark:text-foreground-tertiary hover:text-gray-700 dark:hover:text-gray-200 flex items-center gap-1\">\n            <ExternalLink size={14} />\n            GitHub\n          </a>\n\n          <div className=\"flex items-center gap-2\">\n            {pageIdx < PAGES.length - 1 ? (\n              <Button onClick={() => setPageIdx(pageIdx + 1)} icon={<ChevronRight size={16} />} size=\"sm\" className=\"bg-banana-500 hover:bg-banana-600 text-black dark:text-white\">\n                {t('guide.next')}\n              </Button>\n            ) : (\n              <Button variant=\"ghost\" onClick={onClose} size=\"sm\">\n                {t('common.close')}\n              </Button>\n            )}\n          </div>\n        </div>\n      </div>\n    </Modal>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/shared/ImagePreviewList.tsx",
    "content": "import React, { useMemo } from 'react';\nimport { X } from 'lucide-react';\nimport { useT } from '@/hooks/useT';\nimport { isUploadingUrl, getUploadingPreviewUrl } from '@/hooks/useImagePaste';\n\n// ImagePreviewList 组件自包含翻译\nconst imagePreviewI18n = {\n  zh: {\n    imagePreview: { title: \"图片预览\", removeImage: \"移除图片\", imageLoadFailed: \"图片加载失败\" }\n  },\n  en: {\n    imagePreview: { title: \"Image Preview\", removeImage: \"Remove Image\", imageLoadFailed: \"Image load failed\" }\n  }\n};\n\ninterface ImagePreviewListProps {\n  content: string;\n  onRemoveImage?: (imageUrl: string) => void;\n  className?: string;\n}\n\n/**\n * 解析markdown文本中的图片链接\n * 支持格式: ![alt](url) 或 ![](url)\n */\nconst parseMarkdownImages = (text: string): Array<{ url: string; alt: string; fullMatch: string }> => {\n  const imageRegex = /!\\[([^\\]]*)\\]\\(([^)]+)\\)/g;\n  const images: Array<{ url: string; alt: string; fullMatch: string }> = [];\n  let match;\n\n  while ((match = imageRegex.exec(text)) !== null) {\n    images.push({\n      alt: match[1] || 'image',\n      url: match[2],\n      fullMatch: match[0]\n    });\n  }\n\n  return images;\n};\n\n/**\n * 图片预览列表组件 - 横向滚动\n * 解析并显示编辑框中的所有markdown图片\n */\nexport const ImagePreviewList: React.FC<ImagePreviewListProps> = ({\n  content,\n  onRemoveImage,\n  className = ''\n}) => {\n  const t = useT(imagePreviewI18n);\n  // 解析图片列表\n  const images = useMemo(() => parseMarkdownImages(content), [content]);\n\n  // 如果没有图片，不显示组件\n  if (images.length === 0) {\n    return null;\n  }\n\n  return (\n    <div className={`${className}`}>\n      <div className=\"flex items-center gap-2 mb-2\">\n        <span className=\"text-sm font-medium text-gray-700 dark:text-foreground-secondary\">\n          {t('imagePreview.title')} ({images.length})\n        </span>\n      </div>\n\n      {/* 横向滚动容器 */}\n      <div className=\"flex gap-3 overflow-x-auto pb-2\">\n        {images.map((image, index) => {\n          const uploading = isUploadingUrl(image.url);\n          const imgSrc = uploading ? getUploadingPreviewUrl(image.url) : image.url;\n\n          return (\n            <div\n              key={`${image.url}-${index}`}\n              className=\"relative flex-shrink-0 group\"\n            >\n              {/* 图片容器 */}\n              <div className=\"relative w-32 h-32 bg-gray-100 dark:bg-background-secondary rounded-lg overflow-hidden border-2 border-gray-200 dark:border-border-primary hover:border-banana-400 transition-colors\">\n                <img\n                  src={imgSrc}\n                  alt={image.alt}\n                  className={`w-full h-full object-cover ${uploading ? 'opacity-50' : ''}`}\n                  onError={(e) => {\n                    const target = e.target as HTMLImageElement;\n                    target.style.display = 'none';\n                    const parent = target.parentElement;\n                    if (parent && !parent.querySelector('.error-placeholder')) {\n                      const placeholder = document.createElement('div');\n                      placeholder.className = 'error-placeholder w-full h-full flex items-center justify-center text-gray-400 text-xs text-center p-2';\n                      placeholder.textContent = t('imagePreview.imageLoadFailed');\n                      parent.appendChild(placeholder);\n                    }\n                  }}\n                />\n\n                {/* 上传中遮罩 */}\n                {uploading && (\n                  <div className=\"absolute inset-0 flex items-center justify-center\">\n                    <div className=\"w-5 h-5 border-2 border-banana-500 border-t-transparent rounded-full animate-spin\" />\n                  </div>\n                )}\n\n                {/* 删除按钮 */}\n                {onRemoveImage && !uploading && (\n                  <button\n                    onClick={() => onRemoveImage(image.url)}\n                    className=\"absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-600 active:scale-95\"\n                    title={t('imagePreview.removeImage')}\n                  >\n                    <X size={14} />\n                  </button>\n                )}\n\n                {/* 悬浮时显示图片描述 */}\n                <div className=\"absolute inset-x-0 bottom-0 bg-black/70 text-white text-xs p-1 opacity-0 group-hover:opacity-100 transition-opacity truncate\">\n                  {image.alt !== 'image' ? image.alt : decodeURIComponent(imgSrc.split('/').pop()?.replace(/_\\d+\\./, '.') || '')}\n                </div>\n              </div>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n};\n\nexport default ImagePreviewList;\n"
  },
  {
    "path": "frontend/src/components/shared/Input.tsx",
    "content": "import React from 'react';\nimport { cn } from '@/utils';\n\ninterface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {\n  label?: string;\n  error?: string;\n}\n\nexport const Input: React.FC<InputProps> = ({\n  label,\n  error,\n  className,\n  ...props\n}) => {\n  return (\n    <div className=\"w-full\">\n      {label && (\n        <label className=\"block text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-2\">\n          {label}\n        </label>\n      )}\n      <input\n        className={cn(\n          'w-full h-10 px-4 rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary',\n          'focus:outline-none focus:ring-2 focus:ring-banana-500 focus:border-transparent',\n          'placeholder:text-gray-400 dark:placeholder:text-gray-500 transition-all',\n          'text-gray-900 dark:text-foreground-primary',\n          error && 'border-red-500 focus:ring-red-500',\n          className\n        )}\n        {...props}\n      />\n      {error && (\n        <p className=\"mt-1 text-sm text-red-500\">{error}</p>\n      )}\n    </div>\n  );\n};\n\n"
  },
  {
    "path": "frontend/src/components/shared/Loading.tsx",
    "content": "import React, { useEffect, useRef } from 'react';\nimport { ArrowLeft } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '@/utils';\n\ninterface ProgressData {\n  total: number;\n  completed: number;\n  percent?: number;\n  current_step?: string;\n  messages?: string[];\n}\n\ninterface LoadingProps {\n  fullscreen?: boolean;\n  message?: string;\n  progress?: ProgressData;\n  /** Callback when user clicks \"Run in Background\" button */\n  onBackgroundClick?: () => void;\n  /** Label for the background button */\n  backgroundButtonLabel?: string;\n}\n\nexport const Loading: React.FC<LoadingProps> = ({\n  fullscreen = false,\n  message,\n  progress,\n  onBackgroundClick,\n  backgroundButtonLabel,\n}) => {\n  const { t } = useTranslation();\n  const messagesEndRef = useRef<HTMLDivElement>(null);\n  const defaultMessage = message || t('common.loading');\n  const defaultBackgroundLabel = backgroundButtonLabel || t('common.runInBackground');\n  \n  // 自动滚动到最新消息\n  useEffect(() => {\n    if (messagesEndRef.current) {\n      messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });\n    }\n  }, [progress?.messages]);\n  \n  // 计算进度百分比\n  const getPercent = () => {\n    if (!progress) return 0;\n    if (progress.percent !== undefined) return progress.percent;\n    if (progress.total > 0) return Math.round((progress.completed / progress.total) * 100);\n    return 0;\n  };\n  \n  const percent = getPercent();\n  const hasMessages = progress?.messages && progress.messages.length > 0;\n  \n  const content = (\n    <div className=\"flex flex-col items-center justify-center max-w-md w-full px-4\">\n      {/* 加载图标 */}\n      <div className=\"relative w-12 h-12 mb-4\">\n        <div className=\"absolute inset-0 border-4 border-banana-100 rounded-full\" />\n        <div className=\"absolute inset-0 border-4 border-banana-500 rounded-full border-t-transparent animate-spin\" />\n      </div>\n      \n      {/* 消息 */}\n      <p className=\"text-lg text-gray-700 dark:text-foreground-secondary mb-4 text-center\">{defaultMessage}</p>\n\n      {/* 进度条 */}\n      {progress && (\n        <div className=\"w-full\">\n          <div className=\"flex justify-end text-sm text-gray-600 dark:text-foreground-tertiary mb-2\">\n            <span className=\"font-medium\">{percent}%</span>\n          </div>\n          <div className=\"h-2 bg-gray-200 dark:bg-background-hover rounded-full overflow-hidden\">\n            <div\n              className=\"h-full bg-gradient-to-r from-banana-500 to-banana-600 transition-all duration-300\"\n              style={{ width: `${percent}%` }}\n            />\n          </div>\n        </div>\n      )}\n\n      {/* 滚动消息日志 */}\n      {hasMessages && (\n        <div className=\"w-full mt-4\">\n          <div className=\"bg-banana-50 dark:bg-background-secondary border border-banana-200 dark:border-border-primary rounded-lg p-3 h-32 overflow-y-auto text-xs\">\n            {progress.messages!.map((msg, index) => (\n              <div\n                key={index}\n                className={cn(\n                  \"py-0.5\",\n                  index === progress.messages!.length - 1\n                    ? \"text-banana-700 dark:text-banana-400 font-medium\"\n                    : \"text-gray-500 dark:text-foreground-tertiary\"\n                )}\n              >\n                <span className=\"text-banana-400 mr-2\">›</span>\n                {msg}\n              </div>\n            ))}\n            <div ref={messagesEndRef} />\n          </div>\n        </div>\n      )}\n    </div>\n  );\n\n  if (fullscreen) {\n    return (\n      <div className=\"fixed inset-0 bg-white/90 dark:bg-background-primary/90 backdrop-blur-sm flex items-center justify-center z-50\">\n        {/* Background button - top left corner */}\n        {onBackgroundClick && (\n          <button\n            onClick={onBackgroundClick}\n            className=\"absolute top-4 left-4 flex items-center gap-2 px-4 py-2 text-sm text-gray-600 dark:text-foreground-tertiary hover:text-banana-600 bg-white/80 dark:bg-background-secondary/80 hover:bg-banana-50 dark:hover:bg-background-hover rounded-lg border border-gray-200 dark:border-border-primary shadow-sm transition-colors\"\n          >\n            <ArrowLeft size={16} />\n            {defaultBackgroundLabel}\n          </button>\n        )}\n        {content}\n      </div>\n    );\n  }\n\n  return content;\n};\n\n// 骨架屏组件\nexport const Skeleton: React.FC<{ className?: string }> = ({ className }) => {\n  return (\n    <div\n      className={cn(\n        'animate-shimmer bg-gradient-to-r from-gray-200 dark:from-background-hover via-banana-50 dark:via-background-elevated to-gray-200 dark:to-background-hover',\n        'bg-[length:200%_100%]',\n        className\n      )}\n    />\n  );\n};\n\n"
  },
  {
    "path": "frontend/src/components/shared/Markdown.tsx",
    "content": "import React, { useMemo } from 'react';\nimport ReactMarkdown from 'react-markdown';\nimport remarkGfm from 'remark-gfm';\nimport remarkBreaks from 'remark-breaks';\nimport remarkMath from 'remark-math';\nimport rehypeRaw from 'rehype-raw';\nimport rehypeKatex from 'rehype-katex';\nimport rehypeSanitize, { defaultSchema } from 'rehype-sanitize';\nimport 'katex/dist/katex.min.css';\n\ninterface MarkdownProps {\n  children: string;\n  className?: string;\n}\n\n/**\n * Preprocess LaTeX delimiters that remark-math doesn't support natively.\n * Converts \\[...\\] to $$...$$ and \\(...\\) to $...$\n */\nfunction preprocessMarkdown(content: string): string {\n  // Convert \\[...\\] block math to $$...$$\n  content = content.replace(/\\\\\\[([\\s\\S]*?)\\\\\\]/g, (_, math) => `$$${math}$$`);\n  // Convert \\(...\\) inline math to $...$\n  content = content.replace(/\\\\\\(([\\s\\S]*?)\\\\\\)/g, (_, math) => `$${math}$`);\n  // 表格前必须有空行才能被解析，自动补空行\n  content = content.replace(/([^\\n])\\n(\\|[^\\n]+\\|\\s*\\n\\|[\\s:|-]+\\|\\s*\\n)/g, '$1\\n\\n$2');\n  return content;\n}\n\nexport const Markdown: React.FC<MarkdownProps> = ({ children, className = '' }) => {\n  const processedContent = useMemo(() => preprocessMarkdown(children), [children]);\n\n  // Create sanitize schema that allows KaTeX classes and spans\n  const sanitizeSchema = useMemo(() => ({\n    ...defaultSchema,\n    attributes: {\n      ...defaultSchema.attributes,\n      span: [...(defaultSchema.attributes?.span || []), 'className', 'style'],\n      div: [...(defaultSchema.attributes?.div || []), 'className'],\n    },\n    tagNames: [...(defaultSchema.tagNames || []), 'math', 'semantics', 'mrow', 'msup', 'mi', 'mn', 'mo'],\n  }), []);\n\n  return (\n    <div className={`markdown-content ${className}`}>\n      <ReactMarkdown\n        remarkPlugins={[remarkGfm, remarkBreaks, remarkMath]}\n        rehypePlugins={[rehypeKatex, rehypeRaw, [rehypeSanitize, sanitizeSchema]]}\n        components={{\n        // 自定义渲染规则\n        p: ({ children }) => <p className=\"mb-2 last:mb-0\">{children}</p>,\n        ul: ({ children }) => <ul className=\"list-disc list-inside space-y-1\">{children}</ul>,\n        ol: ({ children }) => <ol className=\"list-decimal list-inside space-y-1\">{children}</ol>,\n        li: ({ children }) => <li className=\"text-sm\">{children}</li>,\n        a: ({ href, children }) => (\n          <a href={href} target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-blue-600 hover:text-blue-800 underline\">\n            {children}\n          </a>\n        ),\n        img: ({ src, alt }) => (\n          <img\n            src={src}\n            alt={alt || ''}\n            className=\"max-w-48 max-h-36 w-auto h-auto rounded-lg my-2\"\n            loading=\"lazy\"\n          />\n        ),\n        h1: ({ children }) => <h1 className=\"text-xl font-bold mb-2\">{children}</h1>,\n        h2: ({ children }) => <h2 className=\"text-lg font-bold mb-2\">{children}</h2>,\n        h3: ({ children }) => <h3 className=\"text-base font-bold mb-2\">{children}</h3>,\n        code: ({ className, children }) => {\n          const isInline = !className;\n          return isInline ? (\n            <code className=\"bg-gray-100 dark:bg-background-secondary px-1 py-0.5 rounded text-sm font-mono\">{children}</code>\n          ) : (\n            <code className={`${className} block bg-gray-100 dark:bg-background-secondary p-2 rounded text-sm font-mono overflow-x-auto`}>\n              {children}\n            </code>\n          );\n        },\n        strong: ({ children }) => <strong className=\"font-semibold\">{children}</strong>,\n        em: ({ children }) => <em className=\"italic\">{children}</em>,\n        br: () => <br />,\n        table: ({ children }) => (\n          <div className=\"overflow-x-auto my-4\">\n            <table className=\"min-w-full border-collapse border border-gray-300 dark:border-border-primary\">\n              {children}\n            </table>\n          </div>\n        ),\n        thead: ({ children }) => <thead className=\"bg-gray-100 dark:bg-background-secondary\">{children}</thead>,\n        tbody: ({ children }) => <tbody>{children}</tbody>,\n        tr: ({ children }) => <tr className=\"border-b border-gray-300 dark:border-border-primary\">{children}</tr>,\n        th: ({ children }) => (\n          <th className=\"border border-gray-300 dark:border-border-primary px-4 py-2 text-left font-semibold\">\n            {children}\n          </th>\n        ),\n        td: ({ children }) => (\n          <td className=\"border border-gray-300 dark:border-border-primary px-4 py-2\">\n            {children}\n          </td>\n        ),\n      }}\n      >\n        {processedContent}\n      </ReactMarkdown>\n    </div>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/shared/MarkdownTextarea.tsx",
    "content": "import React, { useRef, useEffect, useCallback, useState, useMemo, forwardRef, useImperativeHandle } from 'react';\nimport { cn } from '@/utils';\nimport { useT } from '@/hooks/useT';\nimport { isUploadingUrl, getUploadingPreviewUrl } from '@/hooks/useImagePaste';\n\nconst markdownTextareaI18n = {\n  zh: {\n    markdownTextarea: {\n      dropImages: '拖放图片到此处',\n      uploadImage: '上传图片',\n      imageDescription: '图片描述',\n      doubleClickToEdit: '双击编辑描述',\n      uploading: '上传中...',\n    }\n  },\n  en: {\n    markdownTextarea: {\n      dropImages: 'Drop images here',\n      uploadImage: 'Upload image',\n      imageDescription: 'Image description',\n      doubleClickToEdit: 'Double-click to edit description',\n      uploading: 'Uploading...',\n    }\n  }\n};\n\nconst IMAGE_REGEX = /!\\[([^\\]]*)\\]\\(([^)]+)\\)/g;\nconst CHIP_SELECTED_CLASS = 'md-chip-selected';\nconst CHIP_CLASS = 'md-chip';\n\ninterface MarkdownTextareaProps {\n  value: string;\n  onChange: (value: string) => void;\n  onPaste?: (e: React.ClipboardEvent<HTMLDivElement>) => void;\n  /** Called when files are dropped or selected via upload button */\n  onFiles?: (files: File[]) => void;\n  onBlur?: () => void;\n  onFocus?: () => void;\n  placeholder?: string;\n  label?: string;\n  error?: string;\n  className?: string;\n  rows?: number;\n  /** Show the inline image upload button. Default: true when onFiles is provided */\n  showUploadButton?: boolean;\n  /** Extra content rendered on the left side of the toolbar (after built-in buttons) */\n  toolbarLeft?: React.ReactNode;\n  /** Content rendered on the right side of the toolbar */\n  toolbarRight?: React.ReactNode;\n  /** Show compact image preview strip. Default: true */\n  showImagePreview?: boolean;\n}\n\n/** Ref handle for MarkdownTextarea */\nexport interface MarkdownTextareaRef {\n  /** Insert text at the current cursor position */\n  insertAtCursor: (text: string) => void;\n  /** Focus the editor */\n  focus: () => void;\n}\n\nfunction escapeHtml(text: string) {\n  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\n}\n\ntype Segment =\n  | { type: 'text'; content: string }\n  | { type: 'image'; alt: string; url: string; raw: string };\n\nfunction parseSegments(text: string): Segment[] {\n  const segments: Segment[] = [];\n  let lastIndex = 0;\n  const regex = new RegExp(IMAGE_REGEX.source, 'g');\n  let match;\n  while ((match = regex.exec(text)) !== null) {\n    if (match.index > lastIndex) {\n      segments.push({ type: 'text', content: text.slice(lastIndex, match.index) });\n    }\n    segments.push({ type: 'image', alt: match[1] || 'image', url: match[2], raw: match[0] });\n    lastIndex = regex.lastIndex;\n  }\n  if (lastIndex < text.length) {\n    segments.push({ type: 'text', content: text.slice(lastIndex) });\n  }\n  return segments;\n}\n\nfunction serializeDOM(element: HTMLElement): string {\n  let result = '';\n  for (const node of Array.from(element.childNodes)) {\n    if (node.nodeType === Node.TEXT_NODE) {\n      result += (node.textContent || '').replace(/\\u200B/g, '');\n    } else if (node.nodeType === Node.ELEMENT_NODE) {\n      const el = node as HTMLElement;\n      if (el.tagName === 'BR') {\n        result += '\\n';\n      } else if (el.dataset.markdown) {\n        result += el.dataset.markdown;\n      } else if (el.tagName === 'DIV') {\n        if (node !== element.firstChild) result += '\\n';\n        result += serializeDOM(el);\n      } else {\n        result += serializeDOM(el);\n      }\n    }\n  }\n  return result;\n}\n\nfunction getDisplayName(alt: string, url: string): string {\n  if (alt && alt !== 'image') return alt;\n  const filename = url.split('/').pop() || 'image';\n  try {\n    return decodeURIComponent(filename.replace(/_\\d{10,}\\./, '.'));\n  } catch {\n    return filename;\n  }\n}\n\nconst IMAGE_ICON = '<svg class=\"flex-shrink-0\" width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\"/><path d=\"m21 15-5-5L5 21\"/></svg>';\nconst SPINNER_ICON = '<span class=\"inline-block w-3 h-3 border-2 border-gray-500 border-t-transparent rounded-full animate-spin flex-shrink-0 dark:border-gray-300 dark:border-t-transparent\"></span>';\n\nfunction applyChipContent(chip: HTMLElement, seg: { alt: string; url: string; raw: string }, tooltips?: { edit: string; uploading: string }) {\n  const uploading = isUploadingUrl(seg.url);\n  chip.dataset.markdown = seg.raw;\n  chip.dataset.alt = seg.alt;\n  chip.dataset.url = seg.url;\n  chip.title = uploading\n    ? (tooltips?.uploading || '')\n    : (tooltips?.edit || '');\n  chip.className = [\n    CHIP_CLASS,\n    'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium',\n    'cursor-default select-none align-middle mx-0.5 transition-colors',\n    uploading\n      ? 'bg-amber-50 text-amber-700 border border-amber-200 dark:bg-amber-900/30 dark:text-amber-300 dark:border-amber-700'\n      : 'bg-gray-100 text-gray-700 border border-gray-200 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-600',\n  ].join(' ');\n  const displayName = getDisplayName(seg.alt, seg.url);\n  chip.innerHTML = `${uploading ? SPINNER_ICON : IMAGE_ICON}<span style=\"max-width:150px\" class=\"truncate\">${escapeHtml(displayName)}</span>`;\n}\n\nfunction buildDOM(container: HTMLElement, segments: Segment[], tooltips?: { edit: string; uploading: string }) {\n  container.innerHTML = '';\n  for (const segment of segments) {\n    if (segment.type === 'text') {\n      const lines = segment.content.split('\\n');\n      lines.forEach((line, i) => {\n        if (i > 0) container.appendChild(document.createElement('br'));\n        if (line) container.appendChild(document.createTextNode(line));\n      });\n    } else {\n      const chip = document.createElement('span');\n      chip.contentEditable = 'false';\n      applyChipContent(chip, segment, tooltips);\n      container.appendChild(chip);\n    }\n  }\n  if (container.childNodes.length === 0) {\n    container.appendChild(document.createElement('br'));\n  }\n}\n\n/**\n * Try to patch chips in-place when only image URLs/alt changed.\n * Returns true if successful (cursor preserved), false if full rebuild needed.\n */\nfunction patchChips(container: HTMLElement, oldValue: string, newValue: string, tooltips?: { edit: string; uploading: string }): boolean {\n  const oldSegs = parseSegments(oldValue);\n  const newSegs = parseSegments(newValue);\n\n  if (oldSegs.length !== newSegs.length) return false;\n\n  for (let i = 0; i < oldSegs.length; i++) {\n    if (oldSegs[i].type !== newSegs[i].type) return false;\n    if (oldSegs[i].type === 'text' && newSegs[i].type === 'text') {\n      if ((oldSegs[i] as { content: string }).content !== (newSegs[i] as { content: string }).content) return false;\n    }\n  }\n\n  // Structure matches — update changed chips in place\n  const chips = Array.from(container.querySelectorAll('.' + CHIP_CLASS)) as HTMLElement[];\n  let chipIdx = 0;\n  for (let i = 0; i < newSegs.length; i++) {\n    if (newSegs[i].type === 'image') {\n      const newSeg = newSegs[i] as { alt: string; url: string; raw: string };\n      const oldSeg = oldSegs[i] as { raw: string };\n      const chip = chips[chipIdx++];\n      if (chip && oldSeg.raw !== newSeg.raw) {\n        applyChipContent(chip, newSeg, tooltips);\n      }\n    }\n  }\n  return true;\n}\n\nfunction getChipBeforeCursor(): HTMLElement | null {\n  const sel = window.getSelection();\n  if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;\n  const range = sel.getRangeAt(0);\n  if (range.startContainer.nodeType === Node.TEXT_NODE && range.startOffset === 0) {\n    const prev = range.startContainer.previousSibling as HTMLElement | null;\n    if (prev?.dataset?.markdown) return prev;\n  }\n  if (range.startContainer.nodeType === Node.ELEMENT_NODE && range.startOffset > 0) {\n    const prev = range.startContainer.childNodes[range.startOffset - 1] as HTMLElement | null;\n    if (prev?.dataset?.markdown) return prev;\n  }\n  return null;\n}\n\nfunction getChipAfterCursor(): HTMLElement | null {\n  const sel = window.getSelection();\n  if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;\n  const range = sel.getRangeAt(0);\n  if (range.startContainer.nodeType === Node.TEXT_NODE) {\n    if (range.startOffset === (range.startContainer.textContent || '').length) {\n      const next = range.startContainer.nextSibling as HTMLElement | null;\n      if (next?.dataset?.markdown) return next;\n    }\n  }\n  if (range.startContainer.nodeType === Node.ELEMENT_NODE) {\n    const next = range.startContainer.childNodes[range.startOffset] as HTMLElement | null;\n    if (next?.dataset?.markdown) return next;\n  }\n  return null;\n}\n\nfunction clearChipSelection(container: HTMLElement) {\n  container.querySelectorAll('.' + CHIP_SELECTED_CLASS).forEach(el => {\n    el.classList.remove(CHIP_SELECTED_CLASS, 'ring-2', 'ring-red-400', 'bg-red-50', 'dark:bg-red-900/30');\n  });\n}\n\nfunction selectChip(chip: HTMLElement) {\n  chip.classList.add(CHIP_SELECTED_CLASS, 'ring-2', 'ring-red-400', 'bg-red-50', 'dark:bg-red-900/30');\n}\n\nexport const MarkdownTextarea = forwardRef<MarkdownTextareaRef, MarkdownTextareaProps>(({\n  value,\n  onChange,\n  onPaste,\n  onFiles,\n  onBlur,\n  onFocus,\n  placeholder,\n  label,\n  error,\n  className,\n  rows = 4,\n  showUploadButton,\n  toolbarLeft,\n  toolbarRight,\n  showImagePreview = true,\n}, ref) => {\n  const t = useT(markdownTextareaI18n);\n  const editorRef = useRef<HTMLDivElement>(null);\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const lastValueRef = useRef(value);\n  const isInternalRef = useRef(false);\n  const [isDragging, setIsDragging] = useState(false);\n  const [editingChip, setEditingChip] = useState<{ chip: HTMLElement; rect: DOMRect } | null>(null);\n  const [editAlt, setEditAlt] = useState('');\n  const editInputRef = useRef<HTMLInputElement>(null);\n  const dragCountRef = useRef(0);\n\n  const shouldShowUpload = showUploadButton ?? !!onFiles;\n  const hasToolbar = shouldShowUpload || toolbarLeft || toolbarRight;\n\n  // Keep chip tooltips in a ref so imperative DOM functions can read the latest i18n\n  const chipTooltipsRef = useRef({ edit: '', uploading: '' });\n  chipTooltipsRef.current = {\n    edit: t('markdownTextarea.doubleClickToEdit'),\n    uploading: t('markdownTextarea.uploading'),\n  };\n\n  // Initial render\n  useEffect(() => {\n    if (editorRef.current) {\n      buildDOM(editorRef.current, parseSegments(value), chipTooltipsRef.current);\n      lastValueRef.current = value;\n    }\n  }, []);\n\n  // Sync from external value changes — incremental patch when possible\n  useEffect(() => {\n    if (isInternalRef.current) {\n      isInternalRef.current = false;\n      // Even when skipping internal edits, check for external changes\n      // batched in the same render (e.g. upload completion while typing)\n      if (editorRef.current && value !== lastValueRef.current) {\n        if (!patchChips(editorRef.current, lastValueRef.current, value, chipTooltipsRef.current)) {\n          buildDOM(editorRef.current, parseSegments(value), chipTooltipsRef.current);\n        }\n        lastValueRef.current = value;\n      }\n      return;\n    }\n    if (editorRef.current && value !== lastValueRef.current) {\n      // Try incremental update first (preserves cursor position)\n      const patched = patchChips(editorRef.current, lastValueRef.current, value, chipTooltipsRef.current);\n      if (!patched) {\n        // Structure changed — full rebuild (e.g. new placeholder inserted)\n        buildDOM(editorRef.current, parseSegments(value), chipTooltipsRef.current);\n      }\n      lastValueRef.current = value;\n    }\n  }, [value]);\n\n  // Focus edit input when editing chip\n  useEffect(() => {\n    if (editingChip && editInputRef.current) {\n      editInputRef.current.focus();\n      editInputRef.current.select();\n    }\n  }, [editingChip]);\n\n  const emitChange = useCallback(() => {\n    if (!editorRef.current) return;\n    const markdown = serializeDOM(editorRef.current);\n    isInternalRef.current = true;\n    lastValueRef.current = markdown;\n    onChange(markdown);\n  }, [onChange]);\n\n  // Shared function to insert content (text + chips) at cursor position\n  const insertContentAtCursor = useCallback((text: string) => {\n    if (!editorRef.current) return;\n\n    // Parse the text to find image markdown and insert chips directly\n    const segments = parseSegments(text);\n    const sel = window.getSelection();\n    if (!sel || sel.rangeCount === 0) {\n      // Fallback: just insert as text\n      document.execCommand('insertText', false, text);\n      emitChange();\n      return;\n    }\n\n    const range = sel.getRangeAt(0);\n    range.deleteContents();\n\n    // Insert segments in order\n    for (const segment of segments) {\n      if (segment.type === 'text') {\n        // Insert text nodes, handling newlines\n        const lines = segment.content.split('\\n');\n        lines.forEach((line, i) => {\n          if (i > 0) {\n            range.insertNode(document.createElement('br'));\n            range.collapse(false);\n          }\n          if (line) {\n            const textNode = document.createTextNode(line);\n            range.insertNode(textNode);\n            range.setStartAfter(textNode);\n            range.collapse(true);\n          }\n        });\n      } else {\n        // Insert chip element directly\n        const chip = document.createElement('span');\n        chip.contentEditable = 'false';\n        applyChipContent(chip, segment, chipTooltipsRef.current);\n        range.insertNode(chip);\n        range.setStartAfter(chip);\n        range.collapse(true);\n      }\n    }\n\n    // Update selection to after inserted content\n    sel.removeAllRanges();\n    sel.addRange(range);\n\n    emitChange();\n  }, [emitChange]);\n\n  // Expose insertAtCursor method via ref for external use (e.g., useImagePaste)\n  useImperativeHandle(ref, () => ({\n    insertAtCursor: (text: string) => {\n      if (!editorRef.current) return;\n      editorRef.current.focus();\n      insertContentAtCursor(text);\n    },\n    focus: () => {\n      editorRef.current?.focus();\n    },\n  }), [insertContentAtCursor]);\n\n  // --- Chip editing ---\n  const startEditChip = useCallback((chip: HTMLElement) => {\n    if (isUploadingUrl(chip.dataset.url || '')) return;\n    const rect = chip.getBoundingClientRect();\n    const containerRect = editorRef.current?.closest('.relative')?.getBoundingClientRect();\n    if (!containerRect) return;\n    setEditAlt(chip.dataset.alt || '');\n    setEditingChip({\n      chip,\n      rect: new DOMRect(\n        rect.left - containerRect.left,\n        rect.bottom - containerRect.top + 4,\n        rect.width,\n        rect.height,\n      ),\n    });\n  }, []);\n\n  const commitChipEdit = useCallback(() => {\n    if (!editingChip) return;\n    const { chip } = editingChip;\n    const url = chip.dataset.url || '';\n    const newAlt = editAlt.trim() || 'image';\n    const newMarkdown = `![${newAlt}](${url})`;\n    chip.dataset.markdown = newMarkdown;\n    chip.dataset.alt = newAlt;\n    const nameSpan = chip.querySelector('.truncate');\n    if (nameSpan) nameSpan.textContent = newAlt;\n    setEditingChip(null);\n    emitChange();\n  }, [editingChip, editAlt, emitChange]);\n\n  const cancelChipEdit = useCallback(() => {\n    setEditingChip(null);\n  }, []);\n\n  // --- Key handling ---\n  const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (!editorRef.current) return;\n\n    if (e.key === 'Backspace') {\n      const selected = editorRef.current.querySelector('.' + CHIP_SELECTED_CLASS) as HTMLElement | null;\n      if (selected) {\n        e.preventDefault();\n        selected.remove();\n        emitChange();\n        return;\n      }\n      const chip = getChipBeforeCursor();\n      if (chip) {\n        e.preventDefault();\n        selectChip(chip);\n        return;\n      }\n    } else if (e.key === 'Delete') {\n      const selected = editorRef.current.querySelector('.' + CHIP_SELECTED_CLASS) as HTMLElement | null;\n      if (selected) {\n        e.preventDefault();\n        selected.remove();\n        emitChange();\n        return;\n      }\n      const chip = getChipAfterCursor();\n      if (chip) {\n        e.preventDefault();\n        selectChip(chip);\n        return;\n      }\n    } else {\n      clearChipSelection(editorRef.current);\n    }\n  }, [emitChange]);\n\n  const handleInput = useCallback(() => {\n    if (editorRef.current) clearChipSelection(editorRef.current);\n    emitChange();\n  }, [emitChange]);\n\n  const handlePaste = useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {\n    onPaste?.(e);\n    if (!e.defaultPrevented) {\n      e.preventDefault();\n      const text = e.clipboardData.getData('text/plain');\n      // Use insertContentAtCursor to properly handle markdown images as chips\n      insertContentAtCursor(text);\n    }\n  }, [onPaste, insertContentAtCursor]);\n\n  const handleCopy = useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {\n    const selection = window.getSelection();\n    if (!selection || selection.rangeCount === 0) return;\n    const range = selection.getRangeAt(0);\n    const fragment = range.cloneContents();\n    const tempDiv = document.createElement('div');\n    tempDiv.appendChild(fragment);\n    const markdown = serializeDOM(tempDiv);\n    e.preventDefault();\n    e.clipboardData.setData('text/plain', markdown);\n  }, []);\n\n  const handleCut = useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {\n    const selection = window.getSelection();\n    if (!selection || selection.rangeCount === 0) return;\n    const range = selection.getRangeAt(0);\n    const fragment = range.cloneContents();\n    const tempDiv = document.createElement('div');\n    tempDiv.appendChild(fragment);\n    const markdown = serializeDOM(tempDiv);\n    e.preventDefault();\n    e.clipboardData.setData('text/plain', markdown);\n    // Delete the selected content after copying\n    range.deleteContents();\n    emitChange();\n  }, [emitChange]);\n\n  const handleClick = useCallback(() => {\n    if (editorRef.current) clearChipSelection(editorRef.current);\n    setEditingChip(null);\n  }, []);\n\n  const handleDoubleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {\n    const target = (e.target as HTMLElement).closest('.' + CHIP_CLASS) as HTMLElement | null;\n    if (target?.dataset?.markdown) {\n      e.preventDefault();\n      startEditChip(target);\n    }\n  }, [startEditChip]);\n\n  // --- Drag and drop ---\n  const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {\n    e.preventDefault();\n    dragCountRef.current++;\n    if (e.dataTransfer.types.includes('Files')) {\n      setIsDragging(true);\n    }\n  }, []);\n\n  const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {\n    e.preventDefault();\n    dragCountRef.current--;\n    if (dragCountRef.current === 0) setIsDragging(false);\n  }, []);\n\n  const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {\n    e.preventDefault();\n    e.dataTransfer.dropEffect = 'copy';\n  }, []);\n\n  const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {\n    e.preventDefault();\n    dragCountRef.current = 0;\n    setIsDragging(false);\n    if (onFiles && e.dataTransfer.files.length > 0) {\n      onFiles(Array.from(e.dataTransfer.files));\n    }\n  }, [onFiles]);\n\n  const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {\n    const files = e.target.files;\n    if (files && files.length > 0 && onFiles) {\n      onFiles(Array.from(files));\n    }\n    e.target.value = '';\n  }, [onFiles]);\n\n  // Click on toolbar empty area → focus editor\n  const handleToolbarMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {\n    // Only handle clicks on the toolbar itself, not on buttons inside\n    if (e.target === e.currentTarget || !(e.target as HTMLElement).closest('button, a, input, [role=\"button\"]')) {\n      e.preventDefault(); // prevent editor blur\n      editorRef.current?.focus();\n    }\n  }, []);\n\n  // --- Image preview ---\n  const images = useMemo(() =>\n    parseSegments(value).filter((s): s is Extract<Segment, { type: 'image' }> => s.type === 'image'),\n    [value]\n  );\n\n  const removeImage = useCallback((url: string) => {\n    const escaped = url.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n    const regex = new RegExp(`!\\\\[[^\\\\]]*\\\\]\\\\(${escaped}\\\\)\\\\n?`, 'g');\n    const newValue = value.replace(regex, '').replace(/\\n{3,}/g, '\\n\\n').trim();\n    if (editorRef.current) {\n      buildDOM(editorRef.current, parseSegments(newValue), chipTooltipsRef.current);\n    }\n    isInternalRef.current = true;\n    lastValueRef.current = newValue;\n    onChange(newValue);\n  }, [value, onChange]);\n\n  const minHeight = rows * 24;\n  const isEmpty = !value.trim();\n\n  return (\n    <div className=\"w-full\">\n      {label && (\n        <label className=\"block text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-2\">\n          {label}\n        </label>\n      )}\n      {/* Outer container — owns the border, focus ring, and toolbar */}\n      <div className={cn(\n        'rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary',\n        'focus-within:ring-2 focus-within:ring-banana-500 focus-within:border-transparent',\n        'transition-all',\n        isDragging && 'ring-2 ring-banana-400 border-banana-400 bg-banana-50/50 dark:bg-banana-900/10',\n        error && 'border-red-500 focus-within:ring-red-500',\n        className\n      )}>\n        {/* Editor area */}\n        <div className=\"relative\">\n          <div\n            ref={editorRef}\n            contentEditable\n            role=\"textbox\"\n            aria-multiline=\"true\"\n            onKeyDown={handleKeyDown}\n            onInput={handleInput}\n            onPaste={handlePaste}\n            onCopy={handleCopy}\n            onCut={handleCut}\n            onClick={handleClick}\n            onDoubleClick={handleDoubleClick}\n            onDragEnter={handleDragEnter}\n            onDragLeave={handleDragLeave}\n            onDragOver={handleDragOver}\n            onDrop={handleDrop}\n            onBlur={onBlur}\n            onFocus={onFocus}\n            style={{ minHeight: `${minHeight}px` }}\n            className=\"w-full px-4 py-3 outline-none overflow-y-auto resize-y whitespace-pre-wrap break-words text-gray-900 dark:text-foreground-primary\"\n          />\n\n          {/* Placeholder */}\n          {isEmpty && placeholder && !isDragging && (\n            <div className=\"absolute top-0 left-0 right-0 px-4 py-3 text-gray-400 dark:text-gray-500 pointer-events-none select-none\">\n              {placeholder}\n            </div>\n          )}\n\n          {/* Drag overlay */}\n          {isDragging && (\n            <div className=\"absolute inset-0 flex items-center justify-center rounded-lg pointer-events-none\">\n              <div className=\"flex items-center gap-2 px-4 py-2 bg-banana-100 dark:bg-banana-900/50 rounded-full text-sm font-medium text-banana-700 dark:text-banana-300\">\n                <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                  <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\"/><path d=\"m21 15-5-5L5 21\"/>\n                </svg>\n                {t('markdownTextarea.dropImages')}\n              </div>\n            </div>\n          )}\n\n          {/* Chip edit popover */}\n          {editingChip && (\n            <div\n              className=\"absolute z-20 flex items-center gap-1 bg-white dark:bg-background-secondary border border-gray-300 dark:border-border-primary rounded-lg shadow-lg p-1\"\n              style={{ left: editingChip.rect.left, top: editingChip.rect.top }}\n            >\n              <input\n                ref={editInputRef}\n                type=\"text\"\n                value={editAlt}\n                onChange={(e) => setEditAlt(e.target.value)}\n                onKeyDown={(e) => {\n                  if (e.key === 'Enter') { e.preventDefault(); commitChipEdit(); }\n                  if (e.key === 'Escape') cancelChipEdit();\n                }}\n                onBlur={commitChipEdit}\n                className=\"px-2 py-1 text-xs border-none outline-none bg-transparent w-36 text-gray-900 dark:text-foreground-primary\"\n                placeholder={t('markdownTextarea.imageDescription')}\n              />\n            </div>\n          )}\n        </div>\n\n        {/* Toolbar */}\n        {hasToolbar && (\n          <div\n            className=\"flex items-center gap-1 px-2 py-1.5 cursor-text\"\n            onMouseDown={handleToolbarMouseDown}\n          >\n            <div className=\"flex items-center gap-0.5\">\n              {shouldShowUpload && (\n                <button\n                  type=\"button\"\n                  onClick={() => fileInputRef.current?.click()}\n                  className=\"p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:text-foreground-tertiary dark:hover:text-foreground-secondary dark:hover:bg-background-hover rounded transition-colors cursor-pointer\"\n                  title={t('markdownTextarea.uploadImage')}\n                >\n                  <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                    <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\"/><path d=\"m21 15-5-5L5 21\"/>\n                  </svg>\n                </button>\n              )}\n              {toolbarLeft}\n            </div>\n            <div className=\"flex-1\" />\n            {toolbarRight && (\n              <div className=\"flex items-center gap-1\">\n                {toolbarRight}\n              </div>\n            )}\n          </div>\n        )}\n\n        {/* Compact image preview strip — below toolbar */}\n        {showImagePreview && images.length > 0 && (\n          <div className=\"flex items-center gap-2 px-3 py-2 overflow-x-auto border-t border-gray-100 dark:border-border-primary\">\n            {images.map((img, i) => {\n              const uploading = isUploadingUrl(img.url);\n              const src = uploading ? getUploadingPreviewUrl(img.url) : img.url;\n              return (\n                <div key={`${img.url}-${i}`} className=\"relative flex-shrink-0 group/thumb\" title={img.alt !== 'image' ? img.alt : getDisplayName(img.alt, img.url)}>\n                  <div className={cn(\n                    'w-14 h-14 rounded overflow-hidden border border-gray-200 dark:border-border-primary',\n                    uploading && 'opacity-60'\n                  )}>\n                    <img\n                      src={src}\n                      alt={img.alt}\n                      className=\"w-full h-full object-cover\"\n                      onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}\n                    />\n                    {uploading && (\n                      <div className=\"absolute inset-0 flex items-center justify-center\">\n                        <div className=\"w-4 h-4 border-2 border-banana-500 border-t-transparent rounded-full animate-spin\" />\n                      </div>\n                    )}\n                  </div>\n                  {!uploading && (\n                    <button\n                      type=\"button\"\n                      onClick={() => removeImage(img.url)}\n                      className=\"absolute -top-1.5 -right-1.5 w-5 h-5 flex items-center justify-center bg-red-500 text-white rounded-full opacity-0 group-hover/thumb:opacity-100 transition-opacity hover:bg-red-600 text-xs leading-none\"\n                    >\n                      &times;\n                    </button>\n                  )}\n                  <div className=\"absolute inset-x-0 bottom-0 bg-black/60 text-white text-[10px] px-1 py-0.5 truncate opacity-0 group-hover/thumb:opacity-100 transition-opacity rounded-b\">\n                    {img.alt !== 'image' ? img.alt : getDisplayName(img.alt, img.url)}\n                  </div>\n                </div>\n              );\n            })}\n          </div>\n        )}\n      </div>\n\n      {/* Hidden file input for image upload */}\n      {shouldShowUpload && (\n        <input\n          ref={fileInputRef}\n          type=\"file\"\n          accept=\"image/*\"\n          multiple\n          onChange={handleFileInput}\n          className=\"hidden\"\n        />\n      )}\n\n      {error && (\n        <p className=\"mt-1 text-sm text-red-500\">{error}</p>\n      )}\n    </div>\n  );\n});\n\n// Add display name for better debugging\nMarkdownTextarea.displayName = 'MarkdownTextarea';\n"
  },
  {
    "path": "frontend/src/components/shared/MaterialCenterModal.tsx",
    "content": "import React, { useReducer, useEffect, useCallback } from 'react';\nimport { ImageIcon, RefreshCw, Upload, Download, X, FolderOpen, Eye, ArrowUpDown } from 'lucide-react';\nimport { Button } from './Button';\nimport { useT } from '@/hooks/useT';\nimport { useToast } from './Toast';\nimport { Modal } from './Modal';\nimport { listMaterials, uploadMaterial, listProjects, deleteMaterial, downloadMaterialsZip, type Material } from '@/api/endpoints';\nimport type { Project } from '@/types';\nimport { getImageUrl } from '@/api/client';\n\n// ---------------------------------------------------------------------------\n// i18n\n// ---------------------------------------------------------------------------\nconst i18nDict = {\n  zh: {\n    mc: {\n      title: '素材中心',\n      count: '共 {{count}} 个素材',\n      empty: '暂无素材',\n      selected: '已选 {{count}} 个',\n      filterAll: '全部素材',\n      filterNone: '未关联项目',\n      moreProjects: '+ 更多项目…',\n      preview: '预览',\n      remove: '删除',\n      closePreview: '关闭预览',\n      emptyHint: '上传图片或通过素材生成功能创建素材',\n      msg: {\n        loadErr: '加载素材失败',\n        badFormat: '不支持的图片格式',\n        uploaded: '素材上传成功',\n        uploadErr: '上传素材失败',\n        noId: '无法删除：缺少素材ID',\n        deleted: '素材已删除',\n        deleteErr: '删除素材失败',\n        downloaded: '下载成功',\n        downloadErr: '下载失败',\n        zipped: '已打包 {{count}} 个素材',\n        zipErr: '批量下载失败',\n        pickFirst: '请先选择要下载的素材',\n      },\n    },\n  },\n  en: {\n    mc: {\n      title: 'Material Center',\n      count: '{{count}} materials',\n      empty: 'No materials',\n      selected: '{{count}} selected',\n      filterAll: 'All Materials',\n      filterNone: 'Unassociated',\n      moreProjects: '+ More projects…',\n      preview: 'Preview',\n      remove: 'Delete',\n      closePreview: 'Close Preview',\n      emptyHint: 'Upload images or create materials via the generator',\n      msg: {\n        loadErr: 'Failed to load materials',\n        badFormat: 'Unsupported image format',\n        uploaded: 'Material uploaded',\n        uploadErr: 'Failed to upload material',\n        noId: 'Cannot delete: missing material ID',\n        deleted: 'Material deleted',\n        deleteErr: 'Failed to delete material',\n        downloaded: 'Download complete',\n        downloadErr: 'Download failed',\n        zipped: 'Packaged {{count}} materials',\n        zipErr: 'Batch download failed',\n        pickFirst: 'Select materials to download first',\n      },\n    },\n  },\n};\n\n// ---------------------------------------------------------------------------\n// State\n// ---------------------------------------------------------------------------\ninterface State {\n  items: Material[];\n  selected: Set<string>;\n  deleting: Set<string>;\n  loading: boolean;\n  uploading: boolean;\n  downloading: boolean;\n  filter: string;\n  sortBy: 'newest' | 'oldest' | 'name-asc' | 'name-desc';\n  projects: Project[];\n  projectsReady: boolean;\n  preview: { url: string; label: string } | null;\n}\n\ntype Action =\n  | { type: 'SET_ITEMS'; items: Material[] }\n  | { type: 'TOGGLE_SELECT'; key: string }\n  | { type: 'SELECT_ALL'; keys: string[] }\n  | { type: 'CLEAR_SELECTION' }\n  | { type: 'SET_LOADING'; on: boolean }\n  | { type: 'SET_UPLOADING'; on: boolean }\n  | { type: 'SET_DOWNLOADING'; on: boolean }\n  | { type: 'SET_FILTER'; value: string }\n  | { type: 'SET_SORT'; value: State['sortBy'] }\n  | { type: 'SET_PROJECTS'; list: Project[] }\n  | { type: 'REMOVE_ITEM'; key: string }\n  | { type: 'ADD_DELETING'; id: string }\n  | { type: 'REMOVE_DELETING'; id: string }\n  | { type: 'SET_PREVIEW'; preview: State['preview'] }\n  | { type: 'RESET_EPHEMERAL' };\n\nconst initial: State = {\n  items: [],\n  selected: new Set(),\n  deleting: new Set(),\n  loading: false,\n  uploading: false,\n  downloading: false,\n  filter: 'all',\n  sortBy: 'newest',\n  projects: [],\n  projectsReady: false,\n  preview: null,\n};\n\nfunction reducer(s: State, a: Action): State {\n  switch (a.type) {\n    case 'SET_ITEMS':\n      return { ...s, items: a.items, loading: false };\n    case 'TOGGLE_SELECT': {\n      const next = new Set(s.selected);\n      next.has(a.key) ? next.delete(a.key) : next.add(a.key);\n      return { ...s, selected: next };\n    }\n    case 'SELECT_ALL':\n      return { ...s, selected: new Set(a.keys) };\n    case 'CLEAR_SELECTION':\n      return { ...s, selected: new Set() };\n    case 'SET_LOADING':\n      return { ...s, loading: a.on };\n    case 'SET_UPLOADING':\n      return { ...s, uploading: a.on };\n    case 'SET_DOWNLOADING':\n      return { ...s, downloading: a.on };\n    case 'SET_FILTER':\n      return { ...s, filter: a.value };\n    case 'SET_SORT':\n      return { ...s, sortBy: a.value };\n    case 'SET_PROJECTS':\n      return { ...s, projects: a.list, projectsReady: true };\n    case 'REMOVE_ITEM': {\n      const items = s.items.filter((m) => m.id !== a.key);\n      const selected = new Set(s.selected);\n      selected.delete(a.key);\n      return { ...s, items, selected };\n    }\n    case 'ADD_DELETING': {\n      const d = new Set(s.deleting);\n      d.add(a.id);\n      return { ...s, deleting: d };\n    }\n    case 'REMOVE_DELETING': {\n      const d = new Set(s.deleting);\n      d.delete(a.id);\n      return { ...s, deleting: d };\n    }\n    case 'SET_PREVIEW':\n      return { ...s, preview: a.preview };\n    case 'RESET_EPHEMERAL':\n      return { ...s, selected: new Set(), showAllProjects: false, preview: null };\n    default:\n      return s;\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\nconst displayName = (m: Material) =>\n  m.prompt?.trim() ||\n  m.name?.trim() ||\n  m.original_filename?.trim() ||\n  m.source_filename?.trim() ||\n  m.filename ||\n  m.url;\n\nconst ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp', 'image/bmp', 'image/svg+xml'];\n\nconst projectLabel = (p: Project) => {\n  const raw = p.idea_prompt || p.outline_text || `Project ${p.project_id.slice(0, 8)}`;\n  return raw.length > 20 ? `${raw.slice(0, 20)}…` : raw;\n};\n\n// ---------------------------------------------------------------------------\n// Sub-components\n// ---------------------------------------------------------------------------\nconst ToolbarSection: React.FC<{\n  t: ReturnType<typeof useT>;\n  state: State;\n  dispatch: React.Dispatch<Action>;\n  onRefresh: () => void;\n  onUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;\n  onDownload: () => void;\n}> = ({ t, state, dispatch, onRefresh, onUpload, onDownload }) => (\n  <div className=\"space-y-2\">\n    <div className=\"flex items-center justify-between flex-wrap gap-2\">\n      <div className=\"flex items-center gap-2 text-sm text-gray-600 dark:text-foreground-tertiary\">\n        <FolderOpen size={16} className=\"text-banana-500\" />\n        <span>\n          {state.items.length > 0\n            ? t('mc.count', { count: state.items.length })\n            : t('mc.empty')}\n        </span>\n        {state.selected.size > 0 && (\n          <span className=\"ml-2 text-banana-600 font-medium\">\n            {t('mc.selected', { count: state.selected.size })}\n          </span>\n        )}\n        {state.loading && state.items.length > 0 && (\n          <RefreshCw size={14} className=\"animate-spin text-gray-400\" />\n        )}\n      </div>\n\n      <div className=\"flex items-center gap-2 flex-wrap\">\n        {/* 项目筛选下拉框 */}\n        <select\n          value={state.filter}\n          onChange={(e) => dispatch({ type: 'SET_FILTER', value: e.target.value })}\n          className=\"px-3 py-1.5 text-sm text-gray-700 dark:text-foreground-secondary bg-transparent hover:bg-gray-100 dark:hover:bg-background-hover rounded-md focus:outline-none transition-colors cursor-pointer\"\n        >\n          <option value=\"all\">{t('mc.filterAll')}</option>\n          <option value=\"none\">{t('mc.filterNone')}</option>\n          {state.projects.map((p) => (\n            <option key={p.project_id} value={p.project_id} title={p.idea_prompt || p.outline_text}>\n              {projectLabel(p)}\n            </option>\n          ))}\n        </select>\n\n        {/* 排序循环按钮 */}\n        <button\n          onClick={() => {\n            const order: Array<State['sortBy']> = ['newest', 'oldest', 'name-asc', 'name-desc'];\n            const currentIndex = order.indexOf(state.sortBy);\n            const nextIndex = (currentIndex + 1) % order.length;\n            dispatch({ type: 'SET_SORT', value: order[nextIndex] });\n          }}\n          className=\"flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-700 dark:text-foreground-secondary hover:bg-gray-100 dark:hover:bg-background-hover rounded-md transition-colors\"\n        >\n          <ArrowUpDown size={14} />\n          <span>\n            {state.sortBy === 'newest' && '从新到旧'}\n            {state.sortBy === 'oldest' && '从旧到新'}\n            {state.sortBy === 'name-asc' && 'A-Z'}\n            {state.sortBy === 'name-desc' && 'Z-A'}\n          </span>\n        </button>\n\n        <Button variant=\"ghost\" size=\"sm\" icon={<RefreshCw size={16} />} onClick={onRefresh} disabled={state.loading}>\n          {t('common.refresh')}\n        </Button>\n\n        <label className=\"inline-block cursor-pointer\">\n          <div className=\"inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-foreground-secondary bg-white dark:bg-background-secondary border border-gray-300 dark:border-border-primary rounded-md hover:bg-gray-50 dark:hover:bg-background-hover disabled:opacity-50 disabled:cursor-not-allowed\">\n            <Upload size={16} />\n            <span>{state.uploading ? t('common.uploading') : t('common.upload')}</span>\n          </div>\n          <input type=\"file\" accept=\"image/*\" onChange={onUpload} className=\"hidden\" disabled={state.uploading} />\n        </label>\n      </div>\n    </div>\n\n    {state.items.length > 0 && (\n      <div className=\"flex items-center gap-2 p-2 bg-gray-50 dark:bg-background-primary rounded-lg\">\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={() =>\n            state.selected.size === state.items.length\n              ? dispatch({ type: 'CLEAR_SELECTION' })\n              : dispatch({ type: 'SELECT_ALL', keys: state.items.map((m) => m.id) })\n          }\n        >\n          {state.selected.size === state.items.length ? t('common.deselectAll') : t('common.selectAll')}\n        </Button>\n        {state.selected.size > 0 && (\n          <>\n            <Button variant=\"ghost\" size=\"sm\" onClick={() => dispatch({ type: 'CLEAR_SELECTION' })}>\n              {t('common.clearSelection')}\n            </Button>\n            <div className=\"flex-1\" />\n            <Button\n              variant=\"primary\"\n              size=\"sm\"\n              icon={<Download size={16} />}\n              onClick={onDownload}\n              disabled={state.downloading}\n            >\n              {state.downloading ? t('common.downloading') : `${t('common.download')} (${state.selected.size})`}\n            </Button>\n          </>\n        )}\n      </div>\n    )}\n  </div>\n);\n\nconst MaterialGrid: React.FC<{\n  items: Material[];\n  selected: Set<string>;\n  deleting: Set<string>;\n  t: ReturnType<typeof useT>;\n  onToggle: (id: string) => void;\n  onPreview: (e: React.MouseEvent, m: Material) => void;\n  onDelete: (e: React.MouseEvent<HTMLButtonElement>, m: Material) => void;\n}> = ({ items, selected, deleting, t, onToggle, onPreview, onDelete }) => (\n  <div className=\"grid grid-cols-4 gap-4 max-h-96 overflow-y-auto p-4\">\n    {items.map((m) => {\n      const sel = selected.has(m.id);\n      const busy = deleting.has(m.id);\n      return (\n        <div\n          key={m.id}\n          onClick={() => onToggle(m.id)}\n          className={`aspect-video rounded-lg border-2 cursor-pointer transition-all relative group ${\n            sel ? 'border-banana-500 ring-2 ring-banana-200' : 'border-gray-200 dark:border-border-primary hover:border-banana-300'\n          }`}\n        >\n          <img src={getImageUrl(m.url)} alt={displayName(m)} className=\"absolute inset-0 w-full h-full object-cover rounded-md\" />\n\n          <button\n            type=\"button\"\n            onClick={(e) => onPreview(e, m)}\n            className=\"absolute top-1 left-1 w-6 h-6 bg-black/60 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow z-10 hover:bg-black/80\"\n            aria-label={t('mc.preview')}\n          >\n            <Eye size={12} />\n          </button>\n\n          <button\n            type=\"button\"\n            onClick={(e) => onDelete(e, m)}\n            disabled={busy}\n            className=\"absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow z-10 disabled:opacity-60 disabled:cursor-not-allowed\"\n            aria-label={t('mc.remove')}\n          >\n            {busy ? <RefreshCw size={12} className=\"animate-spin\" /> : <X size={12} />}\n          </button>\n\n          {sel && (\n            <div className=\"absolute inset-0 bg-banana-500 bg-opacity-20 flex items-center justify-center rounded-md\">\n              <div className=\"bg-banana-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs font-bold\">✓</div>\n            </div>\n          )}\n\n          <div className=\"absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs p-1 truncate opacity-0 group-hover:opacity-100 transition-opacity rounded-b-md\">\n            {displayName(m)}\n          </div>\n        </div>\n      );\n    })}\n  </div>\n);\n\nconst PreviewOverlay: React.FC<{ url: string; label: string; t: ReturnType<typeof useT>; onClose: () => void }> = ({\n  url,\n  label,\n  t,\n  onClose,\n}) => (\n  <div className=\"fixed inset-0 bg-black/80 flex items-center justify-center z-[60]\" onClick={onClose}>\n    <div className=\"relative max-w-[90vw] max-h-[90vh]\">\n      <button\n        type=\"button\"\n        onClick={onClose}\n        className=\"absolute -top-10 right-0 text-white hover:text-gray-300 transition-colors\"\n        aria-label={t('mc.closePreview')}\n      >\n        <X size={24} />\n      </button>\n      <img src={url} alt={label} className=\"max-w-full max-h-[85vh] object-contain rounded-lg\" onClick={(e) => e.stopPropagation()} />\n      <div className=\"text-center text-white text-sm mt-2 truncate max-w-[90vw]\">{label}</div>\n    </div>\n  </div>\n);\n\n// ---------------------------------------------------------------------------\n// Main component\n// ---------------------------------------------------------------------------\ninterface MaterialCenterModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n}\n\nexport const MaterialCenterModal: React.FC<MaterialCenterModalProps> = ({ isOpen, onClose }) => {\n  const t = useT(i18nDict);\n  const { show } = useToast();\n  const [s, dispatch] = useReducer(reducer, initial);\n\n  const fetchItems = useCallback(async () => {\n    dispatch({ type: 'SET_LOADING', on: true });\n    try {\n      const target = s.filter === 'all' ? 'all' : s.filter === 'none' ? 'none' : s.filter;\n      const res = await listMaterials(target);\n      dispatch({ type: 'SET_ITEMS', items: res.data?.materials ?? [] });\n    } catch (err: any) {\n      dispatch({ type: 'SET_LOADING', on: false });\n      show({ message: err?.response?.data?.error?.message || err.message || t('mc.msg.loadErr'), type: 'error' });\n    }\n  }, [s.filter, show, t]);\n\n  const fetchProjects = useCallback(async () => {\n    try {\n      const res = await listProjects(100, 0);\n      if (res.data?.projects) dispatch({ type: 'SET_PROJECTS', list: res.data.projects });\n    } catch {\n      /* non-critical */\n    }\n  }, []);\n\n  useEffect(() => {\n    if (!isOpen) return;\n    if (!s.projectsReady) fetchProjects();\n    fetchItems();\n    dispatch({ type: 'RESET_EPHEMERAL' });\n  }, [isOpen, s.filter]);\n\n  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    if (!file) return;\n    if (!ACCEPTED_TYPES.includes(file.type)) {\n      show({ message: t('mc.msg.badFormat'), type: 'error' });\n      return;\n    }\n    dispatch({ type: 'SET_UPLOADING', on: true });\n    try {\n      const pid = s.filter === 'all' || s.filter === 'none' ? null : s.filter;\n      await uploadMaterial(file, pid);\n      show({ message: t('mc.msg.uploaded'), type: 'success' });\n      fetchItems();\n    } catch (err: any) {\n      show({ message: err?.response?.data?.error?.message || err.message || t('mc.msg.uploadErr'), type: 'error' });\n    } finally {\n      dispatch({ type: 'SET_UPLOADING', on: false });\n      e.target.value = '';\n    }\n  };\n\n  const handleDelete = async (e: React.MouseEvent<HTMLButtonElement>, m: Material) => {\n    e.stopPropagation();\n    if (!m.id) {\n      show({ message: t('mc.msg.noId'), type: 'error' });\n      return;\n    }\n    dispatch({ type: 'ADD_DELETING', id: m.id });\n    try {\n      await deleteMaterial(m.id);\n      dispatch({ type: 'REMOVE_ITEM', key: m.id });\n      show({ message: t('mc.msg.deleted'), type: 'success' });\n    } catch (err: any) {\n      show({ message: err?.response?.data?.error?.message || err.message || t('mc.msg.deleteErr'), type: 'error' });\n    } finally {\n      dispatch({ type: 'REMOVE_DELETING', id: m.id });\n    }\n  };\n\n  const handleDownload = async () => {\n    if (s.selected.size === 0) {\n      show({ message: t('mc.msg.pickFirst'), type: 'info' });\n      return;\n    }\n    const chosen = s.items.filter((m) => s.selected.has(m.id));\n\n    if (chosen.length === 1) {\n      try {\n        const blob = await fetch(getImageUrl(chosen[0].url)).then((r) => r.blob());\n        const href = URL.createObjectURL(blob);\n        const link = Object.assign(document.createElement('a'), {\n          href,\n          download: chosen[0].filename || 'material.png',\n        });\n        document.body.appendChild(link);\n        link.click();\n        document.body.removeChild(link);\n        URL.revokeObjectURL(href);\n        show({ message: t('mc.msg.downloaded'), type: 'success' });\n      } catch (err) {\n        console.error('Download failed:', err);\n        show({ message: t('mc.msg.downloadErr'), type: 'error' });\n      }\n      return;\n    }\n\n    dispatch({ type: 'SET_DOWNLOADING', on: true });\n    try {\n      await downloadMaterialsZip(chosen.map((m) => m.id));\n      show({ message: t('mc.msg.zipped', { count: chosen.length }), type: 'success' });\n    } catch (err: any) {\n      show({ message: err?.response?.data?.error?.message || err.message || t('mc.msg.zipErr'), type: 'error' });\n    } finally {\n      dispatch({ type: 'SET_DOWNLOADING', on: false });\n    }\n  };\n\n  const handlePreview = (e: React.MouseEvent, m: Material) => {\n    e.stopPropagation();\n    dispatch({ type: 'SET_PREVIEW', preview: { url: getImageUrl(m.url), label: displayName(m) } });\n  };\n\n  const sortedItems = [...s.items].sort((a, b) => {\n    switch (s.sortBy) {\n      case 'newest':\n        return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();\n      case 'oldest':\n        return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();\n      case 'name-asc':\n        return displayName(a).localeCompare(displayName(b));\n      case 'name-desc':\n        return displayName(b).localeCompare(displayName(a));\n      default:\n        return 0;\n    }\n  });\n\n  return (\n    <>\n      <Modal isOpen={isOpen} onClose={onClose} title={t('mc.title')} size=\"lg\">\n        <div className=\"space-y-4\">\n          <ToolbarSection t={t} state={s} dispatch={dispatch} onRefresh={fetchItems} onUpload={handleUpload} onDownload={handleDownload} />\n\n          {s.loading && s.items.length === 0 ? (\n            <div className=\"flex items-center justify-center py-12\">\n              <div className=\"text-gray-400\">{t('common.loading')}</div>\n            </div>\n          ) : s.items.length === 0 ? (\n            <div className=\"flex flex-col items-center justify-center py-12 text-gray-400 p-4\">\n              <ImageIcon size={48} className=\"mb-4 opacity-50\" />\n              <div className=\"text-sm\">{t('mc.empty')}</div>\n              <div className=\"text-xs mt-1\">{t('mc.emptyHint')}</div>\n            </div>\n          ) : (\n            <MaterialGrid\n              items={sortedItems}\n              selected={s.selected}\n              deleting={s.deleting}\n              t={t}\n              onToggle={(id) => dispatch({ type: 'TOGGLE_SELECT', key: id })}\n              onPreview={handlePreview}\n              onDelete={handleDelete}\n            />\n          )}\n\n          <div className=\"pt-4 border-t flex justify-end\">\n            <Button variant=\"ghost\" onClick={onClose}>\n              {t('common.close')}\n            </Button>\n          </div>\n        </div>\n      </Modal>\n\n      {s.preview && (\n        <PreviewOverlay\n          url={s.preview.url}\n          label={s.preview.label}\n          t={t}\n          onClose={() => dispatch({ type: 'SET_PREVIEW', preview: null })}\n        />\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/shared/MaterialGeneratorModal.tsx",
    "content": "import React, { useState, useEffect, useRef } from 'react';\nimport { Image as ImageIcon, ImagePlus, Upload, X, FolderOpen, Info } from 'lucide-react';\nimport { Modal } from './Modal';\nimport { useT } from '@/hooks/useT';\nimport { Textarea } from './Textarea';\nimport { Button } from './Button';\nimport { useToast } from './Toast';\nimport { MaterialSelector, materialUrlToFile } from './MaterialSelector';\nimport { ASPECT_RATIO_OPTIONS } from '@/config/aspectRatio';\nimport { useProjectStore } from '@/store/useProjectStore';\n\n// MaterialGeneratorModal 组件自包含翻译\nconst materialGeneratorI18n = {\n  zh: {\n    material: {\n      title: \"素材生成\", saveToLibraryNote: \"生成的素材会保存到素材库\",\n      generatedResult: \"生成结果\", generatedMaterial: \"生成的素材\", generatedPreview: \"生成的素材会展示在这里\",\n      promptLabel: \"提示词（原样发送给文生图模型）\",\n      promptPlaceholder: \"例如：蓝紫色渐变背景，带几何图形和科技感线条，用于科技主题标题页...\",\n      aspectRatioLabel: \"生成比例\",\n      referenceImages: \"参考图片（可选）\", mainReference: \"主参考图（可选）\", extraReference: \"额外参考图（可选，多张）\",\n      clickToUpload: \"点击上传\", selectFromLibrary: \"从素材库选择\", generateMaterial: \"生成素材\",\n      messages: {\n        enterPrompt: \"请输入提示词\", materialAdded: \"已添加 {{count}} 个素材\",\n        generateSuccess: \"素材生成成功，已保存到历史素材库\", generateSuccessGlobal: \"素材生成成功，已保存到全局素材库\",\n        generateComplete: \"素材生成完成，但未找到图片地址\", generateFailed: \"素材生成失败\",\n        generateTimeout: \"素材生成超时，请稍后查看素材库\", pollingFailed: \"轮询任务状态失败，请稍后查看素材库\",\n        noTaskId: \"素材生成失败：未返回任务ID\"\n      }\n    }\n  },\n  en: {\n    material: {\n      title: \"Generate Material\", saveToLibraryNote: \"Generated materials will be saved to the library\",\n      generatedResult: \"Generated Result\", generatedMaterial: \"Generated Material\", generatedPreview: \"Generated materials will be displayed here\",\n      promptLabel: \"Prompt (sent directly to text-to-image model)\",\n      promptPlaceholder: \"e.g., Blue-purple gradient background with geometric shapes and tech-style lines for a tech-themed title page...\",\n      aspectRatioLabel: \"Aspect Ratio\",\n      referenceImages: \"Reference Images (Optional)\", mainReference: \"Main Reference (Optional)\", extraReference: \"Extra References (Optional, multiple)\",\n      clickToUpload: \"Click to upload\", selectFromLibrary: \"Select from Library\", generateMaterial: \"Generate Material\",\n      messages: {\n        enterPrompt: \"Please enter a prompt\", materialAdded: \"Added {{count}} material(s)\",\n        generateSuccess: \"Material generated successfully, saved to history library\", generateSuccessGlobal: \"Material generated successfully, saved to global library\",\n        generateComplete: \"Material generation complete, but image URL not found\", generateFailed: \"Failed to generate material\",\n        generateTimeout: \"Material generation timeout, please check the library later\", pollingFailed: \"Failed to poll task status, please check the library later\",\n        noTaskId: \"Material generation failed: No task ID returned\"\n      }\n    }\n  }\n};\nimport { Skeleton } from './Loading';\nimport { generateMaterialImage, getTaskStatus } from '@/api/endpoints';\nimport { getImageUrl } from '@/api/client';\nimport type { Material } from '@/api/endpoints';\nimport type { Task } from '@/types';\n\ninterface MaterialGeneratorModalProps {\n  projectId?: string | null;\n  isOpen: boolean;\n  onClose: () => void;\n}\n\nexport const MaterialGeneratorModal: React.FC<MaterialGeneratorModalProps> = ({\n  projectId,\n  isOpen,\n  onClose,\n}) => {\n  const t = useT(materialGeneratorI18n);\n  const { show } = useToast();\n  const currentProject = useProjectStore((s) => s.currentProject);\n  const [prompt, setPrompt] = useState('');\n  const [aspectRatio, setAspectRatio] = useState('16:9');\n\n  // Reset aspect ratio to project default when modal opens,\n  // so newly opened modals always reflect current project settings.\n  // Verify the store's currentProject matches the projectId prop to avoid\n  // using a stale/wrong project's aspect ratio.\n  useEffect(() => {\n    if (isOpen) {\n      const projectAspectRatio =\n        (projectId && currentProject?.id === projectId && currentProject.image_aspect_ratio) || '16:9';\n      setAspectRatio(projectAspectRatio);\n    }\n  }, [isOpen, projectId, currentProject]);\n  const [refImage, setRefImage] = useState<File | null>(null);\n  const [extraImages, setExtraImages] = useState<File[]>([]);\n  const [previewUrl, setPreviewUrl] = useState<string | null>(null);\n  const [isGenerating, setIsGenerating] = useState(false);\n  const [isCompleted, setIsCompleted] = useState(false);\n  const [isMaterialSelectorOpen, setIsMaterialSelectorOpen] = useState(false);\n\n  const handleRefImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = (e.target.files && e.target.files[0]) || null;\n    if (file) {\n      setRefImage(file);\n    }\n  };\n\n  const handleExtraImagesChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const files = Array.from(e.target.files || []);\n    if (files.length === 0) return;\n\n    if (!refImage) {\n      const [first, ...rest] = files;\n      setRefImage(first);\n      if (rest.length > 0) {\n        setExtraImages((prev) => [...prev, ...rest]);\n      }\n    } else {\n      setExtraImages((prev) => [...prev, ...files]);\n    }\n  };\n\n  const removeExtraImage = (index: number) => {\n    setExtraImages((prev) => prev.filter((_, i) => i !== index));\n  };\n\n  const handleSelectMaterials = async (materials: Material[]) => {\n    try {\n      const files = await Promise.all(\n        materials.map((material) => materialUrlToFile(material))\n      );\n\n      if (files.length === 0) return;\n\n      if (!refImage) {\n        const [first, ...rest] = files;\n        setRefImage(first);\n        if (rest.length > 0) {\n          setExtraImages((prev) => [...prev, ...rest]);\n        }\n      } else {\n        setExtraImages((prev) => [...prev, ...files]);\n      }\n\n      show({ message: t('material.messages.materialAdded', { count: files.length }), type: 'success' });\n    } catch (error: any) {\n      console.error('Failed to load materials:', error);\n      show({\n        message: t('material.messages.loadMaterialFailed') + ': ' + (error.message || t('common.unknownError')),\n        type: 'error',\n      });\n    }\n  };\n\n  // Manage object URLs to prevent memory leaks\n  const refImageUrl = useRef<string | null>(null);\n  const extraImageUrls = useRef<string[]>([]);\n\n  useEffect(() => {\n    // Revoke previous URL\n    if (refImageUrl.current) URL.revokeObjectURL(refImageUrl.current);\n    refImageUrl.current = refImage ? URL.createObjectURL(refImage) : null;\n  }, [refImage]);\n\n  useEffect(() => {\n    // Revoke all previous URLs\n    extraImageUrls.current.forEach(url => URL.revokeObjectURL(url));\n    extraImageUrls.current = extraImages.map(file => URL.createObjectURL(file));\n  }, [extraImages]);\n\n  useEffect(() => {\n    return () => {\n      if (refImageUrl.current) URL.revokeObjectURL(refImageUrl.current);\n      extraImageUrls.current.forEach(url => URL.revokeObjectURL(url));\n    };\n  }, []);\n\n  const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);\n\n  useEffect(() => {\n    return () => {\n      if (pollingIntervalRef.current) {\n        clearInterval(pollingIntervalRef.current);\n      }\n    };\n  }, []);\n\n  const pollMaterialTask = async (taskId: string) => {\n    const targetProjectId = projectId || 'global';\n    const maxAttempts = 60;\n    let attempts = 0;\n\n    const poll = async () => {\n      try {\n        attempts++;\n        const response = await getTaskStatus(targetProjectId, taskId);\n        const task: Task = response.data;\n\n        if (task.status === 'COMPLETED') {\n          const progress = task.progress || {};\n          const imageUrl = progress.image_url;\n          \n          if (imageUrl) {\n            setPreviewUrl(getImageUrl(imageUrl));\n            const message = projectId\n              ? t('material.messages.generateSuccess')\n              : t('material.messages.generateSuccessGlobal');\n            show({ message, type: 'success' });\n            setIsCompleted(true);\n          } else {\n            show({ message: t('material.messages.generateComplete'), type: 'error' });\n          }\n\n          setIsGenerating(false);\n          if (pollingIntervalRef.current) {\n            clearInterval(pollingIntervalRef.current);\n            pollingIntervalRef.current = null;\n          }\n        } else if (task.status === 'FAILED') {\n          show({\n            message: task.error_message || t('material.messages.generateFailed'),\n            type: 'error',\n          });\n          setIsGenerating(false);\n          if (pollingIntervalRef.current) {\n            clearInterval(pollingIntervalRef.current);\n            pollingIntervalRef.current = null;\n          }\n        } else if (task.status === 'PENDING' || task.status === 'PROCESSING') {\n          if (attempts >= maxAttempts) {\n            show({ message: t('material.messages.generateTimeout'), type: 'warning' });\n            setIsGenerating(false);\n            if (pollingIntervalRef.current) {\n              clearInterval(pollingIntervalRef.current);\n              pollingIntervalRef.current = null;\n            }\n          }\n        }\n      } catch (error: any) {\n        console.error('Failed to poll task status:', error);\n        if (attempts >= maxAttempts) {\n          show({ message: t('material.messages.pollingFailed'), type: 'error' });\n          setIsGenerating(false);\n          if (pollingIntervalRef.current) {\n            clearInterval(pollingIntervalRef.current);\n            pollingIntervalRef.current = null;\n          }\n        }\n      }\n    };\n\n    poll();\n    pollingIntervalRef.current = setInterval(poll, 2000);\n  };\n\n  const handleGenerate = async () => {\n    if (!prompt.trim()) {\n      show({ message: t('material.messages.enterPrompt'), type: 'error' });\n      return;\n    }\n\n    setIsGenerating(true);\n    try {\n      const targetProjectId = projectId || 'none';\n      const resp = await generateMaterialImage(targetProjectId, prompt.trim(), refImage as File, extraImages, aspectRatio);\n      const taskId = resp.data?.task_id;\n      \n      if (taskId) {\n        await pollMaterialTask(taskId);\n      } else {\n        show({ message: t('material.messages.noTaskId'), type: 'error' });\n        setIsGenerating(false);\n      }\n    } catch (error: any) {\n      show({\n        message: error?.response?.data?.error?.message || error.message || t('material.messages.generateFailed'),\n        type: 'error',\n      });\n      setIsGenerating(false);\n    }\n  };\n\n  const handleClose = () => {\n    onClose();\n  };\n\n  return (\n    <Modal isOpen={isOpen} onClose={handleClose} title={t('material.title')} size=\"lg\">\n      {/* 顶部提示信息 */}\n      <div className=\"mb-5 px-4 py-3 rounded-xl bg-gradient-to-r from-banana-50/80 to-amber-50/80 dark:from-banana-900/20 dark:to-amber-900/20 border border-banana-200/50 dark:border-banana-700/30 backdrop-blur-sm\">\n        <p className=\"text-sm text-banana-800 dark:text-banana-200 flex items-center gap-2\">\n          <Info size={16} className=\"flex-shrink-0\" />\n          {t('material.saveToLibraryNote')}\n        </p>\n      </div>\n      <div className=\"space-y-5\">\n        {/* 生成结果预览卡片 - 使用现代渐变和光晕效果 */}\n        <div className=\"relative rounded-2xl overflow-hidden border border-gray-200/50 dark:border-white/10 p-5 bg-gradient-to-br from-gray-50/80 via-white/80 to-gray-50/80 dark:from-gray-900/40 dark:via-gray-800/40 dark:to-gray-900/40 backdrop-blur-xl shadow-lg\">\n          {/* 内部光晕 */}\n          <div className=\"absolute inset-0 rounded-2xl overflow-hidden pointer-events-none\">\n            <div className=\"absolute -top-20 -left-20 w-40 h-40 bg-banana-400/10 dark:bg-banana-400/5 rounded-full blur-3xl\" />\n            <div className=\"absolute -bottom-20 -right-20 w-40 h-40 bg-purple-400/10 dark:bg-purple-400/5 rounded-full blur-3xl\" />\n          </div>\n\n          <div className=\"relative\">\n            <h4 className=\"text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center gap-2\">\n              <span className=\"w-1 h-4 bg-gradient-to-b from-banana-400 to-banana-500 rounded-full\" />\n              {t('material.generatedResult')}\n            </h4>\n            {isGenerating ? (\n              <div className=\"rounded-xl overflow-hidden border border-gray-200/50 dark:border-white/10 shadow-inner\" style={{ aspectRatio: aspectRatio.replace(':', '/') }}>\n                <Skeleton className=\"w-full h-full\" />\n              </div>\n            ) : previewUrl ? (\n              <div className=\"bg-white/50 dark:bg-gray-900/50 rounded-xl overflow-hidden border border-gray-200/50 dark:border-white/10 flex items-center justify-center shadow-inner backdrop-blur-sm\" style={{ aspectRatio: aspectRatio.replace(':', '/') }}>\n                <img\n                  src={previewUrl}\n                  alt={t('material.generatedMaterial')}\n                  className=\"w-full h-full object-contain\"\n                />\n              </div>\n            ) : (\n              <div className=\"bg-gradient-to-br from-gray-100/50 via-gray-50/50 to-gray-100/50 dark:from-gray-800/30 dark:via-gray-900/30 dark:to-gray-800/30 rounded-xl flex flex-col items-center justify-center text-gray-400 dark:text-gray-500 text-sm border border-dashed border-gray-300/50 dark:border-gray-600/50 backdrop-blur-sm\" style={{ aspectRatio: aspectRatio.replace(':', '/') }}>\n                <ImageIcon size={48} className=\"mb-3 animate-pulse opacity-50\" />\n                <div className=\"font-medium\">{t('material.generatedPreview')}</div>\n              </div>\n            )}\n          </div>\n        </div>\n\n        <Textarea\n          label={t('material.promptLabel')}\n          placeholder={t('material.promptPlaceholder')}\n          value={prompt}\n          onChange={(e) => {\n            setPrompt(e.target.value);\n            if (isCompleted) setIsCompleted(false);\n          }}\n          rows={3}\n        />\n\n        {/* 生成比例选择 */}\n        <div>\n          <div className=\"text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">{t('material.aspectRatioLabel')}</div>\n          <div className=\"flex flex-wrap gap-1.5\">\n            {ASPECT_RATIO_OPTIONS.map((opt) => (\n              <button\n                key={opt.value}\n                type=\"button\"\n                onClick={() => { setAspectRatio(opt.value); if (isCompleted) setIsCompleted(false); }}\n                className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-all ${\n                  aspectRatio === opt.value\n                    ? 'border-banana-500 bg-banana-50 dark:bg-banana-900/30 text-banana-700 dark:text-banana'\n                    : 'border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-500 bg-white dark:bg-gray-800'\n                }`}\n              >\n                {opt.label}\n              </button>\n            ))}\n          </div>\n        </div>\n\n        {/* 参考图片上传区域 - 现代渐变设计 */}\n        <div className=\"relative rounded-2xl overflow-hidden border border-gray-200/50 dark:border-white/10 p-5 bg-gradient-to-br from-indigo-50/30 via-white/80 to-purple-50/30 dark:from-indigo-950/20 dark:via-gray-800/40 dark:to-purple-950/20 backdrop-blur-xl shadow-lg\">\n          {/* 内部光晕 */}\n          <div className=\"absolute inset-0 rounded-2xl overflow-hidden pointer-events-none\">\n            <div className=\"absolute top-0 left-1/4 w-32 h-32 bg-indigo-400/10 dark:bg-indigo-400/5 rounded-full blur-3xl\" />\n            <div className=\"absolute bottom-0 right-1/4 w-32 h-32 bg-purple-400/10 dark:bg-purple-400/5 rounded-full blur-3xl\" />\n          </div>\n\n          <div className=\"relative space-y-4\">\n            <div className=\"flex items-center justify-between\">\n              <div className=\"flex items-center gap-2 text-sm text-gray-800 dark:text-gray-200 font-medium\">\n                <ImagePlus size={18} className=\"text-indigo-500 dark:text-indigo-400\" />\n                <span>{t('material.referenceImages')}</span>\n              </div>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                icon={<FolderOpen size={16} />}\n                onClick={() => setIsMaterialSelectorOpen(true)}\n                className=\"hover:bg-indigo-100/50 dark:hover:bg-indigo-900/30\"\n              >\n                {t('material.selectFromLibrary')}\n              </Button>\n            </div>\n\n            <div className=\"flex flex-wrap gap-4\">\n              {/* 主参考图 */}\n              <div className=\"space-y-2\">\n                <div className=\"text-xs text-gray-600 dark:text-gray-400 font-medium\">{t('material.mainReference')}</div>\n                <label className=\"w-40 h-28 border-2 border-dashed border-indigo-300/50 dark:border-indigo-500/30 rounded-xl flex flex-col items-center justify-center cursor-pointer hover:border-indigo-400 dark:hover:border-indigo-400 hover:bg-indigo-50/50 dark:hover:bg-indigo-900/20 transition-all duration-200 bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm relative group shadow-sm hover:shadow-md\">\n                  {refImage ? (\n                    <>\n                      <img\n                        src={refImageUrl.current || ''}\n                        alt={t('material.mainReference')}\n                        className=\"w-full h-full object-cover rounded-xl\"\n                      />\n                      <button\n                        type=\"button\"\n                        onClick={(e) => {\n                          e.preventDefault();\n                          e.stopPropagation();\n                          setRefImage(null);\n                        }}\n                        className=\"absolute -top-2 -right-2 w-7 h-7 bg-gradient-to-br from-red-500 to-red-600 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-200 shadow-lg hover:scale-110 active:scale-95 z-10\"\n                      >\n                        <X size={14} strokeWidth={2.5} />\n                      </button>\n                    </>\n                  ) : (\n                    <>\n                      <ImageIcon size={28} className=\"text-indigo-400 dark:text-indigo-500 mb-1.5 group-hover:scale-110 transition-transform duration-200\" />\n                      <span className=\"text-xs text-gray-600 dark:text-gray-400 font-medium\">{t('material.clickToUpload')}</span>\n                    </>\n                  )}\n                  <input\n                    type=\"file\"\n                    accept=\"image/*\"\n                    className=\"hidden\"\n                    onChange={handleRefImageChange}\n                  />\n                </label>\n              </div>\n\n              {/* 额外参考图 */}\n              <div className=\"flex-1 space-y-2 min-w-[180px]\">\n                <div className=\"text-xs text-gray-600 dark:text-gray-400 font-medium\">{t('material.extraReference')}</div>\n                <div className=\"flex flex-wrap gap-2\">\n                  {extraImages.map((file, idx) => (\n                    <div key={idx} className=\"relative group\">\n                      <img\n                        src={extraImageUrls.current[idx] || ''}\n                        alt={`extra-${idx + 1}`}\n                        className=\"w-20 h-20 object-cover rounded-lg border-2 border-indigo-200/50 dark:border-indigo-500/30 shadow-sm group-hover:shadow-md transition-all duration-200\"\n                      />\n                      <button\n                        onClick={() => removeExtraImage(idx)}\n                        className=\"absolute -top-2 -right-2 w-6 h-6 bg-gradient-to-br from-red-500 to-red-600 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-200 shadow-lg hover:scale-110 active:scale-95\"\n                      >\n                        <X size={12} strokeWidth={2.5} />\n                      </button>\n                    </div>\n                  ))}\n                  <label className=\"w-20 h-20 border-2 border-dashed border-indigo-300/50 dark:border-indigo-500/30 rounded-lg flex flex-col items-center justify-center cursor-pointer hover:border-indigo-400 dark:hover:border-indigo-400 hover:bg-indigo-50/50 dark:hover:bg-indigo-900/20 transition-all duration-200 bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm group shadow-sm hover:shadow-md\">\n                    <Upload size={20} className=\"text-indigo-400 dark:text-indigo-500 mb-1 group-hover:scale-110 transition-transform duration-200\" />\n                    <span className=\"text-[10px] text-gray-600 dark:text-gray-400 font-medium\">{t('common.add')}</span>\n                    <input\n                      type=\"file\"\n                      accept=\"image/*\"\n                      multiple\n                      className=\"hidden\"\n                      onChange={handleExtraImagesChange}\n                    />\n                  </label>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        {/* 底部按钮区域 */}\n        <div className=\"flex justify-end gap-3 pt-3\">\n          <Button variant=\"ghost\" onClick={handleClose} disabled={isGenerating}>\n            {t('common.close')}\n          </Button>\n          <Button\n            variant=\"primary\"\n            onClick={handleGenerate}\n            disabled={isGenerating || isCompleted || !prompt.trim()}\n            className=\"shadow-lg shadow-banana-500/20 hover:shadow-xl hover:shadow-banana-500/30 transition-all duration-200\"\n          >\n            {isGenerating ? t('common.generating') : isCompleted ? t('common.completed') : t('material.generateMaterial')}\n          </Button>\n        </div>\n      </div>\n      <MaterialSelector\n        projectId={projectId}\n        isOpen={isMaterialSelectorOpen}\n        onClose={() => setIsMaterialSelectorOpen(false)}\n        onSelect={handleSelectMaterials}\n        multiple={true}\n      />\n    </Modal>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/shared/MaterialSelector.tsx",
    "content": "import React, { useState, useEffect } from 'react';\nimport { ImageIcon, RefreshCw, Upload, Sparkles, X } from 'lucide-react';\nimport { Button, useToast, Modal } from '@/components/shared';\nimport { useT } from '@/hooks/useT';\nimport { listMaterials, uploadMaterial, listProjects, deleteMaterial, type Material } from '@/api/endpoints';\n\n// MaterialSelector 组件自包含翻译\nconst materialSelectorI18n = {\n  zh: {\n    material: {\n      selectTitle: \"选择素材\", totalMaterials: \"共 {{count}} 个素材\", noMaterials: \"暂无素材\",\n      selectedCount: \"已选择 {{count}} 个\", allMaterials: \"所有素材\", unassociated: \"未关联项目\",\n      currentProject: \"当前项目\", viewMoreProjects: \"+ 查看更多项目...\", uploadFile: \"上传文件\",\n      previewMaterial: \"预览素材\", deleteMaterial: \"删除素材\", closePreview: \"关闭预览\",\n      canUploadOrGenerate: \"可以上传图片或通过素材生成功能创建素材\",\n      canUploadImages: \"可以上传图片作为素材\",\n      generateMaterial: \"生成素材\",\n      messages: {\n        loadMaterialFailed: \"加载素材失败\", unsupportedFormat: \"不支持的图片格式\",\n        uploadSuccess: \"素材上传成功\", uploadFailed: \"上传素材失败\",\n        cannotDelete: \"无法删除：缺少素材ID\", deleteSuccess: \"素材已删除\", deleteFailed: \"删除素材失败\",\n        selectAtLeastOne: \"请至少选择一个素材\", maxSelection: \"最多只能选择 {{count}} 个素材\"\n      }\n    }\n  },\n  en: {\n    material: {\n      selectTitle: \"Select Material\", totalMaterials: \"{{count}} materials\", noMaterials: \"No materials\",\n      selectedCount: \"{{count}} selected\", allMaterials: \"All Materials\", unassociated: \"Unassociated\",\n      currentProject: \"Current Project\", viewMoreProjects: \"+ View more projects...\", uploadFile: \"Upload File\",\n      previewMaterial: \"Preview Material\", deleteMaterial: \"Delete Material\", closePreview: \"Close Preview\",\n      canUploadOrGenerate: \"You can upload images or create materials through the material generator\",\n      canUploadImages: \"You can upload images as materials\",\n      generateMaterial: \"Generate Material\",\n      messages: {\n        loadMaterialFailed: \"Failed to load materials\", unsupportedFormat: \"Unsupported image format\",\n        uploadSuccess: \"Material uploaded successfully\", uploadFailed: \"Failed to upload material\",\n        cannotDelete: \"Cannot delete: Missing material ID\", deleteSuccess: \"Material deleted\", deleteFailed: \"Failed to delete material\",\n        selectAtLeastOne: \"Please select at least one material\", maxSelection: \"Maximum {{count}} materials can be selected\"\n      }\n    }\n  }\n};\nimport type { Project } from '@/types';\nimport { getImageUrl } from '@/api/client';\nimport { MaterialGeneratorModal } from './MaterialGeneratorModal';\n\ninterface MaterialSelectorProps {\n  projectId?: string;\n  isOpen: boolean;\n  onClose: () => void;\n  onSelect: (materials: Material[], saveAsTemplate?: boolean) => void;\n  multiple?: boolean;\n  maxSelection?: number;\n  showSaveAsTemplateOption?: boolean;\n}\n\nexport const MaterialSelector: React.FC<MaterialSelectorProps> = ({\n  projectId,\n  isOpen,\n  onClose,\n  onSelect,\n  multiple = false,\n  maxSelection,\n  showSaveAsTemplateOption = false,\n}) => {\n  const t = useT(materialSelectorI18n);\n  const { show } = useToast();\n  const [materials, setMaterials] = useState<Material[]>([]);\n  const [selectedMaterials, setSelectedMaterials] = useState<Set<string>>(new Set());\n  const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());\n  const [isLoading, setIsLoading] = useState(false);\n  const [isUploading, setIsUploading] = useState(false);\n  const [filterProjectId, setFilterProjectId] = useState<string>('all');\n  const [projects, setProjects] = useState<Project[]>([]);\n  const [projectsLoaded, setProjectsLoaded] = useState(false);\n  const [isGeneratorOpen, setIsGeneratorOpen] = useState(false);\n  const [saveAsTemplate, setSaveAsTemplate] = useState(true);\n  const [showAllProjects, setShowAllProjects] = useState(false);\n\n  useEffect(() => {\n    if (isOpen) {\n      if (!projectsLoaded) {\n        loadProjects();\n      }\n      loadMaterials();\n      setShowAllProjects(false);\n    }\n  }, [isOpen, filterProjectId, projectsLoaded]);\n\n  const loadProjects = async () => {\n    try {\n      const response = await listProjects(100, 0);\n      if (response.data?.projects) {\n        setProjects(response.data.projects);\n        setProjectsLoaded(true);\n      }\n    } catch (error: any) {\n      console.error('Failed to load projects:', error);\n    }\n  };\n\n  const getMaterialKey = (m: Material): string => m.id;\n  const getMaterialDisplayName = (m: Material) =>\n    (m.prompt && m.prompt.trim()) ||\n    (m.name && m.name.trim()) ||\n    (m.original_filename && m.original_filename.trim()) ||\n    (m.source_filename && m.source_filename.trim()) ||\n    m.filename ||\n    m.url;\n\n  const loadMaterials = async () => {\n    setIsLoading(true);\n    try {\n      const targetProjectId = filterProjectId === 'all' ? 'all' : filterProjectId === 'none' ? 'none' : filterProjectId;\n      const response = await listMaterials(targetProjectId);\n      if (response.data?.materials) {\n        setMaterials(response.data.materials);\n      }\n    } catch (error: any) {\n      console.error('Failed to load materials:', error);\n      show({\n        message: error?.response?.data?.error?.message || error.message || t('material.messages.loadMaterialFailed'),\n        type: 'error',\n      });\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  const handleSelectMaterial = (material: Material) => {\n    const key = getMaterialKey(material);\n    if (multiple) {\n      const newSelected = new Set(selectedMaterials);\n      if (newSelected.has(key)) {\n        newSelected.delete(key);\n      } else {\n        if (maxSelection && newSelected.size >= maxSelection) {\n          show({\n            message: t('material.messages.maxSelection', { count: maxSelection }),\n            type: 'info',\n          });\n          return;\n        }\n        newSelected.add(key);\n      }\n      setSelectedMaterials(newSelected);\n    } else {\n      setSelectedMaterials(new Set([key]));\n    }\n  };\n\n  const handleConfirm = () => {\n    const selected = materials.filter((m) => selectedMaterials.has(getMaterialKey(m)));\n    if (selected.length === 0) {\n      show({ message: t('material.messages.selectAtLeastOne'), type: 'info' });\n      return;\n    }\n    onSelect(selected, showSaveAsTemplateOption ? saveAsTemplate : undefined);\n    onClose();\n  };\n\n  const handleClear = () => {\n    setSelectedMaterials(new Set());\n  };\n\n  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    if (!file) return;\n\n    const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp', 'image/bmp', 'image/svg+xml'];\n    if (!allowedTypes.includes(file.type)) {\n      show({ message: t('material.messages.unsupportedFormat'), type: 'error' });\n      return;\n    }\n\n    setIsUploading(true);\n    try {\n      const targetProjectId = (filterProjectId === 'all' || filterProjectId === 'none')\n        ? null\n        : filterProjectId;\n\n      const response = await uploadMaterial(\n        file,\n        targetProjectId\n      );\n      \n      if (response.data) {\n        show({ message: t('material.messages.uploadSuccess'), type: 'success' });\n        loadMaterials();\n      }\n    } catch (error: any) {\n      console.error('Failed to upload material:', error);\n      show({\n        message: error?.response?.data?.error?.message || error.message || t('material.messages.uploadFailed'),\n        type: 'error',\n      });\n    } finally {\n      setIsUploading(false);\n      e.target.value = '';\n    }\n  };\n\n  const handleGeneratorClose = () => {\n    setIsGeneratorOpen(false);\n    loadMaterials();\n  };\n\n  const handleDeleteMaterial = async (\n    e: React.MouseEvent<HTMLButtonElement, MouseEvent>,\n    material: Material\n  ) => {\n    e.stopPropagation();\n    const materialId = material.id;\n    const key = getMaterialKey(material);\n\n    if (!materialId) {\n      show({ message: t('material.messages.cannotDelete'), type: 'error' });\n      return;\n    }\n\n    setDeletingIds((prev) => {\n      const next = new Set(prev);\n      next.add(materialId);\n      return next;\n    });\n\n    try {\n      await deleteMaterial(materialId);\n      setMaterials((prev) => prev.filter((m) => getMaterialKey(m) !== key));\n      setSelectedMaterials((prev) => {\n        const next = new Set(prev);\n        next.delete(key);\n        return next;\n      });\n      show({ message: t('material.messages.deleteSuccess'), type: 'success' });\n    } catch (error: any) {\n      console.error('Failed to delete material:', error);\n      show({\n        message: error?.response?.data?.error?.message || error.message || t('material.messages.deleteFailed'),\n        type: 'error',\n      });\n    } finally {\n      setDeletingIds((prev) => {\n        const next = new Set(prev);\n        next.delete(materialId);\n        return next;\n      });\n    }\n  };\n\n  const renderProjectLabel = (p: Project) => {\n    const text = p.idea_prompt || p.outline_text || `Project ${p.project_id.slice(0, 8)}`;\n    return text.length > 20 ? `${text.slice(0, 20)}…` : text;\n  };\n\n  return (\n    <>\n      <Modal isOpen={isOpen} onClose={onClose} title={t('material.selectTitle')} size=\"lg\">\n        <div className=\"space-y-4\">\n          <div className=\"flex items-center justify-between flex-wrap gap-2\">\n            <div className=\"flex items-center gap-2 text-sm text-gray-600 dark:text-foreground-tertiary\">\n              <span>{materials.length > 0 ? t('material.totalMaterials', { count: materials.length }) : t('material.noMaterials')}</span>\n              {selectedMaterials.size > 0 && (\n                <span className=\"ml-2 text-banana-600\">\n                  {t('material.selectedCount', { count: selectedMaterials.size })}\n                </span>\n              )}\n              {isLoading && materials.length > 0 && (\n                <RefreshCw size={14} className=\"animate-spin text-gray-400\" />\n              )}\n            </div>\n            <div className=\"flex items-center gap-2 flex-wrap\">\n              <select\n                value={filterProjectId}\n                onChange={(e) => {\n                  const value = e.target.value;\n                  if (value === 'show_more') {\n                    setShowAllProjects(true);\n                    return;\n                  }\n                  setFilterProjectId(value);\n                }}\n                className=\"px-3 py-1.5 text-sm border border-gray-300 dark:border-border-primary rounded-md bg-white dark:bg-background-secondary focus:outline-none focus:ring-2 focus:ring-banana-500 w-40 sm:w-48 max-w-[200px] truncate\"\n              >\n                <option value=\"all\">{t('material.allMaterials')}</option>\n                <option value=\"none\">{t('material.unassociated')}</option>\n                {projectId && (\n                  <option value={projectId}>\n                    {t('material.currentProject')}{projects.find(p => p.project_id === projectId) ? `: ${renderProjectLabel(projects.find(p => p.project_id === projectId)!)}` : ''}\n                  </option>\n                )}\n                \n                {showAllProjects ? (\n                  <>\n                    <option disabled>───────────</option>\n                    {projects.filter(p => p.project_id !== projectId).map((p) => (\n                      <option key={p.project_id} value={p.project_id} title={p.idea_prompt || p.outline_text}>\n                        {renderProjectLabel(p)}\n                      </option>\n                    ))}\n                  </>\n                ) : (\n                  projects.length > (projectId ? 1 : 0) && (\n                    <option value=\"show_more\">{t('material.viewMoreProjects')}</option>\n                  )\n                )}\n              </select>\n              \n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                icon={<RefreshCw size={16} />}\n                onClick={loadMaterials}\n                disabled={isLoading}\n              >\n                {t('common.refresh')}\n              </Button>\n              \n              <label className=\"inline-block cursor-pointer\">\n                <div className=\"inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-foreground-secondary bg-white dark:bg-background-secondary border border-gray-300 dark:border-border-primary rounded-md hover:bg-gray-50 dark:hover:bg-background-hover disabled:opacity-50 disabled:cursor-not-allowed\">\n                  <Upload size={16} />\n                  <span>{isUploading ? t('common.uploading') : t('common.upload')}</span>\n                </div>\n                <input\n                  type=\"file\"\n                  accept=\"image/*\"\n                  onChange={handleUpload}\n                  className=\"hidden\"\n                  disabled={isUploading}\n                />\n              </label>\n              \n              {projectId && (\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  icon={<Sparkles size={16} />}\n                  onClick={() => setIsGeneratorOpen(true)}\n                >\n                  {t('material.generateMaterial')}\n                </Button>\n              )}\n              \n              {selectedMaterials.size > 0 && (\n                <Button variant=\"ghost\" size=\"sm\" onClick={handleClear}>\n                  {t('common.clearSelection')}\n                </Button>\n              )}\n            </div>\n          </div>\n\n          {isLoading && materials.length === 0 ? (\n            <div className=\"flex items-center justify-center py-12\">\n              <div className=\"text-gray-400\">{t('common.loading')}</div>\n            </div>\n          ) : materials.length === 0 ? (\n            <div className=\"flex flex-col items-center justify-center py-12 text-gray-400 p-4\">\n              <ImageIcon size={48} className=\"mb-4 opacity-50\" />\n              <div className=\"text-sm\">{t('material.noMaterials')}</div>\n              <div className=\"text-xs mt-1\">\n                {projectId ? t('material.canUploadOrGenerate') : t('material.canUploadImages')}\n              </div>\n            </div>\n          ) : (\n          <div className=\"grid grid-cols-4 gap-4 max-h-96 overflow-y-auto  p-4\">\n            {materials.map((material) => {\n              const key = getMaterialKey(material);\n              const isSelected = selectedMaterials.has(key);\n              const isDeleting = deletingIds.has(material.id);\n              return (\n                <div\n                  key={key}\n                  onClick={() => handleSelectMaterial(material)}\n                  className={`aspect-video rounded-lg border-2 cursor-pointer transition-all relative group ${\n                    isSelected\n                      ? 'border-banana-500 ring-2 ring-banana-200'\n                      : 'border-gray-200 dark:border-border-primary hover:border-banana-300'\n                  }`}\n                >\n                  <img\n                    src={getImageUrl(material.url)}\n                    alt={getMaterialDisplayName(material)}\n                    className=\"absolute inset-0 w-full h-full object-cover\"\n                  />\n                  <button\n                    type=\"button\"\n                    onClick={(e) => handleDeleteMaterial(e, material)}\n                    disabled={isDeleting}\n                    className=\"absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow z-10 disabled:opacity-60 disabled:cursor-not-allowed\"\n                    aria-label={t('material.deleteMaterial')}\n                  >\n                    {isDeleting ? <RefreshCw size={12} className=\"animate-spin\" /> : <X size={12} />}\n                  </button>\n                  {isSelected && (\n                    <div className=\"absolute inset-0 bg-banana-500 bg-opacity-20 flex items-center justify-center\">\n                      <div className=\"bg-banana-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs font-bold\">\n                        ✓\n                      </div>\n                    </div>\n                  )}\n                  <div className=\"absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs p-1 truncate opacity-0 group-hover:opacity-100 transition-opacity\">\n                    {getMaterialDisplayName(material)}\n                  </div>\n                </div>\n              );\n            })}\n          </div>\n          )}\n\n          <div className=\"pt-4 border-t\">\n            {showSaveAsTemplateOption && (\n              <div className=\"mb-3 p-3 bg-gray-50 dark:bg-background-primary rounded-lg border border-gray-200 dark:border-border-primary\">\n                <label className=\"flex items-center gap-2 cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={saveAsTemplate}\n                    onChange={(e) => setSaveAsTemplate(e.target.checked)}\n                    className=\"w-4 h-4 text-banana-500 border-gray-300 dark:border-border-primary rounded focus:ring-banana-500\"\n                  />\n                  <span className=\"text-sm text-gray-700 dark:text-foreground-secondary\">\n                    {t('template.saveToLibraryOnUpload')}\n                  </span>\n                </label>\n              </div>\n            )}\n            \n            <div className=\"flex justify-end gap-3\">\n              <Button variant=\"ghost\" onClick={onClose}>\n                {t('common.cancel')}\n              </Button>\n              <Button\n                variant=\"primary\"\n                onClick={handleConfirm}\n                disabled={selectedMaterials.size === 0}\n              >\n                {t('common.confirm')} ({selectedMaterials.size})\n              </Button>\n            </div>\n          </div>\n        </div>\n      </Modal>\n      \n      {projectId && (\n        <MaterialGeneratorModal\n          projectId={projectId}\n          isOpen={isGeneratorOpen}\n          onClose={handleGeneratorClose}\n        />\n      )}\n    </>\n  );\n};\n\nexport const materialUrlToFile = async (\n  material: Material,\n  filename?: string\n): Promise<File> => {\n  const imageUrl = getImageUrl(material.url);\n  const response = await fetch(imageUrl);\n  const blob = await response.blob();\n  const file = new File(\n    [blob],\n    filename || material.filename,\n    { type: blob.type || 'image/png' }\n  );\n  return file;\n};\n"
  },
  {
    "path": "frontend/src/components/shared/Modal.tsx",
    "content": "import React, { useEffect, useState, useCallback } from 'react';\nimport { X } from 'lucide-react';\nimport { cn } from '@/utils';\n\ninterface ModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  title?: string;\n  children: React.ReactNode;\n  size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';\n  showCloseButton?: boolean;\n}\n\nexport const Modal: React.FC<ModalProps> = ({\n  isOpen,\n  onClose,\n  title,\n  children,\n  size = 'md',\n  showCloseButton = true,\n}) => {\n  const [isVisible, setIsVisible] = useState(false);\n  const [isAnimating, setIsAnimating] = useState(false);\n\n  useEffect(() => {\n    if (isOpen) {\n      setIsVisible(true);\n      requestAnimationFrame(() => {\n        requestAnimationFrame(() => {\n          setIsAnimating(true);\n        });\n      });\n      document.body.style.overflow = 'hidden';\n    } else {\n      setIsAnimating(false);\n      const timer = setTimeout(() => {\n        setIsVisible(false);\n      }, 250);\n      document.body.style.overflow = '';\n      return () => clearTimeout(timer);\n    }\n\n    return () => {\n      document.body.style.overflow = '';\n    };\n  }, [isOpen]);\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 handleBackdropClick = useCallback((e: React.MouseEvent) => {\n    if (e.target === e.currentTarget) {\n      onClose();\n    }\n  }, [onClose]);\n\n  if (!isVisible) return null;\n\n  const sizes = {\n    sm: 'max-w-[380px]',\n    md: 'max-w-[480px]',\n    lg: 'max-w-[640px]',\n    xl: 'max-w-[800px]',\n    full: 'max-w-[calc(100vw-2rem)] sm:max-w-[calc(100vw-4rem)]',\n  };\n\n  return (\n    <div className=\"fixed inset-0 z-50 overflow-y-auto overscroll-contain\">\n      {/* 遮罩 */}\n      <div\n        className={cn(\n          'fixed inset-0 transition-all duration-300',\n          'bg-gradient-to-br from-black/50 via-black/40 to-black/50',\n          'backdrop-blur-md',\n          isAnimating ? 'opacity-100' : 'opacity-0'\n        )}\n        onClick={onClose}\n        aria-hidden=\"true\"\n      />\n\n      {/* 容器 */}\n      <div\n        className=\"relative flex min-h-full items-center justify-center p-4 sm:p-6\"\n        onClick={handleBackdropClick}\n      >\n        <div\n          role=\"dialog\"\n          aria-modal=\"true\"\n          aria-labelledby={title ? 'modal-title' : undefined}\n          className={cn(\n            'relative w-full flex flex-col',\n            'max-h-[85vh]',\n            // 背景和边框\n            'bg-white/95 dark:bg-[#1a1a24]/95',\n            'backdrop-blur-xl',\n            'border border-white/20 dark:border-white/10',\n            // 圆角 + 裁剪滚动条\n            'rounded-3xl overflow-hidden',\n            // 阴影 - 多层次\n            'shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_2px_4px_rgba(0,0,0,0.05),0_12px_24px_rgba(0,0,0,0.09)]',\n            'dark:shadow-[0_0_0_1px_rgba(255,255,255,0.05),0_2px_4px_rgba(0,0,0,0.2),0_12px_24px_rgba(0,0,0,0.4)]',\n            // 动画\n            'transition-all duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]',\n            isAnimating\n              ? 'opacity-100 scale-100 translate-y-0'\n              : 'opacity-0 scale-[0.96] translate-y-3',\n            sizes[size]\n          )}\n          onClick={(e) => e.stopPropagation()}\n        >\n          {/* 顶部光晕效果 */}\n          <div className=\"absolute -top-px left-8 right-8 h-px bg-gradient-to-r from-transparent via-banana-400/50 to-transparent\" />\n\n          {/* 内部光晕 */}\n          <div className=\"absolute inset-0 rounded-3xl overflow-hidden pointer-events-none\">\n            <div className=\"absolute -top-32 -left-32 w-64 h-64 bg-banana-400/10 dark:bg-banana-400/5 rounded-full blur-3xl\" />\n            <div className=\"absolute -bottom-32 -right-32 w-64 h-64 bg-banana-300/10 dark:bg-banana-300/5 rounded-full blur-3xl\" />\n          </div>\n\n          {/* 标题栏 */}\n          {title && (\n            <div className=\"relative flex-shrink-0 px-7 pt-7 pb-5\">\n              <h2\n                id=\"modal-title\"\n                className=\"text-xl font-semibold text-gray-900 dark:text-white tracking-tight pr-10\"\n              >\n                {title}\n              </h2>\n            </div>\n          )}\n\n          {/* 关闭按钮 */}\n          {showCloseButton && (\n            <button\n              onClick={onClose}\n              className={cn(\n                'absolute z-20 group',\n                'w-9 h-9 flex items-center justify-center',\n                'rounded-xl',\n                'text-gray-400 dark:text-gray-500',\n                'hover:text-gray-600 dark:hover:text-gray-300',\n                'hover:bg-gray-100/80 dark:hover:bg-white/10',\n                'active:scale-95',\n                'transition-all duration-150',\n                'focus:outline-none focus-visible:ring-2 focus-visible:ring-banana-400/50 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-[#1a1a24]',\n                title ? 'top-5 right-5' : 'top-4 right-4'\n              )}\n              aria-label=\"关闭\"\n            >\n              <X\n                size={18}\n                strokeWidth={2}\n                className=\"transition-transform duration-150 group-hover:scale-110\"\n              />\n            </button>\n          )}\n\n          {/* 内容区域 */}\n          <div\n            className={cn(\n              'relative px-7 pb-7 overflow-y-auto flex-1',\n              'scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600',\n              title ? '' : 'pt-7'\n            )}\n          >\n            {children}\n          </div>\n\n          {/* 底部边框光晕 */}\n          <div className=\"absolute -bottom-px left-8 right-8 h-px bg-gradient-to-r from-transparent via-white/20 dark:via-white/10 to-transparent\" />\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/shared/Pagination.tsx",
    "content": "import React from 'react';\nimport { ChevronLeft, ChevronRight } from 'lucide-react';\nimport { cn } from '@/utils';\n\ninterface PaginationProps {\n  currentPage: number;\n  totalPages: number;\n  onPageChange: (page: number) => void;\n  pageSize?: number;\n  onPageSizeChange?: (size: number) => void;\n  pageSizeOptions?: number[];\n  pageSizeLabel?: string;\n}\n\nexport const Pagination: React.FC<PaginationProps> = ({\n  currentPage,\n  totalPages,\n  onPageChange,\n  pageSize,\n  onPageSizeChange,\n  pageSizeOptions = [5, 10, 20],\n  pageSizeLabel = '/ page',\n}) => {\n  // When only page size selector is needed (totalPages <= 1), still render if onPageSizeChange is provided\n  if (totalPages <= 1 && !onPageSizeChange) return null;\n\n  const getPageNumbers = (): (number | 'ellipsis')[] => {\n    // Show all pages when total is small enough\n    if (totalPages <= 5) {\n      return Array.from({ length: totalPages }, (_, i) => i + 1);\n    }\n\n    const pages: (number | 'ellipsis')[] = [1];\n\n    const start = Math.max(2, currentPage - 1);\n    const end = Math.min(totalPages - 1, currentPage + 1);\n\n    if (start > 2) pages.push('ellipsis');\n    for (let i = start; i <= end; i++) pages.push(i);\n    if (end < totalPages - 1) pages.push('ellipsis');\n\n    pages.push(totalPages);\n    return pages;\n  };\n\n  const buttonBase =\n    'flex items-center justify-center rounded-lg transition-all duration-200 select-none';\n  const btnSize = 'w-9 h-9 text-sm';\n\n  return (\n    <nav className=\"flex items-center justify-center gap-1.5\" aria-label=\"Pagination\">\n      {/* Previous */}\n      <button\n        className={cn(buttonBase, btnSize, 'text-gray-500 dark:text-foreground-tertiary', {\n          'hover:bg-gray-100 dark:hover:bg-background-hover cursor-pointer': currentPage > 1,\n          'opacity-30 cursor-not-allowed': currentPage <= 1,\n        })}\n        onClick={() => onPageChange(currentPage - 1)}\n        disabled={currentPage <= 1}\n        aria-label=\"Previous page\"\n      >\n        <ChevronLeft size={18} />\n      </button>\n\n      {/* Page numbers */}\n      {getPageNumbers().map((page, idx) =>\n        page === 'ellipsis' ? (\n          <span\n            key={`ellipsis-${idx}`}\n            className=\"w-9 h-9 flex items-center justify-center text-gray-400 dark:text-foreground-tertiary text-sm select-none\"\n          >\n            ...\n          </span>\n        ) : (\n          <button\n            key={page}\n            className={cn(buttonBase, btnSize, 'font-medium', {\n              'bg-banana-500 text-black shadow-sm': page === currentPage,\n              'text-gray-700 dark:text-foreground-secondary hover:bg-gray-100 dark:hover:bg-background-hover':\n                page !== currentPage,\n            })}\n            onClick={() => onPageChange(page)}\n            aria-current={page === currentPage ? 'page' : undefined}\n          >\n            {page}\n          </button>\n        )\n      )}\n\n      {/* Next */}\n      <button\n        className={cn(buttonBase, btnSize, 'text-gray-500 dark:text-foreground-tertiary', {\n          'hover:bg-gray-100 dark:hover:bg-background-hover cursor-pointer': currentPage < totalPages,\n          'opacity-30 cursor-not-allowed': currentPage >= totalPages,\n        })}\n        onClick={() => onPageChange(currentPage + 1)}\n        disabled={currentPage >= totalPages}\n        aria-label=\"Next page\"\n      >\n        <ChevronRight size={18} />\n      </button>\n\n      {/* Page size selector */}\n      {onPageSizeChange && (\n        <select\n          value={pageSize}\n          onChange={(e) => onPageSizeChange(Number(e.target.value))}\n          className=\"ml-3 h-9 px-2 text-sm rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary text-gray-700 dark:text-foreground-secondary cursor-pointer focus:outline-none focus:ring-1 focus:ring-banana-500\"\n        >\n          {pageSizeOptions.map((size) => (\n            <option key={size} value={size}>{size} {pageSizeLabel}</option>\n          ))}\n        </select>\n      )}\n    </nav>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/shared/PresetCapsules.tsx",
    "content": "import React, { useState, useCallback, useRef, useEffect } from 'react';\nimport { Plus, X } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { useT } from '@/hooks/useT';\nimport { Modal } from '@/components/shared/Modal';\n\n// ─── i18n ────────────────────────────────────────────────────────────────────\nconst presetI18n = {\n  zh: {\n    preset: {\n      addCustom: '自定义',\n      modalTitle: '添加自定义预设',\n      nameLabel: '预设名称',\n      namePlaceholder: '例如：学术风格',\n      contentLabel: '提示词内容',\n      contentPlaceholder: '例如：使用学术论文的严谨表述，引用数据时标注来源',\n      add: '添加',\n      cancel: '取消',\n    },\n  },\n  en: {\n    preset: {\n      addCustom: 'Custom',\n      modalTitle: 'Add Custom Preset',\n      nameLabel: 'Preset Name',\n      namePlaceholder: 'e.g., Academic style',\n      contentLabel: 'Prompt Content',\n      contentPlaceholder: 'e.g., Use rigorous academic language, cite data sources',\n      add: 'Add',\n      cancel: 'Cancel',\n    },\n  },\n};\n\n// ─── Types ───────────────────────────────────────────────────────────────────\nexport interface Preset {\n  name: string;\n  content: string;\n}\n\nexport type PresetType = 'outline' | 'description';\n\n// ─── System presets ──────────────────────────────────────────────────────────\nconst SYSTEM_PRESETS: Record<PresetType, Record<'zh' | 'en', Preset[]>> = {\n  outline: {\n    zh: [],\n    en: [],\n  },\n  description: {\n    zh: [],\n    en: [],\n  },\n};\n\nconst STORAGE_KEY_PREFIX = 'presetCapsules_';\n\nfunction loadUserPresets(type: PresetType): Preset[] {\n  try {\n    const raw = localStorage.getItem(`${STORAGE_KEY_PREFIX}${type}`);\n    return raw ? JSON.parse(raw) : [];\n  } catch {\n    return [];\n  }\n}\n\nfunction saveUserPresets(type: PresetType, presets: Preset[]) {\n  localStorage.setItem(`${STORAGE_KEY_PREFIX}${type}`, JSON.stringify(presets));\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\ninterface PresetCapsulesProps {\n  type: PresetType;\n  onAppend: (text: string) => void;\n}\n\nexport default function PresetCapsules({ type, onAppend }: PresetCapsulesProps) {\n  const t = useT(presetI18n);\n  const [userPresets, setUserPresets] = useState<Preset[]>(() => loadUserPresets(type));\n  const [isModalOpen, setIsModalOpen] = useState(false);\n  const [newName, setNewName] = useState('');\n  const [newContent, setNewContent] = useState('');\n  const nameInputRef = useRef<HTMLInputElement>(null);\n\n  const { i18n } = useTranslation();\n  const currentLang = i18n.language?.startsWith('zh') ? 'zh' : 'en';\n  const systemPresets = SYSTEM_PRESETS[type][currentLang];\n\n  useEffect(() => {\n    if (isModalOpen && nameInputRef.current) {\n      // Delay focus slightly to allow modal animation\n      const timer = setTimeout(() => nameInputRef.current?.focus(), 100);\n      return () => clearTimeout(timer);\n    }\n  }, [isModalOpen]);\n\n  const handleCloseModal = useCallback(() => {\n    setIsModalOpen(false);\n    setNewName('');\n    setNewContent('');\n  }, []);\n\n  const handleAddPreset = useCallback(() => {\n    const trimmedName = newName.trim();\n    const trimmedContent = newContent.trim();\n    if (!trimmedName || !trimmedContent) return;\n\n    const updated = [...userPresets, { name: trimmedName, content: trimmedContent }];\n    setUserPresets(updated);\n    saveUserPresets(type, updated);\n    handleCloseModal();\n  }, [newName, newContent, userPresets, type, handleCloseModal]);\n\n  const handleDeletePreset = useCallback((index: number) => {\n    const updated = userPresets.filter((_, i) => i !== index);\n    setUserPresets(updated);\n    saveUserPresets(type, updated);\n  }, [userPresets, type]);\n\n  const capsuleBase = 'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs cursor-pointer transition-colors max-w-[200px] truncate';\n  const systemCapsule = `${capsuleBase} bg-gray-100 dark:bg-background-primary text-gray-600 dark:text-foreground-secondary hover:bg-banana-50 dark:hover:bg-banana-900/20 hover:text-banana-700 dark:hover:text-banana-400 border border-gray-200 dark:border-border-primary`;\n  const userCapsule = `${capsuleBase} bg-banana-50 dark:bg-banana-900/20 text-banana-700 dark:text-banana-400 hover:bg-banana-100 dark:hover:bg-banana-900/30 border border-banana-200 dark:border-banana-700/40`;\n\n  return (\n    <>\n      <div className=\"flex flex-wrap items-center gap-1.5 mt-2\" data-testid={`${type}-presets`}>\n        {/* System presets */}\n        {systemPresets.map((preset, i) => (\n          <button\n            key={`sys-${i}`}\n            type=\"button\"\n            data-testid={`${type}-system-preset-${i}`}\n            className={systemCapsule}\n            title={preset.content}\n            onClick={() => onAppend(preset.content)}\n          >\n            {preset.name}\n          </button>\n        ))}\n\n        {/* User presets */}\n        {userPresets.map((preset, i) => (\n          <span\n            key={`usr-${i}`}\n            className={userCapsule}\n            title={preset.content}\n            data-testid={`${type}-user-preset-${i}`}\n          >\n            <button\n              type=\"button\"\n              className=\"truncate\"\n              onClick={() => onAppend(preset.content)}\n            >\n              {preset.name}\n            </button>\n            <button\n              type=\"button\"\n              data-testid={`${type}-delete-preset-${i}`}\n              aria-label=\"Delete preset\"\n              className=\"ml-0.5 p-0.5 rounded-full hover:bg-banana-200 dark:hover:bg-banana-800/40 transition-colors\"\n              onClick={(e) => { e.stopPropagation(); handleDeletePreset(i); }}\n            >\n              <X size={10} />\n            </button>\n          </span>\n        ))}\n\n        {/* Add button */}\n        <button\n          type=\"button\"\n          data-testid={`${type}-add-preset`}\n          onClick={() => setIsModalOpen(true)}\n          className={`${capsuleBase} bg-white dark:bg-background-primary text-gray-400 dark:text-foreground-tertiary hover:text-banana-600 dark:hover:text-banana-400 hover:border-banana-300 dark:hover:border-banana-600/40 border border-dashed border-gray-300 dark:border-border-primary`}\n        >\n          <Plus size={10} />\n          {t('preset.addCustom')}\n        </button>\n      </div>\n\n      {/* Add preset modal */}\n      <Modal\n        isOpen={isModalOpen}\n        onClose={handleCloseModal}\n        title={t('preset.modalTitle')}\n        size=\"sm\"\n      >\n        <div className=\"space-y-4\">\n          <div>\n            <label className=\"block text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-1.5\">\n              {t('preset.nameLabel')}\n            </label>\n            <input\n              ref={nameInputRef}\n              data-testid={`${type}-preset-name-input`}\n              value={newName}\n              onChange={(e) => setNewName(e.target.value)}\n              placeholder={t('preset.namePlaceholder')}\n              className=\"w-full px-3 py-2 text-sm rounded-lg border border-gray-200 dark:border-border-primary bg-gray-50 dark:bg-background-primary text-gray-700 dark:text-foreground-secondary placeholder-gray-400 dark:placeholder-foreground-tertiary/50 focus:outline-none focus:border-banana-300 dark:focus:border-banana-500/40 transition-colors\"\n            />\n          </div>\n          <div>\n            <label className=\"block text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-1.5\">\n              {t('preset.contentLabel')}\n            </label>\n            <textarea\n              data-testid={`${type}-preset-content-input`}\n              value={newContent}\n              onChange={(e) => setNewContent(e.target.value)}\n              placeholder={t('preset.contentPlaceholder')}\n              rows={3}\n              className=\"w-full px-3 py-2 text-sm rounded-lg border border-gray-200 dark:border-border-primary bg-gray-50 dark:bg-background-primary text-gray-700 dark:text-foreground-secondary placeholder-gray-400 dark:placeholder-foreground-tertiary/50 resize-y focus:outline-none focus:border-banana-300 dark:focus:border-banana-500/40 transition-colors\"\n            />\n          </div>\n          <div className=\"flex justify-end gap-2 pt-2\">\n            <button\n              type=\"button\"\n              data-testid={`${type}-preset-cancel`}\n              onClick={handleCloseModal}\n              className=\"px-4 py-2 text-sm rounded-lg text-gray-600 dark:text-foreground-tertiary hover:bg-gray-100 dark:hover:bg-background-hover transition-colors\"\n            >\n              {t('preset.cancel')}\n            </button>\n            <button\n              type=\"button\"\n              data-testid={`${type}-preset-confirm`}\n              onClick={handleAddPreset}\n              disabled={!newName.trim() || !newContent.trim()}\n              className=\"px-4 py-2 text-sm rounded-lg bg-banana-500 text-white hover:bg-banana-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\n            >\n              {t('preset.add')}\n            </button>\n          </div>\n        </div>\n      </Modal>\n    </>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/shared/ProjectResourcesList.tsx",
    "content": "import React, { useState, useEffect, useCallback } from 'react';\nimport { Image as ImageIcon, RefreshCw, X, FileText } from 'lucide-react';\nimport { useT } from '@/hooks/useT';\nimport { listMaterials, deleteMaterial, listProjectReferenceFiles, type Material, type ReferenceFile } from '@/api/endpoints';\nimport { getImageUrl } from '@/api/client';\nimport { ReferenceFileCard } from './ReferenceFileCard';\n\n// ProjectResourcesList 组件自包含翻译\nconst projectResourcesI18n = {\n  zh: {\n    projectResources: {\n      uploadedFiles: \"已上传的文件\", uploadedImages: \"已上传图片\",\n      refreshList: \"刷新列表\", imageLoadFailed: \"图片加载失败\", deleteThisMaterial: \"删除此素材\"\n    },\n    material: { messages: { loadMaterialFailed: \"加载素材失败\", deleteSuccess: \"素材已删除\", deleteFailed: \"删除素材失败\" } }\n  },\n  en: {\n    projectResources: {\n      uploadedFiles: \"Uploaded Files\", uploadedImages: \"Uploaded Images\",\n      refreshList: \"Refresh List\", imageLoadFailed: \"Image load failed\", deleteThisMaterial: \"Delete this material\"\n    },\n    material: { messages: { loadMaterialFailed: \"Failed to load materials\", deleteSuccess: \"Material deleted\", deleteFailed: \"Failed to delete material\" } }\n  }\n};\n\ninterface ProjectResourcesListProps {\n  projectId: string | null;\n  className?: string;\n  showFiles?: boolean; // 是否显示参考文件\n  showImages?: boolean; // 是否显示图片素材\n  onFileClick?: (fileId: string) => void;\n  onImageClick?: (material: Material) => void;\n  showToast?: (props: { message: string; type: 'success' | 'error' | 'info' | 'warning' }) => void;\n}\n\n/**\n * 项目资源列表组件\n * 统一展示项目的参考文件和图片素材\n */\nexport const ProjectResourcesList: React.FC<ProjectResourcesListProps> = ({\n  projectId,\n  className = 'mb-4',\n  showFiles = true,\n  showImages = true,\n  onFileClick,\n  onImageClick,\n  showToast,\n}) => {\n  const t = useT(projectResourcesI18n);\n  const [materials, setMaterials] = useState<Material[]>([]);\n  const [files, setFiles] = useState<ReferenceFile[]>([]);\n  const [isLoadingMaterials, setIsLoadingMaterials] = useState(false);\n  const [isLoadingFiles, setIsLoadingFiles] = useState(false);\n  const [deletingMaterialIds, setDeletingMaterialIds] = useState<Set<string>>(new Set());\n  const [failedImageUrls, setFailedImageUrls] = useState<Set<string>>(new Set());\n\n  // 加载素材列表\n  const loadMaterials = useCallback(async () => {\n    if (!projectId || !showImages) return;\n    \n    setIsLoadingMaterials(true);\n    try {\n      const response = await listMaterials(projectId);\n      if (response.data?.materials) {\n        setMaterials(response.data.materials);\n      }\n    } catch (error: any) {\n      console.error('Load materials failed:', error);\n      showToast?.({ message: `${t('material.messages.loadMaterialFailed')}: ${error.message || t('common.unknownError')}`, type: 'error' });\n    } finally {\n      setIsLoadingMaterials(false);\n    }\n  }, [projectId, showImages]);\n\n  // 加载文件列表\n  const loadFiles = useCallback(async () => {\n    if (!projectId || !showFiles) return;\n    \n    setIsLoadingFiles(true);\n    try {\n      const response = await listProjectReferenceFiles(projectId);\n      if (response.data?.files) {\n        setFiles(response.data.files);\n      }\n    } catch (error: any) {\n      console.error('Load files failed:', error);\n    } finally {\n      setIsLoadingFiles(false);\n    }\n  }, [projectId, showFiles]);\n\n  useEffect(() => {\n    loadMaterials();\n    loadFiles();\n  }, [loadMaterials, loadFiles]);\n\n  // 删除素材\n  const handleDeleteMaterial = async (\n    e: React.MouseEvent<HTMLButtonElement>,\n    materialId: string\n  ) => {\n    e.stopPropagation();\n    \n    setDeletingMaterialIds(prev => new Set(prev).add(materialId));\n    \n    try {\n      await deleteMaterial(materialId);\n      setMaterials(prev => prev.filter(m => m.id !== materialId));\n      showToast?.({ message: t('material.messages.deleteSuccess'), type: 'success' });\n    } catch (error: any) {\n      console.error('Delete material failed:', error);\n      showToast?.({\n        message: error?.response?.data?.error?.message || error.message || t('material.messages.deleteFailed'),\n        type: 'error',\n      });\n    } finally {\n      setDeletingMaterialIds(prev => {\n        const next = new Set(prev);\n        next.delete(materialId);\n        return next;\n      });\n    }\n  };\n\n  const handleFileStatusChange = (updatedFile: ReferenceFile) => {\n    setFiles(prev => prev.map(f => f.id === updatedFile.id ? updatedFile : f));\n  };\n\n  const handleFileDelete = (fileId: string) => {\n    setFiles(prev => prev.filter(f => f.id !== fileId));\n  };\n\n  const getMaterialDisplayName = (m: Material) =>\n    (m.prompt && m.prompt.trim()) ||\n    (m.name && m.name.trim()) ||\n    (m.original_filename && m.original_filename.trim()) ||\n    (m.source_filename && m.source_filename.trim()) ||\n    m.filename ||\n    m.url;\n\n  // 如果没有项目ID，不显示\n  if (!projectId) {\n    return null;\n  }\n\n  // 如果两个都不显示任何内容，则不渲染\n  if ((!showFiles || files.length === 0) && (!showImages || materials.length === 0)) {\n    return null;\n  }\n\n  return (\n    <div className={className}>\n      {/* 参考文件列表 */}\n      {showFiles && files.length > 0 && (\n        <div className=\"mb-4\">\n          <div className=\"flex items-center justify-between mb-2\">\n            <div className=\"flex items-center gap-2\">\n              <FileText size={16} className=\"text-gray-500 dark:text-foreground-tertiary\" />\n              <span className=\"text-sm font-medium text-gray-700 dark:text-foreground-secondary\">\n                {t('projectResources.uploadedFiles')} ({files.length})\n              </span>\n            </div>\n            <button\n              onClick={loadFiles}\n              disabled={isLoadingFiles}\n              className=\"p-1 text-gray-400 hover:text-gray-600 disabled:opacity-50\"\n              title={t('projectResources.refreshList')}\n            >\n              <RefreshCw size={14} className={isLoadingFiles ? 'animate-spin' : ''} />\n            </button>\n          </div>\n          <div className=\"space-y-2\">\n            {files.map(file => (\n              <ReferenceFileCard\n                key={file.id}\n                file={file}\n                onDelete={handleFileDelete}\n                onStatusChange={handleFileStatusChange}\n                deleteMode=\"remove\"\n                onClick={() => onFileClick?.(file.id)}\n                showToast={showToast}\n              />\n            ))}\n          </div>\n        </div>\n      )}\n\n      {/* 图片素材列表 */}\n      {showImages && materials.length > 0 && (\n        <div>\n          <div className=\"flex items-center justify-between mb-2\">\n            <div className=\"flex items-center gap-2\">\n              <ImageIcon size={16} className=\"text-gray-500 dark:text-foreground-tertiary\" />\n              <span className=\"text-sm font-medium text-gray-700 dark:text-foreground-secondary\">\n                {t('projectResources.uploadedImages')} ({materials.length})\n              </span>\n            </div>\n            <button\n              onClick={loadMaterials}\n              disabled={isLoadingMaterials}\n              className=\"p-1 text-gray-400 hover:text-gray-600 disabled:opacity-50\"\n              title={t('projectResources.refreshList')}\n            >\n              <RefreshCw size={14} className={isLoadingMaterials ? 'animate-spin' : ''} />\n            </button>\n          </div>\n\n          {/* 横向滚动的图片列表 */}\n          <div className=\"flex gap-3 overflow-x-auto pb-2\">\n            {materials.map((material) => {\n              const isDeleting = deletingMaterialIds.has(material.id);\n              return (\n                <div\n                  key={material.id}\n                  className=\"relative flex-shrink-0 group cursor-pointer\"\n                  onClick={() => onImageClick?.(material)}\n                >\n                  {/* 图片容器 */}\n                  <div className=\"relative w-32 h-32 bg-gray-100 dark:bg-background-secondary rounded-lg overflow-hidden border-2 border-gray-200 dark:border-border-primary hover:border-banana-400 transition-colors\">\n                    {failedImageUrls.has(material.url) ? (\n                      <div className=\"w-full h-full flex items-center justify-center text-gray-400 text-xs text-center p-2\">\n                        {t('projectResources.imageLoadFailed')}\n                      </div>\n                    ) : (\n                      <img\n                        src={getImageUrl(material.url)}\n                        alt={getMaterialDisplayName(material)}\n                        className=\"w-full h-full object-cover\"\n                        onError={() => setFailedImageUrls(prev => new Set(prev).add(material.url))}\n                      />\n                    )}\n\n                    {/* 删除按钮 */}\n                    <button\n                      onClick={(e) => handleDeleteMaterial(e, material.id)}\n                      disabled={isDeleting}\n                      className=\"absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-600 active:scale-95 disabled:opacity-60\"\n                      title={t('projectResources.deleteThisMaterial')}\n                    >\n                      {isDeleting ? (\n                        <RefreshCw size={14} className=\"animate-spin\" />\n                      ) : (\n                        <X size={14} />\n                      )}\n                    </button>\n\n                    {/* 悬浮时显示文件名 */}\n                    <div className=\"absolute inset-x-0 bottom-0 bg-black/70 text-white text-xs p-1 opacity-0 group-hover:opacity-100 transition-opacity truncate\">\n                      {getMaterialDisplayName(material)}\n                    </div>\n                  </div>\n                </div>\n              );\n            })}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n\nexport default ProjectResourcesList;\n\n"
  },
  {
    "path": "frontend/src/components/shared/ProjectSettingsModal.tsx",
    "content": "import React, { useState } from 'react';\nimport { X, FileText, Settings as SettingsIcon, Download, Sparkles, AlertTriangle, HelpCircle } from 'lucide-react';\nimport { Button, Textarea } from '@/components/shared';\nimport { useT } from '@/hooks/useT';\nimport { Settings } from '@/pages/Settings';\nimport type { ExportExtractorMethod, ExportInpaintMethod } from '@/types';\nimport { ASPECT_RATIO_OPTIONS } from '@/config/aspectRatio';\n\n// ProjectSettings 组件自包含翻译\nconst projectSettingsI18n = {\n  zh: {\n    projectSettings: {\n      title: \"设置\", projectConfig: \"项目设置\", exportConfig: \"导出设置\", globalConfig: \"全局设置\",\n      projectConfigTitle: \"项目级配置\", projectConfigDesc: \"这些设置仅应用于当前项目，不影响其他项目\",\n      globalConfigTitle: \"全局设置\", globalConfigDesc: \"这些设置应用于所有项目\",\n      aspectRatio: \"画面比例\", aspectRatioDesc: \"设置生成幻灯片图片的画面比例\",\n      aspectRatioLocked: \"已生成图片的项目无法调整画面比例\",\n      aspectRatioHelp: \"部分模型仅支持特定的画面比例（如 16:9、4:3、1:1）。如果图片生成报错，可尝试切换画面比例后重试。\",\n      extraRequirements: \"额外要求\", extraRequirementsDesc: \"在生成每个页面时，AI 会参考这些额外要求\",\n      extraRequirementsPlaceholder: \"例如：使用紧凑的布局，顶部展示一级大纲标题，加入更丰富的PPT插图...\",\n      saveExtraRequirements: \"保存额外要求\",\n      styleDescription: \"风格描述\", styleDescriptionDesc: \"描述您期望的 PPT 整体风格，AI 将根据描述生成相应风格的页面\",\n      styleDescriptionPlaceholder: \"例如：简约商务风格，使用深蓝色和白色配色，字体清晰大方，布局整洁...\",\n      saveStyleDescription: \"保存风格描述\",\n      styleTip: \"风格描述会在生成图片时自动添加到提示词中。如果同时上传了模板图片，风格描述会作为补充说明。\",\n      editablePptxExport: \"可编辑 PPTX 导出设置\", editablePptxExportDesc: \"配置「导出可编辑 PPTX」功能的处理方式。这些设置影响导出质量和API调用成本。\",\n      extractorMethod: \"组件提取方法\", extractorMethodDesc: \"选择如何从PPT图片中提取文字、表格等可编辑组件\",\n      extractorHybrid: \"混合提取（推荐）\", extractorHybridDesc: \"MinerU版面分析 + 百度高精度OCR，文字识别更精确\",\n      extractorMineru: \"MinerU提取\", extractorMineruDesc: \"仅使用MinerU进行版面分析和文字识别\",\n      backgroundMethod: \"背景图获取方法\", backgroundMethodDesc: \"选择如何生成干净的背景图（移除原图中的文字后用于PPT背景）\",\n      backgroundHybrid: \"混合方式获取（推荐）\", backgroundHybridDesc: \"百度精确去除文字 + 生成式模型提升画质\",\n      backgroundGenerative: \"生成式获取\", backgroundGenerativeDesc: \"使用生成式大模型（如Gemini）直接生成背景，背景质量高但有遗留元素的可能\",\n      backgroundBaidu: \"百度抹除服务获取\", backgroundBaiduDesc: \"使用百度图像修复API，速度快但画质一般\",\n      usesAiModel: \"使用文生图模型\",\n      costTip: \"标有「使用文生图模型」的选项会调用AI图片生成API（如Gemini），每页会产生额外的API调用费用。如果需要控制成本，可选择「百度修复」方式。\",\n      errorHandling: \"错误处理策略\", errorHandlingDesc: \"配置导出过程中遇到错误时的处理方式\",\n      allowPartialResult: \"允许返回半成品\", allowPartialResultDesc: \"开启后，导出过程中遇到错误（如样式提取失败、文本渲染失败等）时会跳过错误继续导出，最终可能得到不完整的结果。关闭时，任何错误都会立即停止导出并提示具体原因。\",\n      allowPartialResultWarning: \"开启此选项可能导致导出的 PPTX 文件中部分文字样式丢失、元素位置错误或内容缺失。建议仅在需要快速获取结果且可以接受质量损失时开启。\",\n      saveExportSettings: \"保存导出设置\",\n      tip: \"提示\"\n    },\n    shared: { saving: \"保存中...\" }\n  },\n  en: {\n    projectSettings: {\n      title: \"Settings\", projectConfig: \"Project Settings\", exportConfig: \"Export Settings\", globalConfig: \"Global Settings\",\n      projectConfigTitle: \"Project-level Configuration\", projectConfigDesc: \"These settings only apply to the current project\",\n      globalConfigTitle: \"Global Settings\", globalConfigDesc: \"These settings apply to all projects\",\n      aspectRatio: \"Aspect Ratio\", aspectRatioDesc: \"Set the aspect ratio for generated slide images\",\n      aspectRatioLocked: \"Cannot change aspect ratio after images have been generated\",\n      aspectRatioHelp: \"Some models only support specific aspect ratios (e.g. 16:9, 4:3, 1:1). If image generation fails, try switching to a different aspect ratio.\",\n      extraRequirements: \"Extra Requirements\", extraRequirementsDesc: \"AI will reference these extra requirements when generating each page\",\n      extraRequirementsPlaceholder: \"e.g., Use compact layout, show first-level outline title at top, add richer PPT illustrations...\",\n      saveExtraRequirements: \"Save Extra Requirements\",\n      styleDescription: \"Style Description\", styleDescriptionDesc: \"Describe your expected PPT overall style, AI will generate pages in that style\",\n      styleDescriptionPlaceholder: \"e.g., Simple business style, use navy blue and white colors, clear fonts, clean layout...\",\n      saveStyleDescription: \"Save Style Description\",\n      styleTip: \"Style description will be automatically added to the prompt when generating images. If a template image is also uploaded, the style description will serve as supplementary notes.\",\n      editablePptxExport: \"Editable PPTX Export Settings\", editablePptxExportDesc: \"Configure how \\\"Export Editable PPTX\\\" works. These settings affect export quality and API call costs.\",\n      extractorMethod: \"Component Extraction Method\", extractorMethodDesc: \"Choose how to extract editable components like text and tables from PPT images\",\n      extractorHybrid: \"Hybrid Extraction (Recommended)\", extractorHybridDesc: \"MinerU layout analysis + Baidu high-precision OCR for more accurate text recognition\",\n      extractorMineru: \"MinerU Extraction\", extractorMineruDesc: \"Use only MinerU for layout analysis and text recognition\",\n      backgroundMethod: \"Background Image Method\", backgroundMethodDesc: \"Choose how to generate clean background images (remove text from original for PPT background)\",\n      backgroundHybrid: \"Hybrid Method (Recommended)\", backgroundHybridDesc: \"Baidu precise text removal + generative model quality enhancement\",\n      backgroundGenerative: \"Generative Method\", backgroundGenerativeDesc: \"Use generative model (like Gemini) to directly generate background, high quality but may have residual elements\",\n      backgroundBaidu: \"Baidu Inpainting\", backgroundBaiduDesc: \"Use Baidu image repair API, fast but average quality\",\n      usesAiModel: \"Uses AI Image Model\",\n      costTip: \"Options marked \\\"Uses AI Image Model\\\" will call AI image generation API (like Gemini), incurring extra API costs per page. To control costs, choose \\\"Baidu Inpainting\\\".\",\n      errorHandling: \"Error Handling Strategy\", errorHandlingDesc: \"Configure how to handle errors during export\",\n      allowPartialResult: \"Allow Partial Results\", allowPartialResultDesc: \"When enabled, export will skip errors (like style extraction or text rendering failures) and continue, potentially resulting in incomplete output. When disabled, any error will stop export immediately with a specific reason.\",\n      allowPartialResultWarning: \"Enabling this option may result in PPTX files with missing text styles, mispositioned elements, or missing content. Only enable when you need quick results and can accept quality loss.\",\n      saveExportSettings: \"Save Export Settings\",\n      tip: \"Tip\"\n    },\n    shared: { saving: \"Saving...\" }\n  }\n};\n\ninterface ProjectSettingsModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  extraRequirements: string;\n  templateStyle: string;\n  onExtraRequirementsChange: (value: string) => void;\n  onTemplateStyleChange: (value: string) => void;\n  onSaveExtraRequirements: () => void;\n  onSaveTemplateStyle: () => void;\n  isSavingRequirements: boolean;\n  isSavingTemplateStyle: boolean;\n  exportExtractorMethod?: ExportExtractorMethod;\n  exportInpaintMethod?: ExportInpaintMethod;\n  exportAllowPartial?: boolean;\n  onExportExtractorMethodChange?: (value: ExportExtractorMethod) => void;\n  onExportInpaintMethodChange?: (value: ExportInpaintMethod) => void;\n  onExportAllowPartialChange?: (value: boolean) => void;\n  onSaveExportSettings?: () => void;\n  isSavingExportSettings?: boolean;\n  aspectRatio?: string;\n  onAspectRatioChange?: (value: string) => void;\n  onSaveAspectRatio?: () => void;\n  isSavingAspectRatio?: boolean;\n  hasImages?: boolean;\n}\n\ntype SettingsTab = 'project' | 'global' | 'export';\n\nexport const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({\n  isOpen,\n  onClose,\n  extraRequirements,\n  templateStyle,\n  onExtraRequirementsChange,\n  onTemplateStyleChange,\n  onSaveExtraRequirements,\n  onSaveTemplateStyle,\n  isSavingRequirements,\n  isSavingTemplateStyle,\n  exportExtractorMethod = 'hybrid',\n  exportInpaintMethod = 'hybrid',\n  exportAllowPartial = false,\n  onExportExtractorMethodChange,\n  onExportInpaintMethodChange,\n  onExportAllowPartialChange,\n  onSaveExportSettings,\n  isSavingExportSettings = false,\n  aspectRatio = '16:9',\n  onAspectRatioChange,\n  onSaveAspectRatio,\n  isSavingAspectRatio = false,\n  hasImages = false,\n}) => {\n  const t = useT(projectSettingsI18n);\n  const [activeTab, setActiveTab] = useState<SettingsTab>('project');\n\n  const EXTRACTOR_METHOD_OPTIONS: { value: ExportExtractorMethod; labelKey: string; descKey: string }[] = [\n    { value: 'hybrid', labelKey: 'projectSettings.extractorHybrid', descKey: 'projectSettings.extractorHybridDesc' },\n    { value: 'mineru', labelKey: 'projectSettings.extractorMineru', descKey: 'projectSettings.extractorMineruDesc' },\n  ];\n\n  const INPAINT_METHOD_OPTIONS: { value: ExportInpaintMethod; labelKey: string; descKey: string; usesAI: boolean }[] = [\n    { value: 'hybrid', labelKey: 'projectSettings.backgroundHybrid', descKey: 'projectSettings.backgroundHybridDesc', usesAI: true },\n    { value: 'generative', labelKey: 'projectSettings.backgroundGenerative', descKey: 'projectSettings.backgroundGenerativeDesc', usesAI: true },\n    { value: 'baidu', labelKey: 'projectSettings.backgroundBaidu', descKey: 'projectSettings.backgroundBaiduDesc', usesAI: false },\n  ];\n\n  if (!isOpen) return null;\n\n  return (\n    <div className=\"fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4\">\n      <div className=\"bg-white dark:bg-background-secondary rounded-xl shadow-2xl w-full max-w-5xl h-[90vh] flex flex-col overflow-hidden\">\n        <div className=\"flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-border-primary flex-shrink-0\">\n          <h2 className=\"text-xl font-bold text-gray-900 dark:text-foreground-primary\">{t('projectSettings.title')}</h2>\n          <button\n            onClick={onClose}\n            className=\"p-2 hover:bg-gray-100 dark:hover:bg-background-hover rounded-lg transition-colors\"\n            aria-label={t('common.close')}\n          >\n            <X size={20} />\n          </button>\n        </div>\n\n        <div className=\"flex-1 flex overflow-hidden min-h-0\">\n          <aside className=\"w-64 bg-gray-50 dark:bg-background-primary border-r border-gray-200 dark:border-border-primary flex-shrink-0\">\n            <nav className=\"p-4 space-y-2\">\n              <button\n                onClick={() => setActiveTab('project')}\n                className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-all ${\n                  activeTab === 'project'\n                    ? 'bg-banana-500 text-white shadow-md'\n                    : 'bg-white dark:bg-background-secondary text-gray-700 dark:text-foreground-secondary hover:bg-gray-100 dark:hover:bg-background-hover'\n                }`}\n              >\n                <FileText size={20} />\n                <span className=\"font-medium\">{t('projectSettings.projectConfig')}</span>\n              </button>\n              <button\n                onClick={() => setActiveTab('export')}\n                className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-all ${\n                  activeTab === 'export'\n                    ? 'bg-banana-500 text-white shadow-md'\n                    : 'bg-white dark:bg-background-secondary text-gray-700 dark:text-foreground-secondary hover:bg-gray-100 dark:hover:bg-background-hover'\n                }`}\n              >\n                <Download size={20} />\n                <span className=\"font-medium\">{t('projectSettings.exportConfig')}</span>\n              </button>\n              <button\n                onClick={() => setActiveTab('global')}\n                className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-all ${\n                  activeTab === 'global'\n                    ? 'bg-banana-500 text-white shadow-md'\n                    : 'bg-white dark:bg-background-secondary text-gray-700 dark:text-foreground-secondary hover:bg-gray-100 dark:hover:bg-background-hover'\n                }`}\n              >\n                <SettingsIcon size={20} />\n                <span className=\"font-medium\">{t('projectSettings.globalConfig')}</span>\n              </button>\n            </nav>\n          </aside>\n\n          <div className=\"flex-1 overflow-y-auto p-6\">\n            {activeTab === 'project' ? (\n              <div className=\"max-w-3xl space-y-6\">\n                <div>\n                  <h3 className=\"text-lg font-semibold text-gray-900 dark:text-foreground-primary mb-4\">{t('projectSettings.projectConfigTitle')}</h3>\n                  <p className=\"text-sm text-gray-600 dark:text-foreground-tertiary mb-6\">\n                    {t('projectSettings.projectConfigDesc')}\n                  </p>\n                </div>\n\n                {/* 画面比例 */}\n                <div className=\"bg-gray-50 dark:bg-background-primary rounded-lg p-6 space-y-4\">\n                  <div>\n                    <div className=\"flex items-center gap-2 mb-2\">\n                      <h4 className=\"text-base font-semibold text-gray-900 dark:text-foreground-primary\">{t('projectSettings.aspectRatio')}</h4>\n                      <div className=\"relative group\">\n                        <button type=\"button\" className=\"p-1 -m-1 rounded-full focus:outline-none focus:ring-2 focus:ring-banana-500\">\n                          <HelpCircle size={16} className=\"text-gray-400 dark:text-foreground-tertiary cursor-help\" />\n                        </button>\n                        <div className=\"absolute left-1/2 -translate-x-1/2 bottom-full mb-2 w-64 p-2 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible transition-all z-10 pointer-events-none\">\n                          {t('projectSettings.aspectRatioHelp')}\n                        </div>\n                      </div>\n                    </div>\n                    <p className=\"text-sm text-gray-600 dark:text-foreground-tertiary\">\n                      {hasImages ? t('projectSettings.aspectRatioLocked') : t('projectSettings.aspectRatioDesc')}\n                    </p>\n                  </div>\n                  <div className=\"flex flex-wrap gap-2\">\n                    {ASPECT_RATIO_OPTIONS.map((opt) => (\n                      <button\n                        key={opt.value}\n                        type=\"button\"\n                        disabled={hasImages}\n                        onClick={() => onAspectRatioChange?.(opt.value)}\n                        className={`px-4 py-2 text-sm font-medium rounded-lg border-2 transition-all disabled:opacity-50 disabled:cursor-not-allowed ${\n                          aspectRatio === opt.value\n                            ? 'border-banana-500 bg-banana-50 dark:bg-background-secondary text-banana-700 dark:text-banana'\n                            : 'border-gray-200 dark:border-border-primary text-gray-700 dark:text-foreground-secondary hover:border-gray-300 dark:hover:border-gray-500 bg-white dark:bg-background-secondary'\n                        }`}\n                      >\n                        {opt.label}\n                      </button>\n                    ))}\n                  </div>\n                  {onSaveAspectRatio && !hasImages && (\n                    <Button\n                      variant=\"secondary\"\n                      size=\"sm\"\n                      onClick={onSaveAspectRatio}\n                      disabled={isSavingAspectRatio}\n                      className=\"w-full sm:w-auto\"\n                    >\n                      {isSavingAspectRatio ? t('shared.saving') : t('common.save')}\n                    </Button>\n                  )}\n                </div>\n\n                <div className=\"bg-gray-50 dark:bg-background-primary rounded-lg p-6 space-y-4\">\n                  <div>\n                    <h4 className=\"text-base font-semibold text-gray-900 dark:text-foreground-primary mb-2\">{t('projectSettings.extraRequirements')}</h4>\n                    <p className=\"text-sm text-gray-600 dark:text-foreground-tertiary\">\n                      {t('projectSettings.extraRequirementsDesc')}\n                    </p>\n                  </div>\n                  <Textarea\n                    value={extraRequirements}\n                    onChange={(e) => onExtraRequirementsChange(e.target.value)}\n                    placeholder={t('projectSettings.extraRequirementsPlaceholder')}\n                    rows={4}\n                    className=\"text-sm\"\n                  />\n                  <Button\n                    variant=\"secondary\"\n                    size=\"sm\"\n                    onClick={onSaveExtraRequirements}\n                    disabled={isSavingRequirements}\n                    className=\"w-full sm:w-auto\"\n                  >\n                    {isSavingRequirements ? t('shared.saving') : t('projectSettings.saveExtraRequirements')}\n                  </Button>\n                </div>\n\n                <div className=\"bg-blue-50 dark:bg-blue-900/30 rounded-lg p-6 space-y-4\">\n                  <div>\n                    <h4 className=\"text-base font-semibold text-gray-900 dark:text-foreground-primary mb-2\">{t('projectSettings.styleDescription')}</h4>\n                    <p className=\"text-sm text-gray-600 dark:text-foreground-tertiary\">\n                      {t('projectSettings.styleDescriptionDesc')}\n                    </p>\n                  </div>\n                  <Textarea\n                    value={templateStyle}\n                    onChange={(e) => onTemplateStyleChange(e.target.value)}\n                    placeholder={t('projectSettings.styleDescriptionPlaceholder')}\n                    rows={5}\n                    className=\"text-sm\"\n                  />\n                  <div className=\"flex flex-col sm:flex-row gap-3\">\n                    <Button\n                      variant=\"secondary\"\n                      size=\"sm\"\n                      onClick={onSaveTemplateStyle}\n                      disabled={isSavingTemplateStyle}\n                      className=\"w-full sm:w-auto\"\n                    >\n                      {isSavingTemplateStyle ? t('shared.saving') : t('projectSettings.saveStyleDescription')}\n                    </Button>\n                  </div>\n                  <div className=\"bg-blue-100 dark:bg-blue-900/30 rounded-md p-3\">\n                    <p className=\"text-xs text-blue-900 dark:text-blue-300\">\n                      💡 <strong>{t('projectSettings.tip')}：</strong>{t('projectSettings.styleTip')}\n                    </p>\n                  </div>\n                </div>\n              </div>\n            ) : activeTab === 'export' ? (\n              <div className=\"max-w-3xl space-y-6\">\n                <div>\n                  <h3 className=\"text-lg font-semibold text-gray-900 dark:text-foreground-primary mb-4\">{t('projectSettings.editablePptxExport')}</h3>\n                  <p className=\"text-sm text-gray-600 dark:text-foreground-tertiary mb-6\">\n                    {t('projectSettings.editablePptxExportDesc')}\n                  </p>\n                </div>\n\n                <div className=\"bg-gray-50 dark:bg-background-primary rounded-lg p-6 space-y-4\">\n                  <div>\n                    <h4 className=\"text-base font-semibold text-gray-900 dark:text-foreground-primary mb-2\">{t('projectSettings.extractorMethod')}</h4>\n                    <p className=\"text-sm text-gray-600 dark:text-foreground-tertiary\">\n                      {t('projectSettings.extractorMethodDesc')}\n                    </p>\n                  </div>\n                  <div className=\"space-y-3\">\n                    {EXTRACTOR_METHOD_OPTIONS.map((option) => (\n                      <label\n                        key={option.value}\n                        className={`flex items-start gap-3 p-4 rounded-lg border-2 cursor-pointer transition-all ${\n                          exportExtractorMethod === option.value\n                            ? 'border-banana-500 bg-banana-50 dark:bg-background-secondary'\n                            : 'border-gray-200 dark:border-border-primary hover:border-gray-300 dark:hover:border-gray-500 bg-white dark:bg-background-secondary'\n                        }`}\n                      >\n                        <input\n                          type=\"radio\"\n                          name=\"extractorMethod\"\n                          value={option.value}\n                          checked={exportExtractorMethod === option.value}\n                          onChange={(e) => onExportExtractorMethodChange?.(e.target.value as ExportExtractorMethod)}\n                          className=\"mt-1 w-4 h-4 text-banana-500 focus:ring-banana-500\"\n                        />\n                        <div className=\"flex-1\">\n                          <div className=\"font-medium text-gray-900 dark:text-foreground-primary\">{t(option.labelKey)}</div>\n                          <div className=\"text-sm text-gray-600 dark:text-foreground-tertiary mt-1\">{t(option.descKey)}</div>\n                        </div>\n                      </label>\n                    ))}\n                  </div>\n                </div>\n\n                <div className=\"bg-orange-50 dark:bg-orange-900/20 rounded-lg p-6 space-y-4\">\n                  <div>\n                    <h4 className=\"text-base font-semibold text-gray-900 dark:text-foreground-primary mb-2\">{t('projectSettings.backgroundMethod')}</h4>\n                    <p className=\"text-sm text-gray-600 dark:text-foreground-tertiary\">\n                      {t('projectSettings.backgroundMethodDesc')}\n                    </p>\n                  </div>\n                  <div className=\"space-y-3\">\n                    {INPAINT_METHOD_OPTIONS.map((option) => (\n                      <label\n                        key={option.value}\n                        className={`flex items-start gap-3 p-4 rounded-lg border-2 cursor-pointer transition-all ${\n                          exportInpaintMethod === option.value\n                            ? 'border-banana-500 bg-banana-50 dark:bg-background-secondary'\n                            : 'border-gray-200 dark:border-border-primary hover:border-gray-300 dark:hover:border-gray-500 bg-white dark:bg-background-secondary'\n                        }`}\n                      >\n                        <input\n                          type=\"radio\"\n                          name=\"inpaintMethod\"\n                          value={option.value}\n                          checked={exportInpaintMethod === option.value}\n                          onChange={(e) => onExportInpaintMethodChange?.(e.target.value as ExportInpaintMethod)}\n                          className=\"mt-1 w-4 h-4 text-banana-500 focus:ring-banana-500\"\n                        />\n                        <div className=\"flex-1\">\n                          <div className=\"flex items-center gap-2\">\n                            <span className=\"font-medium text-gray-900 dark:text-foreground-primary\">{t(option.labelKey)}</span>\n                            {option.usesAI && (\n                              <span className=\"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300\">\n                                <Sparkles size={12} />\n                                {t('projectSettings.usesAiModel')}\n                              </span>\n                            )}\n                          </div>\n                          <div className=\"text-sm text-gray-600 dark:text-foreground-tertiary mt-1\">{t(option.descKey)}</div>\n                        </div>\n                      </label>\n                    ))}\n                  </div>\n                  <div className=\"bg-amber-100 dark:bg-amber-900/20 rounded-md p-3 flex items-start gap-2\">\n                    <AlertTriangle size={16} className=\"text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5\" />\n                    <p className=\"text-xs text-amber-900 dark:text-amber-300\">\n                      <strong>{t('projectSettings.tip')}：</strong>{t('projectSettings.costTip')}\n                    </p>\n                  </div>\n                </div>\n\n                <div className=\"bg-red-50 dark:bg-red-900/20 rounded-lg p-6 space-y-4\">\n                  <div>\n                    <h4 className=\"text-base font-semibold text-gray-900 dark:text-foreground-primary mb-2\">{t('projectSettings.errorHandling')}</h4>\n                    <p className=\"text-sm text-gray-600 dark:text-foreground-tertiary\">\n                      {t('projectSettings.errorHandlingDesc')}\n                    </p>\n                  </div>\n                  <label className=\"flex items-start gap-3 cursor-pointer\">\n                    <input\n                      type=\"checkbox\"\n                      checked={exportAllowPartial}\n                      onChange={(e) => onExportAllowPartialChange?.(e.target.checked)}\n                      className=\"mt-1 w-4 h-4 text-red-500 focus:ring-red-500 rounded\"\n                    />\n                    <div className=\"flex-1\">\n                      <div className=\"font-medium text-gray-900 dark:text-foreground-primary\">{t('projectSettings.allowPartialResult')}</div>\n                      <div className=\"text-sm text-gray-600 dark:text-foreground-tertiary mt-1\">\n                        {t('projectSettings.allowPartialResultDesc')}\n                      </div>\n                    </div>\n                  </label>\n                  <div className=\"bg-red-100 dark:bg-red-900/20 rounded-md p-3 flex items-start gap-2\">\n                    <AlertTriangle size={16} className=\"text-red-700 dark:text-red-400 flex-shrink-0 mt-0.5\" />\n                    <p className=\"text-xs text-red-900 dark:text-red-300\">\n                      <strong>{t('common.warning')}：</strong>{t('projectSettings.allowPartialResultWarning')}\n                    </p>\n                  </div>\n                </div>\n\n                {onSaveExportSettings && (\n                  <div className=\"flex justify-end pt-4\">\n                    <Button\n                      variant=\"primary\"\n                      onClick={onSaveExportSettings}\n                      disabled={isSavingExportSettings}\n                    >\n                      {isSavingExportSettings ? t('shared.saving') : t('projectSettings.saveExportSettings')}\n                    </Button>\n                  </div>\n                )}\n              </div>\n            ) : (\n              <div className=\"max-w-4xl\">\n                <div className=\"mb-6\">\n                  <h3 className=\"text-lg font-semibold text-gray-900 dark:text-foreground-primary mb-2\">{t('projectSettings.globalConfigTitle')}</h3>\n                  <p className=\"text-sm text-gray-600 dark:text-foreground-tertiary\">\n                    {t('projectSettings.globalConfigDesc')}\n                  </p>\n                </div>\n                <Settings />\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/shared/ReferenceFileCard.tsx",
    "content": "import React, { useEffect, useState } from 'react';\nimport { FileText, Loader2, CheckCircle2, XCircle, X, RefreshCw } from 'lucide-react';\nimport { getReferenceFile, deleteReferenceFile, dissociateFileFromProject, triggerFileParse, type ReferenceFile } from '@/api/endpoints';\nimport { useT } from '@/hooks/useT';\n\n// ReferenceFileCard 组件自包含翻译\nconst referenceFileCardI18n = {\n  zh: {\n    referenceFile: {\n      parseStatus: { pending: \"等待解析\", parsing: \"解析中...\", completed: \"解析完成\", failed: \"解析失败\" },\n      reparse: \"重新解析\", removeFromProject: \"从项目中移除\", deleteFile: \"删除文件\",\n      imageCaptionFailed: \"⚠️ {{count}} 张图片未能生成描述\",\n      previewAfterParse: \"解析完成后可预览\"\n    }\n  },\n  en: {\n    referenceFile: {\n      parseStatus: { pending: \"Pending\", parsing: \"Parsing...\", completed: \"Completed\", failed: \"Failed\" },\n      reparse: \"Reparse\", removeFromProject: \"Remove from Project\", deleteFile: \"Delete File\",\n      imageCaptionFailed: \"⚠️ {{count}} images failed to generate captions\",\n      previewAfterParse: \"Preview available after parsing\"\n    }\n  }\n};\n\nexport interface ReferenceFileCardProps {\n  file: ReferenceFile;\n  onDelete: (fileId: string) => void;\n  onStatusChange?: (file: ReferenceFile) => void;\n  deleteMode?: 'delete' | 'remove';\n  onClick?: () => void;\n  showToast?: (props: { message: string; type: 'success' | 'error' | 'info' | 'warning' }) => void;\n}\n\nexport const ReferenceFileCard: React.FC<ReferenceFileCardProps> = ({\n  file: initialFile,\n  onDelete,\n  onStatusChange,\n  deleteMode = 'delete',\n  onClick,\n  showToast,\n}) => {\n  const t = useT(referenceFileCardI18n);\n  const [file, setFile] = useState<ReferenceFile>(initialFile);\n  const [isDeleting, setIsDeleting] = useState(false);\n  const [isReparsing, setIsReparsing] = useState(false);\n\n  useEffect(() => {\n    if (file.parse_status === 'pending' || file.parse_status === 'parsing') {\n      const intervalId = setInterval(async () => {\n        try {\n          const response = await getReferenceFile(file.id);\n          if (response.data?.file) {\n            const updatedFile = response.data.file;\n            setFile(updatedFile);\n            \n            if (onStatusChange) {\n              onStatusChange(updatedFile);\n            }\n            \n            if (updatedFile.parse_status === 'completed' || updatedFile.parse_status === 'failed') {\n              clearInterval(intervalId);\n            }\n          }\n        } catch (error) {\n          console.error('Failed to poll file status:', error);\n        }\n      }, 2000);\n\n      return () => {\n        clearInterval(intervalId);\n      };\n    }\n  }, [file.id, file.parse_status, onStatusChange]);\n\n  const handleDelete = async () => {\n    if (isDeleting) return;\n    \n    setIsDeleting(true);\n    try {\n      if (deleteMode === 'remove') {\n        await dissociateFileFromProject(file.id);\n      } else {\n        await deleteReferenceFile(file.id);\n      }\n      onDelete(file.id);\n    } catch (error) {\n      console.error('Failed to delete/remove file:', error);\n      setIsDeleting(false);\n    }\n  };\n\n  const handleReparse = async () => {\n    if (isReparsing || file.parse_status === 'parsing' || file.parse_status === 'pending') return;\n    \n    setIsReparsing(true);\n    try {\n      const response = await triggerFileParse(file.id);\n      if (response.data?.file) {\n        const updatedFile = response.data.file;\n        setFile(updatedFile);\n        \n        if (onStatusChange) {\n          onStatusChange(updatedFile);\n        }\n      }\n    } catch (error) {\n      console.error('Failed to trigger reparse:', error);\n    } finally {\n      setIsReparsing(false);\n    }\n  };\n\n  const formatFileSize = (bytes: number): string => {\n    if (bytes < 1024) return `${bytes} B`;\n    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n  };\n\n  const getStatusIcon = () => {\n    switch (file.parse_status) {\n      case 'pending':\n      case 'parsing':\n        return <Loader2 className=\"w-4 h-4 text-blue-500 animate-spin\" />;\n      case 'completed':\n        return <CheckCircle2 className=\"w-4 h-4 text-green-500\" />;\n      case 'failed':\n        return <XCircle className=\"w-4 h-4 text-red-500\" />;\n      default:\n        return null;\n    }\n  };\n\n  const getStatusText = () => {\n    switch (file.parse_status) {\n      case 'pending':\n        return t('referenceFile.parseStatus.pending');\n      case 'parsing':\n        return t('referenceFile.parseStatus.parsing');\n      case 'completed':\n        return t('referenceFile.parseStatus.completed');\n      case 'failed':\n        return t('referenceFile.parseStatus.failed');\n      default:\n        return '';\n    }\n  };\n\n  const getStatusColor = () => {\n    switch (file.parse_status) {\n      case 'pending':\n      case 'parsing':\n        return 'text-blue-600';\n      case 'completed':\n        return 'text-green-600';\n      case 'failed':\n        return 'text-red-600';\n      default:\n        return 'text-gray-600 dark:text-foreground-tertiary';\n    }\n  };\n\n  return (\n    <div\n      className={`flex items-center gap-2 px-3 py-2 w-72 bg-white dark:bg-background-secondary border border-gray-200 dark:border-border-primary rounded-lg hover:shadow-sm transition-shadow ${\n        onClick ? 'cursor-pointer' : ''\n      }`}\n      onClick={() => {\n        if (!onClick) return;\n        if (file.parse_status === 'pending' || file.parse_status === 'parsing') {\n          showToast?.({ message: t('referenceFile.previewAfterParse'), type: 'info' });\n          return;\n        }\n        onClick();\n      }}\n    >\n      <div className=\"flex-shrink-0\">\n        <div className=\"w-8 h-8 bg-blue-50 dark:bg-blue-900/30 rounded-md flex items-center justify-center\">\n          <FileText className=\"w-4 h-4 text-blue-600\" />\n        </div>\n      </div>\n\n      <div className=\"flex-1 min-w-0\">\n        <div className=\"flex items-center gap-2\">\n          <p className=\"text-sm font-medium text-gray-900 dark:text-foreground-primary truncate\">\n            {file.filename}\n          </p>\n          <span className=\"text-xs text-gray-500 dark:text-foreground-tertiary flex-shrink-0\">\n            {formatFileSize(file.file_size)}\n          </span>\n        </div>\n        \n        <div className=\"flex items-center gap-1.5 mt-1\">\n          {getStatusIcon()}\n          <p className={`text-xs ${getStatusColor()}`}>\n            {getStatusText()}\n          </p>\n        </div>\n\n        {file.parse_status === 'completed' && \n         typeof file.image_caption_failed_count === 'number' && \n         file.image_caption_failed_count > 0 && (\n          <p className=\"text-xs text-orange-500 mt-1\">\n            {t('referenceFile.imageCaptionFailed', { count: file.image_caption_failed_count })}\n          </p>\n        )}\n\n        {file.parse_status === 'failed' && file.error_message && (\n          <p className=\"text-xs text-red-500 mt-1 line-clamp-2\">\n            {file.error_message}\n          </p>\n        )}\n      </div>\n\n      <div className=\"flex items-center gap-1\">\n        {(file.parse_status === 'completed' || file.parse_status === 'failed') && (\n          <button\n            onClick={(e) => {\n              e.stopPropagation();\n              handleReparse();\n            }}\n            disabled={isReparsing}\n            className=\"flex-shrink-0 p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors disabled:opacity-50\"\n            title={t('referenceFile.reparse')}\n          >\n            {isReparsing ? (\n              <Loader2 className=\"w-4 h-4 animate-spin\" />\n            ) : (\n              <RefreshCw className=\"w-4 h-4\" />\n            )}\n          </button>\n        )}\n        \n        <button\n          onClick={(e) => {\n            e.stopPropagation();\n            handleDelete();\n          }}\n          disabled={isDeleting}\n          className=\"flex-shrink-0 p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors disabled:opacity-50\"\n          title={deleteMode === 'remove' ? t('referenceFile.removeFromProject') : t('referenceFile.deleteFile')}\n        >\n          {isDeleting ? (\n            <Loader2 className=\"w-4 h-4 animate-spin\" />\n          ) : (\n            <X className=\"w-4 h-4\" />\n          )}\n        </button>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/shared/ReferenceFileList.tsx",
    "content": "import React, { useState, useEffect, useRef } from 'react';\nimport { ReferenceFileCard } from '@/components/shared';\nimport { useT } from '@/hooks/useT';\nimport { listProjectReferenceFiles, type ReferenceFile } from '@/api/endpoints';\n\n// ReferenceFileList 组件自包含翻译\nconst referenceFileListI18n = {\n  zh: { referenceFile: { uploadedFiles: \"已上传的文件\", messages: { loadFailed: \"加载参考文件列表失败\" } } },\n  en: { referenceFile: { uploadedFiles: \"Uploaded Files\", messages: { loadFailed: \"Failed to load reference file list\" } } }\n};\n\ninterface ReferenceFileListProps {\n  // 两种模式：1. 从 API 加载（传入 projectId） 2. 直接显示（传入 files）\n  projectId?: string | null;\n  files?: ReferenceFile[]; // 如果传入 files，则直接显示，不从 API 加载\n  onFileClick?: (fileId: string) => void;\n  onFileStatusChange?: (file: ReferenceFile) => void;\n  onFileDelete?: (fileId: string) => void; // 如果传入，使用外部删除逻辑\n  deleteMode?: 'delete' | 'remove';\n  title?: string; // 自定义标题\n  className?: string; // 自定义样式\n  showToast?: (props: { message: string; type: 'success' | 'error' | 'info' | 'warning' }) => void;\n}\n\nexport const ReferenceFileList: React.FC<ReferenceFileListProps> = ({\n  projectId,\n  files: externalFiles,\n  onFileClick,\n  onFileStatusChange,\n  onFileDelete,\n  deleteMode = 'remove',\n  title,\n  className = 'mb-6',\n  showToast,\n}) => {\n  const t = useT(referenceFileListI18n);\n  const [internalFiles, setInternalFiles] = useState<ReferenceFile[]>([]);\n  const showRef = useRef(showToast);\n  \n  const displayTitle = title ?? t('referenceFile.uploadedFiles');\n\n  // 如果传入了 files，使用外部文件列表；否则从 API 加载\n  const isExternalMode = externalFiles !== undefined;\n  const files = isExternalMode ? externalFiles : internalFiles;\n\n  useEffect(() => {\n    showRef.current = showToast;\n  }, [showToast]);\n\n  // 只在非外部模式下从 API 加载\n  useEffect(() => {\n    if (isExternalMode || !projectId) {\n      if (!isExternalMode) {\n        setInternalFiles([]);\n      }\n      return;\n    }\n\n    const loadFiles = async () => {\n      try {\n        const response = await listProjectReferenceFiles(projectId);\n        if (response.data?.files) {\n          setInternalFiles(response.data.files);\n        }\n      } catch (error: any) {\n        console.error('Load file list failed:', error);\n        showRef.current?.({\n          message: error?.response?.data?.error?.message || error.message || t('referenceFile.messages.loadFailed'),\n          type: 'error',\n        });\n      }\n    };\n\n    loadFiles();\n  }, [projectId, isExternalMode]);\n\n  const handleFileStatusChange = (updatedFile: ReferenceFile) => {\n    if (!isExternalMode) {\n      setInternalFiles(prev => prev.map(f => f.id === updatedFile.id ? updatedFile : f));\n    }\n    onFileStatusChange?.(updatedFile);\n  };\n\n  const handleFileDelete = (fileId: string) => {\n    if (onFileDelete) {\n      // 使用外部删除逻辑\n      onFileDelete(fileId);\n    } else if (!isExternalMode) {\n      // 内部删除逻辑\n      setInternalFiles(prev => prev.filter(f => f.id !== fileId));\n    }\n  };\n\n  if (files.length === 0) {\n    return null;\n  }\n\n  return (\n    <div className={className}>\n      <h3 className=\"text-sm font-semibold text-gray-700 dark:text-foreground-secondary mb-3\">{displayTitle}</h3>\n      <div className=\"flex flex-wrap gap-2\">\n        {files.map(file => (\n          <ReferenceFileCard\n            key={file.id}\n            file={file}\n            onDelete={handleFileDelete}\n            onStatusChange={handleFileStatusChange}\n            deleteMode={deleteMode}\n            onClick={() => onFileClick?.(file.id)}\n            showToast={showToast}\n          />\n        ))}\n      </div>\n    </div>\n  );\n};\n\n"
  },
  {
    "path": "frontend/src/components/shared/ReferenceFileSelector.tsx",
    "content": "import React, { useState, useEffect, useRef, useCallback } from 'react';\nimport { FileText, Upload, X, Loader2, CheckCircle2, XCircle, RefreshCw, ArrowUpDown } from 'lucide-react';\nimport { useT } from '@/hooks/useT';\nimport { Button, useToast, Modal } from '@/components/shared';\n\n// ReferenceFileSelector 组件自包含翻译\nconst referenceFileSelectorI18n = {\n  zh: {\n    referenceFile: {\n      title: \"选择参考文件\", totalFiles: \"共 {{count}} 个文件\", noFiles: \"暂无文件\",\n      selectedCount: \"已选择 {{count}} 个\", allAttachments: \"所有附件\", unclassified: \"未归类附件\",\n      currentProjectAttachments: \"当前项目附件\", uploadedFiles: \"已上传的文件\",\n      refreshList: \"刷新列表\", imageLoadFailed: \"图片加载失败\",\n      parseStatus: { pending: \"等待解析\", parsing: \"解析中...\", completed: \"解析完成\", failed: \"解析失败\" },\n      reparse: \"重新解析\", removeFromProject: \"从项目中移除\", deleteFile: \"删除文件\",\n      uploading: \"上传中...\", uploadFile: \"上传文件\", clearSelection: \"清空选择\",\n      loading: \"加载中...\", noRefFiles: \"暂无参考文件\", noRefFilesHint: '点击「上传文件」按钮添加文件',\n      parseOnConfirm: \"(确定后解析)\", imageCaptionFailed: \"{{count}} 张图片未能生成描述\",\n      autoParseHint: \"选择未解析的文件将自动开始解析\",\n      cancel: \"取消\", confirm: \"确定\",\n      sortBy: \"排序\", sortNewest: \"从新到旧\", sortOldest: \"从旧到新\", sortNameAsc: \"文件名 A-Z\", sortNameDesc: \"文件名 Z-A\",\n      messages: {\n        loadFailed: \"加载参考文件列表失败\", uploadSuccess: \"成功上传 {{count}} 个文件\", uploadFailed: \"上传文件失败\",\n        cannotDelete: \"无法删除：缺少文件ID\", deleteSuccess: \"文件删除成功\", deleteFailed: \"删除文件失败\",\n        selectAtLeastOne: \"请至少选择一个文件\", selectValid: \"请选择有效的文件\",\n        maxSelection: \"最多只能选择 {{count}} 个文件\",\n        parseTriggered: \"已触发 {{count}} 个文件的解析，将在后台进行\", parseFailed: \"触发文件解析失败\"\n      }\n    },\n    shared: { pptTip: \"提示：建议将PPT转换为PDF格式上传，可获得更好的解析效果\" }\n  },\n  en: {\n    referenceFile: {\n      title: \"Select Reference Files\", totalFiles: \"{{count}} files\", noFiles: \"No files\",\n      selectedCount: \"{{count}} selected\", allAttachments: \"All Attachments\", unclassified: \"Unclassified\",\n      currentProjectAttachments: \"Current Project Attachments\", uploadedFiles: \"Uploaded Files\",\n      refreshList: \"Refresh List\", imageLoadFailed: \"Image load failed\",\n      parseStatus: { pending: \"Pending\", parsing: \"Parsing...\", completed: \"Completed\", failed: \"Failed\" },\n      reparse: \"Reparse\", removeFromProject: \"Remove from Project\", deleteFile: \"Delete File\",\n      uploading: \"Uploading...\", uploadFile: \"Upload File\", clearSelection: \"Clear Selection\",\n      loading: \"Loading...\", noRefFiles: \"No reference files\", noRefFilesHint: \"Click \\\"Upload File\\\" to add files\",\n      parseOnConfirm: \"(parse on confirm)\", imageCaptionFailed: \"{{count}} image(s) failed to generate captions\",\n      autoParseHint: \"Selecting unparsed files will automatically start parsing\",\n      cancel: \"Cancel\", confirm: \"Confirm\",\n      sortBy: \"Sort\", sortNewest: \"Newest First\", sortOldest: \"Oldest First\", sortNameAsc: \"Name A-Z\", sortNameDesc: \"Name Z-A\",\n      messages: {\n        loadFailed: \"Failed to load reference file list\", uploadSuccess: \"Successfully uploaded {{count}} file(s)\", uploadFailed: \"Failed to upload file\",\n        cannotDelete: \"Cannot delete: Missing file ID\", deleteSuccess: \"File deleted successfully\", deleteFailed: \"Failed to delete file\",\n        selectAtLeastOne: \"Please select at least one file\", selectValid: \"Please select valid files\",\n        maxSelection: \"Maximum {{count}} files can be selected\",\n        parseTriggered: \"Triggered parsing for {{count}} file(s), will process in background\", parseFailed: \"Failed to trigger file parsing\"\n      }\n    },\n    shared: { pptTip: \"Tip: Convert PPT to PDF for better parsing results\" }\n  }\n};\nimport {\n  listProjectReferenceFiles,\n  uploadReferenceFile,\n  deleteReferenceFile,\n  getReferenceFile,\n  triggerFileParse,\n  listProjects,\n  type ReferenceFile,\n} from '@/api/endpoints';\nimport type { Project } from '@/types';\n\ninterface ReferenceFileSelectorProps {\n  projectId?: string | null; // 可选，如果不提供则使用全局文件\n  isOpen: boolean;\n  onClose: () => void;\n  onSelect: (files: ReferenceFile[]) => void;\n  multiple?: boolean; // 是否支持多选\n  maxSelection?: number; // 最大选择数量\n  initialSelectedIds?: string[]; // 初始已选择的文件ID列表\n}\n\n/**\n * 参考文件选择器组件\n * - 浏览项目下的所有参考文件\n * - 支持单选/多选\n * - 支持上传本地文件\n * - 支持从文件库选择（已解析的直接用，未解析的选中后当场解析）\n * - 支持删除文件\n */\nexport const ReferenceFileSelector: React.FC<ReferenceFileSelectorProps> = React.memo(({\n  projectId: _projectId,\n  isOpen,\n  onClose,\n  onSelect,\n  multiple = true,\n  maxSelection,\n  initialSelectedIds = [],\n}) => {\n  const t = useT(referenceFileSelectorI18n);\n  const { show } = useToast();\n  const [files, setFiles] = useState<ReferenceFile[]>([]);\n  const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());\n  const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());\n  const [isLoading, setIsLoading] = useState(false);\n  const [isUploading, setIsUploading] = useState(false);\n  const [parsingIds, setParsingIds] = useState<Set<string>>(new Set());\n  const [filterProjectId, setFilterProjectId] = useState<string>('all');\n  const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'name-asc' | 'name-desc'>('newest');\n  const [projects, setProjects] = useState<Project[]>([]);\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const initialSelectedIdsRef = useRef(initialSelectedIds);\n  const showRef = useRef(show);\n\n  // 更新 ref 以保持最新的值，避免将其加入依赖数组导致无限循环\n  useEffect(() => {\n    initialSelectedIdsRef.current = initialSelectedIds;\n    showRef.current = show;\n  }, [initialSelectedIds, show]);\n\n  const loadFiles = useCallback(async () => {\n    setIsLoading(true);\n    try {\n      // 根据 filterProjectId 决定查询哪些文件\n      // 'all' - 所有文件（全局 + 项目）\n      // 'none' - 只查询未归类文件（全局文件，project_id=None）\n      // 项目ID - 只查询该项目的文件\n      const targetProjectId = filterProjectId === 'all' ? 'all' : filterProjectId === 'none' ? 'none' : filterProjectId;\n      const response = await listProjectReferenceFiles(targetProjectId);\n      \n      if (response.data?.files) {\n        // 合并新旧文件列表，避免丢失正在解析的文件\n        setFiles(prev => {\n          const fileMap = new Map<string, ReferenceFile>();\n          const serverFiles = response.data!.files; // 已经检查过 response.data?.files\n          \n          // 先添加服务器返回的文件（这些是权威数据）\n          serverFiles.forEach((f: ReferenceFile) => {\n            fileMap.set(f.id, f);\n          });\n          \n          // 然后添加正在解析的文件（可能服务器还没更新状态）\n          prev.forEach(f => {\n            if (parsingIds.has(f.id) && !fileMap.has(f.id)) {\n              fileMap.set(f.id, f);\n            }\n          });\n          \n          return Array.from(fileMap.values());\n        });\n      }\n    } catch (error: any) {\n      console.error('加载参考文件列表失败:', error);\n      showRef.current({\n        message: error?.response?.data?.error?.message || error.message || t('referenceFile.messages.loadFailed'),\n        type: 'error',\n      });\n    } finally {\n      setIsLoading(false);\n    }\n  }, [filterProjectId, parsingIds]);\n\n  // Load projects list\n  useEffect(() => {\n    if (isOpen) {\n      listProjects().then(response => {\n        if (response.data?.projects) {\n          setProjects(response.data.projects);\n        }\n      }).catch(error => {\n        console.error('Failed to load projects:', error);\n      });\n    }\n  }, [isOpen]);\n\n  useEffect(() => {\n    if (isOpen) {\n      loadFiles();\n      setSelectedFiles(new Set(initialSelectedIdsRef.current));\n    }\n  }, [isOpen, filterProjectId, loadFiles]);\n\n  // 轮询解析状态\n  useEffect(() => {\n    if (!isOpen || parsingIds.size === 0) return;\n\n    const intervalId = setInterval(async () => {\n      const idsToCheck = Array.from(parsingIds);\n      const updatedFiles: ReferenceFile[] = [];\n      const completedIds: string[] = [];\n\n      for (const fileId of idsToCheck) {\n        try {\n          const response = await getReferenceFile(fileId);\n          if (response.data?.file) {\n            const updatedFile = response.data.file;\n            updatedFiles.push(updatedFile);\n            \n            // 如果解析完成或失败，标记为完成\n            if (updatedFile.parse_status === 'completed' || updatedFile.parse_status === 'failed') {\n              completedIds.push(fileId);\n            }\n          }\n        } catch (error) {\n          console.error(`Failed to poll file ${fileId}:`, error);\n        }\n      }\n\n      // 批量更新文件列表\n      if (updatedFiles.length > 0) {\n        setFiles(prev => {\n          const fileMap = new Map(prev.map(f => [f.id, f]));\n          updatedFiles.forEach(uf => fileMap.set(uf.id, uf));\n          return Array.from(fileMap.values());\n        });\n      }\n\n      // 从轮询列表中移除已完成的文件\n      if (completedIds.length > 0) {\n        setParsingIds(prev => {\n          const newSet = new Set(prev);\n          completedIds.forEach(id => newSet.delete(id));\n          return newSet;\n        });\n      }\n    }, 2000); // 每2秒轮询一次\n\n    return () => clearInterval(intervalId);\n  }, [isOpen, parsingIds]);\n\n  const handleSelectFile = (file: ReferenceFile) => {\n    // 允许选择所有状态的文件（包括 pending 和 parsing）\n    // pending 的文件会在确定时触发解析\n    // parsing 的文件会等待解析完成\n\n    if (multiple) {\n      const newSelected = new Set(selectedFiles);\n      if (newSelected.has(file.id)) {\n        newSelected.delete(file.id);\n      } else {\n        if (maxSelection && newSelected.size >= maxSelection) {\n          show({\n            message: t('referenceFile.messages.maxSelection', { count: maxSelection }),\n            type: 'info',\n          });\n          return;\n        }\n        newSelected.add(file.id);\n      }\n      setSelectedFiles(newSelected);\n    } else {\n      setSelectedFiles(new Set([file.id]));\n    }\n  };\n\n  const handleConfirm = async () => {\n    const selected = files.filter((f) => selectedFiles.has(f.id));\n    \n    if (selected.length === 0) {\n      show({ message: t('referenceFile.messages.selectAtLeastOne'), type: 'info' });\n      return;\n    }\n    \n    // 检查是否有未解析的文件需要触发解析\n    const unparsedFiles = selected.filter(f => f.parse_status === 'pending' || f.parse_status === 'failed');\n    \n    if (unparsedFiles.length > 0) {\n      // 触发解析未解析的文件，但立即返回（不等待）\n      try {\n        show({\n          message: t('referenceFile.messages.parseTriggered', { count: unparsedFiles.length }),\n          type: 'success',\n        });\n\n        // 触发所有未解析文件的解析（不等待完成）\n        unparsedFiles.forEach(file => {\n          triggerFileParse(file.id).catch(error => {\n            console.error(`触发文件 ${file.filename} 解析失败:`, error);\n          });\n        });\n        \n        // 立即返回所有选中的文件（包括 pending 状态的）\n        onSelect(selected);\n        onClose();\n      } catch (error: any) {\n        console.error('触发文件解析失败:', error);\n        show({\n        message: error?.response?.data?.error?.message || error.message || t('referenceFile.messages.parseFailed'),\n          type: 'error',\n        });\n      }\n    } else {\n      // 所有文件都已解析或正在解析，直接确认\n      // 允许选择所有状态的文件（completed, parsing）\n      const validFiles = selected.filter(f => \n        f.parse_status === 'completed' || f.parse_status === 'parsing'\n      );\n      \n      if (validFiles.length === 0) {\n        show({ message: t('referenceFile.messages.selectValid'), type: 'info' });\n        return;\n      }\n      \n      onSelect(validFiles);\n      onClose();\n    }\n  };\n\n  const handleClear = () => {\n    setSelectedFiles(new Set());\n  };\n\n  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\n    const files = e.target.files;\n    if (!files || files.length === 0) return;\n\n    // 检查是否有PPT文件，提示建议使用PDF\n    const hasPptFiles = Array.from(files).some(file => {\n      const fileExt = file.name.split('.').pop()?.toLowerCase();\n      return fileExt === 'ppt' || fileExt === 'pptx';\n    });\n    \n    if (hasPptFiles) show({ message: `💡 ${t('shared.pptTip')}`, type: 'info' });\n    \n\n    setIsUploading(true);\n    try {\n      // 根据当前筛选条件决定上传文件的归属\n      // 如果筛选为 'all' 或 'none'，上传为全局文件（不关联项目）\n      // 如果筛选为项目ID，上传到该项目\n      const targetProjectId = (filterProjectId === 'all' || filterProjectId === 'none')\n        ? null\n        : filterProjectId;\n      \n      // 上传所有选中的文件\n      const uploadPromises = Array.from(files).map(file =>\n        uploadReferenceFile(file, targetProjectId)\n      );\n\n      const results = await Promise.all(uploadPromises);\n      const uploadedFiles = results\n        .map(r => r.data?.file)\n        .filter((f): f is ReferenceFile => f !== undefined);\n\n      if (uploadedFiles.length > 0) {\n        show({ message: t('referenceFile.messages.uploadSuccess', { count: uploadedFiles.length }), type: 'success' });\n        \n        // 只有正在解析的文件才添加到轮询列表（pending 状态的文件不轮询）\n        const needsParsing = uploadedFiles.filter(f => \n          f.parse_status === 'parsing'\n        );\n        if (needsParsing.length > 0) {\n          setParsingIds(prev => {\n            const newSet = new Set(prev);\n            needsParsing.forEach(f => newSet.add(f.id));\n            return newSet;\n          });\n        }\n        \n        // 合并新上传的文件到现有列表，而不是完全替换\n        setFiles(prev => {\n          const fileMap = new Map(prev.map(f => [f.id, f]));\n          uploadedFiles.forEach(uf => fileMap.set(uf.id, uf));\n          return Array.from(fileMap.values());\n        });\n        \n        // 延迟重新加载文件列表，确保服务器端数据已更新\n        setTimeout(() => {\n          loadFiles();\n        }, 500);\n      }\n    } catch (error: any) {\n      console.error('上传文件失败:', error);\n      show({\n        message: error?.response?.data?.error?.message || error.message || t('referenceFile.messages.uploadFailed'),\n        type: 'error',\n      });\n    } finally {\n      setIsUploading(false);\n      // 清空 input 值，以便可以重复选择同一文件\n      if (fileInputRef.current) {\n        fileInputRef.current.value = '';\n      }\n    }\n  };\n\n  const handleDeleteFile = async (\n    e: React.MouseEvent<HTMLButtonElement, MouseEvent>,\n    file: ReferenceFile\n  ) => {\n    e.stopPropagation();\n    const fileId = file.id;\n\n    if (!fileId) {\n      show({ message: t('referenceFile.messages.cannotDelete'), type: 'error' });\n      return;\n    }\n\n    setDeletingIds((prev) => {\n      const newSet = new Set(prev);\n      newSet.add(fileId);\n      return newSet;\n    });\n\n    try {\n      await deleteReferenceFile(fileId);\n      show({ message: t('referenceFile.messages.deleteSuccess'), type: 'success' });\n      \n      // 从选择中移除\n      setSelectedFiles((prev) => {\n        const newSet = new Set(prev);\n        newSet.delete(fileId);\n        return newSet;\n      });\n      \n      // 从轮询列表中移除\n      setParsingIds((prev) => {\n        const newSet = new Set(prev);\n        newSet.delete(fileId);\n        return newSet;\n      });\n      \n      loadFiles(); // 重新加载文件列表\n    } catch (error: any) {\n      console.error('删除文件失败:', error);\n      show({\n        message: error?.response?.data?.error?.message || error.message || t('referenceFile.messages.deleteFailed'),\n        type: 'error',\n      });\n    } finally {\n      setDeletingIds((prev) => {\n        const newSet = new Set(prev);\n        newSet.delete(fileId);\n        return newSet;\n      });\n    }\n  };\n\n  const formatFileSize = (bytes: number): string => {\n    if (bytes < 1024) return `${bytes} B`;\n    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n  };\n\n  const getStatusIcon = (file: ReferenceFile) => {\n    if (parsingIds.has(file.id) || file.parse_status === 'parsing') {\n      return <Loader2 className=\"w-4 h-4 text-blue-500 animate-spin\" />;\n    }\n    switch (file.parse_status) {\n      case 'completed':\n        return <CheckCircle2 className=\"w-4 h-4 text-green-500\" />;\n      case 'failed':\n        return <XCircle className=\"w-4 h-4 text-red-500\" />;\n      default:\n        return null;\n    }\n  };\n\n  const getStatusText = (file: ReferenceFile) => {\n    if (parsingIds.has(file.id) || file.parse_status === 'parsing') {\n      return t('referenceFile.parseStatus.parsing');\n    }\n    switch (file.parse_status) {\n      case 'pending':\n        return t('referenceFile.parseStatus.pending');\n      case 'completed':\n        return t('referenceFile.parseStatus.completed');\n      case 'failed':\n        return t('referenceFile.parseStatus.failed');\n      default:\n        return '';\n    }\n  };\n\n  const sortedFiles = [...files].sort((a, b) => {\n    switch (sortBy) {\n      case 'newest':\n        return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();\n      case 'oldest':\n        return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();\n      case 'name-asc':\n        return a.filename.localeCompare(b.filename);\n      case 'name-desc':\n        return b.filename.localeCompare(a.filename);\n      default:\n        return 0;\n    }\n  });\n\n  return (\n    <Modal isOpen={isOpen} onClose={onClose} title={t('referenceFile.title')} size=\"lg\">\n      <div className=\"space-y-4\">\n        {/* 工具栏 */}\n        <div className=\"flex items-center justify-between flex-wrap gap-2\">\n          <div className=\"flex items-center gap-2 text-sm text-gray-600 dark:text-foreground-tertiary\">\n            <span>{files.length > 0 ? t('referenceFile.totalFiles', { count: files.length }) : t('referenceFile.noFiles')}</span>\n            {selectedFiles.size > 0 && (\n              <span className=\"ml-2 text-banana-600\">\n                {t('referenceFile.selectedCount', { count: selectedFiles.size })}\n              </span>\n            )}\n            {isLoading && files.length > 0 && (\n              <RefreshCw size={14} className=\"animate-spin text-gray-400\" />\n            )}\n          </div>\n          <div className=\"flex items-center gap-2 flex-wrap\">\n            {/* 项目筛选下拉框 */}\n            <select\n              value={filterProjectId}\n              onChange={(e) => setFilterProjectId(e.target.value)}\n              className=\"px-3 py-1.5 text-sm text-gray-700 dark:text-foreground-secondary bg-transparent hover:bg-gray-100 dark:hover:bg-background-hover rounded-md focus:outline-none transition-colors cursor-pointer\"\n            >\n              <option value=\"all\">{t('referenceFile.allAttachments')}</option>\n              <option value=\"none\">{t('referenceFile.unclassified')}</option>\n              {projects.map(project => (\n                <option key={project.project_id} value={project.project_id}>{project.idea_prompt}</option>\n              ))}\n            </select>\n\n            {/* 排序循环按钮 */}\n            <button\n              onClick={() => {\n                const order: Array<typeof sortBy> = ['newest', 'oldest', 'name-asc', 'name-desc'];\n                const currentIndex = order.indexOf(sortBy);\n                const nextIndex = (currentIndex + 1) % order.length;\n                setSortBy(order[nextIndex]);\n              }}\n              className=\"flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-700 dark:text-foreground-secondary hover:bg-gray-100 dark:hover:bg-background-hover rounded-md transition-colors\"\n            >\n              <ArrowUpDown size={14} />\n              <span>\n                {sortBy === 'newest' && t('referenceFile.sortNewest')}\n                {sortBy === 'oldest' && t('referenceFile.sortOldest')}\n                {sortBy === 'name-asc' && 'A-Z'}\n                {sortBy === 'name-desc' && 'Z-A'}\n              </span>\n            </button>\n\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              icon={<RefreshCw size={16} />}\n              onClick={loadFiles}\n              disabled={isLoading}\n            >\n              {t('referenceFile.refreshList')}\n            </Button>\n            \n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              icon={<Upload size={16} />}\n              onClick={() => fileInputRef.current?.click()}\n              disabled={isUploading}\n            >\n              {isUploading ? t('referenceFile.uploading') : t('referenceFile.uploadFile')}\n            </Button>\n            \n            {selectedFiles.size > 0 && (\n              <Button variant=\"ghost\" size=\"sm\" onClick={handleClear}>\n                {t('referenceFile.clearSelection')}\n              </Button>\n            )}\n          </div>\n        </div>\n\n        {/* 隐藏的文件输入 */}\n        <input\n          ref={fileInputRef}\n          type=\"file\"\n          multiple\n          accept=\".pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.csv,.txt,.md\"\n          onChange={handleUpload}\n          className=\"hidden\"\n        />\n\n        {/* 文件列表 */}\n        <div className=\"border border-gray-200 dark:border-border-primary rounded-lg max-h-96 overflow-y-auto\">\n          {isLoading ? (\n            <div className=\"flex items-center justify-center py-12\">\n              <Loader2 className=\"w-6 h-6 text-gray-400 animate-spin\" />\n              <span className=\"ml-2 text-gray-500 dark:text-foreground-tertiary\">{t('referenceFile.loading')}</span>\n            </div>\n          ) : files.length === 0 ? (\n            <div className=\"flex flex-col items-center justify-center py-12 text-gray-400\">\n              <FileText className=\"w-12 h-12 mb-2\" />\n              <p>{t('referenceFile.noRefFiles')}</p>\n              <p className=\"text-sm mt-1\">{t('referenceFile.noRefFilesHint')}</p>\n            </div>\n          ) : (\n            <div className=\"divide-y divide-gray-200 dark:divide-border-primary\">\n              {sortedFiles.map((file) => {\n                const isSelected = selectedFiles.has(file.id);\n                const isDeleting = deletingIds.has(file.id);\n                const isPending = file.parse_status === 'pending';\n\n                return (\n                  <div\n                    key={file.id}\n                    onClick={() => handleSelectFile(file)}\n                    className={`\n                      p-4 cursor-pointer transition-colors\n                      ${isSelected ? 'bg-banana-50 dark:bg-background-secondary border-l-4 border-l-banana-500' : 'hover:bg-gray-50 dark:hover:bg-background-hover'}\n                      ${file.parse_status === 'failed' ? 'opacity-60' : ''}\n                    `}\n                  >\n                    <div className=\"flex items-start gap-3\">\n                      {/* 选择框 */}\n                      <div className=\"flex-shrink-0 mt-1\">\n                        <div\n                          className={`\n                            w-5 h-5 rounded border-2 flex items-center justify-center\n                            ${isSelected\n                              ? 'bg-banana-500 border-banana-500'\n                              : 'border-gray-300 dark:border-border-primary'\n                            }\n                            ${file.parse_status === 'failed' ? 'opacity-50' : ''}\n                          `}\n                        >\n                          {isSelected && (\n                            <CheckCircle2 className=\"w-4 h-4 text-white\" />\n                          )}\n                        </div>\n                      </div>\n\n                      {/* 文件图标 */}\n                      <div className=\"flex-shrink-0\">\n                        <div className=\"w-10 h-10 bg-blue-50 dark:bg-blue-900/30 rounded-lg flex items-center justify-center\">\n                          <FileText className=\"w-5 h-5 text-blue-600\" />\n                        </div>\n                      </div>\n\n                      {/* 文件信息 */}\n                      <div className=\"flex-1 min-w-0\">\n                        <div className=\"flex items-center gap-2\">\n                          <p className=\"text-sm font-medium text-gray-900 dark:text-foreground-primary truncate\">\n                            {file.filename}\n                          </p>\n                          <span className=\"text-xs text-gray-500 dark:text-foreground-tertiary flex-shrink-0\">\n                            {formatFileSize(file.file_size)}\n                          </span>\n                        </div>\n\n                        {/* 状态 */}\n                        <div className=\"flex items-center gap-1.5 mt-1\">\n                          {getStatusIcon(file)}\n                          <p className=\"text-xs text-gray-600 dark:text-foreground-tertiary\">\n                            {getStatusText(file)}\n                            {isPending && (\n                              <span className=\"ml-1 text-orange-500\">{t('referenceFile.parseOnConfirm')}</span>\n                            )}\n                          </p>\n                        </div>\n\n                        {/* 失败信息 */}\n                        {file.parse_status === 'failed' && file.error_message && (\n                          <p className=\"text-xs text-red-500 mt-1 line-clamp-1\">\n                            {file.error_message}\n                          </p>\n                        )}\n\n                        {/* 图片识别失败警告 */}\n                        {file.parse_status === 'completed' && \n                         typeof file.image_caption_failed_count === 'number' && \n                         file.image_caption_failed_count > 0 && (\n                          <p className=\"text-xs text-orange-500 mt-1\">\n                            ⚠️ {t('referenceFile.imageCaptionFailed', { count: file.image_caption_failed_count })}\n                          </p>\n                        )}\n                      </div>\n\n                      {/* 删除按钮 */}\n                      <button\n                        onClick={(e) => handleDeleteFile(e, file)}\n                        disabled={isDeleting}\n                        className=\"flex-shrink-0 p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors disabled:opacity-50\"\n                        title={t('referenceFile.deleteFile')}\n                      >\n                        {isDeleting ? (\n                          <Loader2 className=\"w-4 h-4 animate-spin\" />\n                        ) : (\n                          <X className=\"w-4 h-4\" />\n                        )}\n                      </button>\n                    </div>\n                  </div>\n                );\n              })}\n            </div>\n          )}\n        </div>\n\n        {/* 底部操作栏 */}\n        <div className=\"flex items-center justify-between pt-4 border-t border-gray-200 dark:border-border-primary\">\n          <p className=\"text-xs text-gray-500 dark:text-foreground-tertiary\">\n            💡 {t('referenceFile.autoParseHint')}\n          </p>\n          <div className=\"flex items-center gap-2\">\n            <Button variant=\"ghost\" onClick={onClose}>\n              {t('referenceFile.cancel')}\n            </Button>\n            <Button\n              onClick={handleConfirm}\n              disabled={selectedFiles.size === 0}\n            >\n              {t('referenceFile.confirm')} ({selectedFiles.size})\n            </Button>\n          </div>\n        </div>\n      </div>\n    </Modal>\n  );\n});\n\n"
  },
  {
    "path": "frontend/src/components/shared/ShimmerOverlay.tsx",
    "content": "import React from 'react';\n\ninterface ShimmerOverlayProps {\n  /** 是否显示渐变效果 */\n  show: boolean;\n  /** 透明度，默认 0.4 */\n  opacity?: number;\n  /** 圆角类型，默认 'card' */\n  rounded?: 'card' | 'lg' | 'md' | 'sm' | 'none';\n}\n\n/**\n * 通用的渐变滚动覆盖层组件\n * 用于在卡片上显示\"生成中\"或\"处理中\"的视觉反馈\n * 复用了 Skeleton 组件的渐变效果样式\n */\nexport const ShimmerOverlay: React.FC<ShimmerOverlayProps> = ({\n  show,\n  opacity = 0.4,\n  rounded = 'card',\n}) => {\n  if (!show) return null;\n\n  const roundedClass = {\n    card: 'rounded-card',\n    lg: 'rounded-lg',\n    md: 'rounded-md',\n    sm: 'rounded-sm',\n    none: '',\n  }[rounded];\n\n  return (\n    <div className={`absolute inset-0 ${roundedClass} overflow-hidden pointer-events-none z-10`}>\n      <div \n        className=\"absolute inset-0 bg-gradient-to-r from-gray-200 via-banana-50 to-gray-200 animate-shimmer\" \n        style={{ \n          backgroundSize: '200% 100%',\n          opacity \n        }}\n      />\n    </div>\n  );\n};\n\n"
  },
  {
    "path": "frontend/src/components/shared/StatusBadge.tsx",
    "content": "import React from 'react';\nimport { cn } from '@/utils';\nimport { useT } from '@/hooks/useT';\nimport type { PageStatus } from '@/types';\n\n// Status 组件自包含翻译\nconst statusI18n = {\n  zh: {\n    status: {\n      draft: \"草稿\", generatingDescription: \"描述生成中\", descriptionGenerated: \"已生成描述\", queued: \"排队中\", generating: \"生成中\",\n      completed: \"已完成\", failed: \"失败\", unknown: \"未知\",\n      notGeneratedDesc: \"未生成描述\", noDescription: \"还没有生成描述\",\n      descGenerated: \"描述已生成\", notGeneratedImage: \"未生成图片\",\n      waitingForImage: \"描述已生成，等待生成图片\", generatingImage: \"正在生成图片\",\n      imageFailed: \"图片生成失败\", imageCompleted: \"图片已生成\",\n      draftStage: \"草稿阶段\", allCompleted: \"全部完成\", statusUnknown: \"状态未知\"\n    }\n  },\n  en: {\n    status: {\n      draft: \"Draft\", generatingDescription: \"Generating Description\", descriptionGenerated: \"Description Generated\", queued: \"Queued\", generating: \"Generating\",\n      completed: \"Completed\", failed: \"Failed\", unknown: \"Unknown\",\n      notGeneratedDesc: \"Description Not Generated\", noDescription: \"No description generated yet\",\n      descGenerated: \"Description Generated\", notGeneratedImage: \"Image Not Generated\",\n      waitingForImage: \"Description generated, waiting for image\", generatingImage: \"Generating image\",\n      imageFailed: \"Image generation failed\", imageCompleted: \"Image generated\",\n      draftStage: \"Draft Stage\", allCompleted: \"All Completed\", statusUnknown: \"Status Unknown\"\n    }\n  }\n};\n\ninterface StatusBadgeProps {\n  status: PageStatus;\n}\n\nconst statusClassNames: Record<PageStatus, string> = {\n  DRAFT: 'bg-gray-100 dark:bg-background-secondary text-gray-600 dark:text-foreground-tertiary',\n  GENERATING_DESCRIPTION: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 animate-pulse',\n  DESCRIPTION_GENERATED: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',\n  QUEUED: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 animate-pulse',\n  GENERATING: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 animate-pulse',\n  COMPLETED: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',\n  FAILED: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',\n};\n\nconst statusLabelKeys: Record<PageStatus, string> = {\n  DRAFT: 'status.draft',\n  GENERATING_DESCRIPTION: 'status.generatingDescription',\n  DESCRIPTION_GENERATED: 'status.descriptionGenerated',\n  QUEUED: 'status.queued',\n  GENERATING: 'status.generating',\n  COMPLETED: 'status.completed',\n  FAILED: 'status.failed',\n};\n\nexport const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {\n  const t = useT(statusI18n);\n  const className = statusClassNames[status];\n  const labelKey = statusLabelKeys[status];\n  \n  return (\n    <span\n      data-testid=\"status-badge\"\n      data-status={status}\n      className={cn(\n        'inline-flex items-center px-2.5 py-0.5 rounded text-xs font-medium',\n        className\n      )}\n    >\n      {t(labelKey)}\n    </span>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/shared/TemplateSelector.tsx",
    "content": "import React, { useState, useEffect } from 'react';\nimport { Button, useToast, MaterialSelector } from '@/components/shared';\nimport { useT } from '@/hooks/useT';\nimport { getImageUrl } from '@/api/client';\n\n// Template 组件自包含翻译\nconst templateI18n = {\n  zh: {\n    template: {\n      myTemplates: \"我的模板\", presetTemplates: \"预设模板\", uploadTemplate: \"上传模板\",\n      deleteTemplate: \"删除模板\", templateSelected: \"已选择\",\n      saveToLibraryOnUpload: \"上传模板时同时保存到我的模板库\",\n      selectFromMaterials: \"从素材库选择\", selectAsTemplate: \"从素材库选择作为模板\",\n      cannotDeleteInUse: \"当前使用中的模板不能删除，请先取消选择或切换\",\n      presets: {\n        retroScroll: \"复古卷轴\", vectorIllustration: \"矢量插画\", glassEffect: \"拟物玻璃\",\n        techBlue: \"科技蓝\", simpleBusiness: \"简约商务\", academicReport: \"学术报告\"\n      },\n      messages: { uploadSuccess: \"模板上传成功\", uploadFailed: \"模板上传失败\", deleteSuccess: \"模板已删除\", deleteFailed: \"删除模板失败\" }\n    },\n    material: { messages: { savedToLibrary: \"素材已保存到模板库\", selectedAsTemplate: \"已从素材库选择作为模板\", loadMaterialFailed: \"加载素材失败\" } }\n  },\n  en: {\n    template: {\n      myTemplates: \"My Templates\", presetTemplates: \"Preset Templates\", uploadTemplate: \"Upload Template\",\n      deleteTemplate: \"Delete Template\", templateSelected: \"Selected\",\n      saveToLibraryOnUpload: \"Save to my template library when uploading\",\n      selectFromMaterials: \"Select from Materials\", selectAsTemplate: \"Select from materials as template\",\n      cannotDeleteInUse: \"Cannot delete template in use, please deselect or switch first\",\n      presets: {\n        retroScroll: \"Retro Scroll\", vectorIllustration: \"Vector Illustration\", glassEffect: \"Glass Effect\",\n        techBlue: \"Tech Blue\", simpleBusiness: \"Simple Business\", academicReport: \"Academic Report\"\n      },\n      messages: { uploadSuccess: \"Template uploaded successfully\", uploadFailed: \"Failed to upload template\", deleteSuccess: \"Template deleted\", deleteFailed: \"Failed to delete template\" }\n    },\n    material: { messages: { savedToLibrary: \"Material saved to template library\", selectedAsTemplate: \"Selected from library as template\", loadMaterialFailed: \"Failed to load materials\" } }\n  }\n};\nimport { listUserTemplates, uploadUserTemplate, deleteUserTemplate, type UserTemplate } from '@/api/endpoints';\nimport { materialUrlToFile } from '@/components/shared/MaterialSelector';\nimport type { Material } from '@/api/endpoints';\nimport { ImagePlus, X } from 'lucide-react';\n\ninterface TemplateSelectorProps {\n  onSelect: (templateFile: File | null, templateId?: string) => void;\n  selectedTemplateId?: string | null;\n  selectedPresetTemplateId?: string | null;\n  showUpload?: boolean;\n  projectId?: string | null;\n}\n\nexport const TemplateSelector: React.FC<TemplateSelectorProps> = ({\n  onSelect,\n  selectedTemplateId,\n  selectedPresetTemplateId,\n  showUpload = true,\n  projectId,\n}) => {\n  const t = useT(templateI18n);\n  const [userTemplates, setUserTemplates] = useState<UserTemplate[]>([]);\n  const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);\n  const [isMaterialSelectorOpen, setIsMaterialSelectorOpen] = useState(false);\n  const [deletingTemplateId, setDeletingTemplateId] = useState<string | null>(null);\n  const [saveToLibrary, setSaveToLibrary] = useState(true);\n  const { show, ToastContainer } = useToast();\n\n  const presetTemplates = [\n    { id: '1', nameKey: 'template.presets.retroScroll', preview: '/templates/template_y.png', thumb: '/templates/template_y-thumb.webp' },\n    { id: '2', nameKey: 'template.presets.vectorIllustration', preview: '/templates/template_vector_illustration.png', thumb: '/templates/template_vector_illustration-thumb.webp' },\n    { id: '3', nameKey: 'template.presets.glassEffect', preview: '/templates/template_glass.png', thumb: '/templates/template_glass-thumb.webp' },\n  ];\n\n  useEffect(() => {\n    loadUserTemplates();\n  }, []);\n\n  const loadUserTemplates = async () => {\n    setIsLoadingTemplates(true);\n    try {\n      const response = await listUserTemplates();\n      if (response.data?.templates) {\n        setUserTemplates(response.data.templates);\n      }\n    } catch (error: any) {\n      console.error('Failed to load user templates:', error);\n    } finally {\n      setIsLoadingTemplates(false);\n    }\n  };\n\n  const handleTemplateUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    if (file) {\n      try {\n        if (showUpload) {\n          const response = await uploadUserTemplate(file);\n          if (response.data) {\n            const template = response.data;\n            setUserTemplates(prev => [template, ...prev]);\n            onSelect(null, template.template_id);\n            show({ message: t('template.messages.uploadSuccess'), type: 'success' });\n          }\n        } else {\n          if (saveToLibrary) {\n            const response = await uploadUserTemplate(file);\n            if (response.data) {\n              const template = response.data;\n              setUserTemplates(prev => [template, ...prev]);\n              onSelect(file, template.template_id);\n              show({ message: t('material.messages.savedToLibrary'), type: 'success' });\n            }\n          } else {\n            onSelect(file);\n          }\n        }\n      } catch (error: any) {\n        console.error('Failed to upload template:', error);\n        show({ message: t('template.messages.uploadFailed') + ': ' + (error.message || t('common.unknownError')), type: 'error' });\n      }\n    }\n    e.target.value = '';\n  };\n\n  const handleSelectUserTemplate = (template: UserTemplate) => {\n    onSelect(null, template.template_id);\n  };\n\n  const handleSelectPresetTemplate = (templateId: string, preview: string) => {\n    if (!preview) return;\n    onSelect(null, templateId);\n  };\n\n  const handleSelectMaterials = async (materials: Material[], saveAsTemplate?: boolean) => {\n    if (materials.length === 0) return;\n    \n    try {\n      const file = await materialUrlToFile(materials[0]);\n      \n      if (saveAsTemplate) {\n        const response = await uploadUserTemplate(file);\n        if (response.data) {\n          const template = response.data;\n          setUserTemplates(prev => [template, ...prev]);\n          onSelect(file, template.template_id);\n          show({ message: t('material.messages.savedToLibrary'), type: 'success' });\n        }\n      } else {\n        onSelect(file);\n        show({ message: t('material.messages.selectedAsTemplate'), type: 'success' });\n      }\n    } catch (error: any) {\n      console.error('Failed to load material:', error);\n      show({ message: t('material.messages.loadMaterialFailed') + ': ' + (error.message || t('common.unknownError')), type: 'error' });\n    }\n  };\n\n  const handleDeleteUserTemplate = async (template: UserTemplate, e: React.MouseEvent) => {\n    e.stopPropagation();\n    if (selectedTemplateId === template.template_id) {\n      show({ message: t('template.cannotDeleteInUse'), type: 'info' });\n      return;\n    }\n    setDeletingTemplateId(template.template_id);\n    try {\n      await deleteUserTemplate(template.template_id);\n      setUserTemplates((prev) => prev.filter((t) => t.template_id !== template.template_id));\n      show({ message: t('template.messages.deleteSuccess'), type: 'success' });\n    } catch (error: any) {\n      console.error('Failed to delete template:', error);\n      show({ message: t('template.messages.deleteFailed') + ': ' + (error.message || t('common.unknownError')), type: 'error' });\n    } finally {\n      setDeletingTemplateId(null);\n    }\n  };\n\n  return (\n    <>\n      <div className=\"space-y-4\">\n        {userTemplates.length > 0 && (\n          <div>\n            <h4 className=\"text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-2\">{t('template.myTemplates')}</h4>\n            <div className=\"grid grid-cols-4 gap-4 mb-4\">\n              {userTemplates.map((template) => (\n                <div\n                  key={template.template_id}\n                  onClick={() => handleSelectUserTemplate(template)}\n                  className={`aspect-[4/3] rounded-lg border-2 cursor-pointer transition-all relative group ${\n                    selectedTemplateId === template.template_id\n                      ? 'border-banana-500 ring-2 ring-banana-200'\n                      : 'border-gray-200 dark:border-border-primary hover:border-banana-300'\n                  }`}\n                >\n                  <img\n                    src={getImageUrl(template.thumb_url || template.template_image_url)}\n                    alt={template.name || 'Template'}\n                    className=\"absolute inset-0 w-full h-full object-cover\"\n                  />\n                  {selectedTemplateId !== template.template_id && (\n                    <button\n                      type=\"button\"\n                      onClick={(e) => handleDeleteUserTemplate(template, e)}\n                      disabled={deletingTemplateId === template.template_id}\n                      className={`absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center shadow z-20 opacity-0 group-hover:opacity-100 transition-opacity ${\n                        deletingTemplateId === template.template_id ? 'opacity-60 cursor-not-allowed' : ''\n                      }`}\n                      aria-label={t('template.deleteTemplate')}\n                    >\n                      <X size={12} />\n                    </button>\n                  )}\n                  {selectedTemplateId === template.template_id && (\n                    <div className=\"absolute inset-0 bg-banana-500 bg-opacity-20 flex items-center justify-center pointer-events-none\">\n                      <span className=\"text-white font-semibold text-sm\">{t('template.templateSelected')}</span>\n                    </div>\n                  )}\n                </div>\n              ))}\n            </div>\n          </div>\n        )}\n\n        <div>\n          <h4 className=\"text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-2\">{t('template.presetTemplates')}</h4>\n          <div className=\"grid grid-cols-4 gap-4\">\n            {presetTemplates.map((template) => (\n              <div\n                key={template.id}\n                onClick={() => template.preview && handleSelectPresetTemplate(template.id, template.preview)}\n                className={`aspect-[4/3] rounded-lg border-2 cursor-pointer transition-all bg-gray-100 dark:bg-background-secondary flex items-center justify-center relative ${\n                  selectedPresetTemplateId === template.id\n                    ? 'border-banana-500 ring-2 ring-banana-200'\n                    : 'border-gray-200 dark:border-border-primary hover:border-banana-500'\n                }`}\n              >\n                {template.preview ? (\n                  <>\n                    <img\n                      src={template.thumb || template.preview}\n                      alt={t(template.nameKey)}\n                      className=\"absolute inset-0 w-full h-full object-cover\"\n                    />\n                    {selectedPresetTemplateId === template.id && (\n                      <div className=\"absolute inset-0 bg-banana-500 bg-opacity-20 flex items-center justify-center pointer-events-none\">\n                        <span className=\"text-white font-semibold text-sm\">{t('template.templateSelected')}</span>\n                      </div>\n                    )}\n                  </>\n                ) : (\n                  <span className=\"text-sm text-gray-500 dark:text-foreground-tertiary\">{t(template.nameKey)}</span>\n                )}\n              </div>\n            ))}\n\n            <label className=\"aspect-[4/3] rounded-lg border-2 border-dashed border-gray-300 dark:border-border-primary hover:border-banana-500 cursor-pointer transition-all flex flex-col items-center justify-center gap-2 relative overflow-hidden\">\n              <span className=\"text-2xl\">+</span>\n              <span className=\"text-sm text-gray-500 dark:text-foreground-tertiary\">{t('template.uploadTemplate')}</span>\n              <input\n                type=\"file\"\n                accept=\"image/*\"\n                onChange={handleTemplateUpload}\n                className=\"hidden\"\n                disabled={isLoadingTemplates}\n              />\n            </label>\n          </div>\n          \n          {!showUpload && (\n            <div className=\"mt-3 p-3 bg-blue-50 dark:bg-blue-900/30 rounded-lg border border-blue-200 dark:border-blue-700\">\n              <label className=\"flex items-center gap-2 cursor-pointer\">\n                <input\n                  type=\"checkbox\"\n                  checked={saveToLibrary}\n                  onChange={(e) => setSaveToLibrary(e.target.checked)}\n                  className=\"w-4 h-4 text-banana-500 border-gray-300 dark:border-border-primary rounded focus:ring-banana-500\"\n                />\n                <span className=\"text-sm text-gray-700 dark:text-foreground-secondary\">\n                  {t('template.saveToLibraryOnUpload')}\n                </span>\n              </label>\n            </div>\n          )}\n        </div>\n\n        {projectId && (\n          <div className=\"mt-4\">\n            <h4 className=\"text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-2\">{t('template.selectFromMaterials')}</h4>\n            <Button\n              variant=\"secondary\"\n              size=\"sm\"\n              icon={<ImagePlus size={16} />}\n              onClick={() => setIsMaterialSelectorOpen(true)}\n              className=\"w-full\"\n            >\n              {t('template.selectAsTemplate')}\n            </Button>\n          </div>\n        )}\n      </div>\n      <ToastContainer />\n      {projectId && (\n        <MaterialSelector\n          projectId={projectId}\n          isOpen={isMaterialSelectorOpen}\n          onClose={() => setIsMaterialSelectorOpen(false)}\n          onSelect={handleSelectMaterials}\n          multiple={false}\n          showSaveAsTemplateOption={true}\n        />\n      )}\n    </>\n  );\n};\n\nexport const getTemplateFile = async (\n  templateId: string,\n  userTemplates: UserTemplate[]\n): Promise<File | null> => {\n  const presetTemplates = [\n    { id: '1', preview: '/templates/template_y.png' },\n    { id: '2', preview: '/templates/template_vector_illustration.png' },\n    { id: '3', preview: '/templates/template_glass.png' },\n  ];\n\n  const presetTemplate = presetTemplates.find(t => t.id === templateId);\n  if (presetTemplate && presetTemplate.preview) {\n    try {\n      const response = await fetch(presetTemplate.preview);\n      const blob = await response.blob();\n      return new File([blob], presetTemplate.preview.split('/').pop() || 'template.png', { type: blob.type });\n    } catch (error) {\n      console.error('Failed to load preset template:', error);\n      return null;\n    }\n  }\n\n  const userTemplate = userTemplates.find(t => t.template_id === templateId);\n  if (userTemplate) {\n    try {\n      const imageUrl = getImageUrl(userTemplate.template_image_url);\n      const response = await fetch(imageUrl);\n      const blob = await response.blob();\n      return new File([blob], 'template.png', { type: blob.type });\n    } catch (error) {\n      console.error('Failed to load user template:', error);\n      return null;\n    }\n  }\n\n  return null;\n};\n"
  },
  {
    "path": "frontend/src/components/shared/TextStyleSelector.tsx",
    "content": "import React, { useState, useRef } from 'react';\nimport { ImagePlus, Loader2 } from 'lucide-react';\nimport { useT } from '@/hooks/useT';\nimport { Textarea } from './Textarea';\nimport { PRESET_STYLES } from '@/config/presetStyles';\nimport { presetStylesI18n } from '@/config/presetStylesI18n';\nimport { extractStyleFromImage } from '@/api/endpoints';\n\nconst i18n = {\n  zh: {\n    presetStyles: presetStylesI18n.zh,\n    stylePlaceholder: '描述您想要的 PPT 风格，例如：简约商务风格，使用蓝色和白色配色，字体清晰大方...',\n    presetStylesLabel: '快速选择预设风格：',\n    styleTip: '提示：点击预设风格快速填充，或自定义描述风格、配色、布局等要求',\n    extractFromImage: '从图片提取风格',\n    extracting: '提取中...',\n    extractSuccess: '风格提取成功',\n    extractFailed: '风格提取失败',\n  },\n  en: {\n    presetStyles: presetStylesI18n.en,\n    stylePlaceholder: 'Describe your desired PPT style, e.g., minimalist business style...',\n    presetStylesLabel: 'Quick select preset styles:',\n    styleTip: 'Tip: Click preset styles to quick fill, or customize',\n    extractFromImage: 'Extract from image',\n    extracting: 'Extracting...',\n    extractSuccess: 'Style extracted successfully',\n    extractFailed: 'Style extraction failed',\n  },\n};\n\ninterface TextStyleSelectorProps {\n  value: string;\n  onChange: (value: string) => void;\n  onToast?: (msg: { message: string; type: 'success' | 'error' }) => void;\n}\n\nexport const TextStyleSelector: React.FC<TextStyleSelectorProps> = ({ value, onChange, onToast }) => {\n  const t = useT(i18n);\n  const [hoveredPresetId, setHoveredPresetId] = useState<string | null>(null);\n  const [isExtractingStyle, setIsExtractingStyle] = useState(false);\n  const styleImageInputRef = useRef<HTMLInputElement>(null);\n\n  return (\n    <div className=\"space-y-3\">\n      <Textarea\n        placeholder={t('stylePlaceholder')}\n        value={value}\n        onChange={(e) => onChange(e.target.value)}\n        rows={3}\n        className=\"text-sm border-2 border-gray-200 dark:border-border-primary dark:bg-background-tertiary dark:text-white dark:placeholder-foreground-tertiary focus:border-banana-400 dark:focus:border-banana transition-colors duration-200\"\n      />\n\n      <div className=\"space-y-2\">\n        <p className=\"text-xs font-medium text-gray-600 dark:text-foreground-tertiary\">\n          {t('presetStylesLabel')}\n        </p>\n        <div className=\"flex flex-wrap gap-2\">\n          {PRESET_STYLES.map((preset) => (\n            <div key={preset.id} className=\"relative\">\n              <button\n                type=\"button\"\n                onClick={() => onChange(t(preset.descriptionKey))}\n                onMouseEnter={() => setHoveredPresetId(preset.id)}\n                onMouseLeave={() => setHoveredPresetId(null)}\n                className=\"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-full border-2 border-gray-200 dark:border-border-primary dark:text-foreground-secondary hover:border-banana-400 dark:hover:border-banana hover:bg-banana-50 dark:hover:bg-background-hover transition-all duration-200 hover:shadow-sm dark:hover:shadow-none\"\n              >\n                <span\n                  className=\"w-2.5 h-2.5 rounded-full flex-shrink-0 ring-1 ring-black/10\"\n                  style={{ backgroundColor: preset.color }}\n                />\n                {t(preset.nameKey)}\n              </button>\n\n              {hoveredPresetId === preset.id && preset.previewImage && (\n                <div className=\"absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 z-50 animate-in fade-in slide-in-from-bottom-2 duration-200\">\n                  <div className=\"bg-white dark:bg-background-secondary rounded-lg shadow-2xl dark:shadow-none border-2 border-banana-400 dark:border-banana p-2.5 w-72\">\n                    <img\n                      src={preset.previewImage}\n                      alt={t(preset.nameKey)}\n                      className=\"w-full h-40 object-cover rounded\"\n                      onError={(e) => { e.currentTarget.style.display = 'none'; }}\n                    />\n                    <p className=\"text-xs text-gray-600 dark:text-foreground-tertiary mt-2 px-1 line-clamp-3\">\n                      {t(preset.descriptionKey)}\n                    </p>\n                  </div>\n                  <div className=\"absolute top-full left-1/2 transform -translate-x-1/2 -mt-1\">\n                    <div className=\"w-3 h-3 bg-white dark:bg-background-secondary border-r-2 border-b-2 border-banana-400 dark:border-banana transform rotate-45\"></div>\n                  </div>\n                </div>\n              )}\n            </div>\n          ))}\n\n          <button\n            type=\"button\"\n            onClick={() => styleImageInputRef.current?.click()}\n            disabled={isExtractingStyle}\n            className=\"px-3 py-1.5 text-xs font-medium rounded-full border-2 border-dashed border-gray-300 dark:border-border-primary dark:text-foreground-secondary hover:border-banana-400 dark:hover:border-banana hover:bg-banana-50 dark:hover:bg-background-hover transition-all duration-200 hover:shadow-sm dark:hover:shadow-none flex items-center gap-1\"\n          >\n            {isExtractingStyle ? (\n              <><Loader2 size={12} className=\"animate-spin\" />{t('extracting')}</>\n            ) : (\n              <><ImagePlus size={12} />{t('extractFromImage')}</>\n            )}\n          </button>\n          <input\n            ref={styleImageInputRef}\n            type=\"file\"\n            accept=\"image/*\"\n            onChange={async (e) => {\n              const file = e.target.files?.[0];\n              if (!file) return;\n              e.target.value = '';\n              setIsExtractingStyle(true);\n              try {\n                const result = await extractStyleFromImage(file);\n                if (result.data?.style_description) {\n                  onChange(result.data.style_description);\n                  onToast?.({ message: t('extractSuccess'), type: 'success' });\n                }\n              } catch (error: any) {\n                onToast?.({ message: `${t('extractFailed')}: ${error?.message || ''}`, type: 'error' });\n              } finally {\n                setIsExtractingStyle(false);\n              }\n            }}\n            className=\"hidden\"\n          />\n        </div>\n      </div>\n\n      <p className=\"text-xs text-gray-500 dark:text-foreground-tertiary\">\n        💡 {t('styleTip')}\n      </p>\n    </div>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/shared/Textarea.tsx",
    "content": "import React from 'react';\nimport { cn } from '@/utils';\n\ninterface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {\n  label?: string;\n  error?: string;\n}\n\nconst TextareaComponent = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({\n  label,\n  error,\n  className,\n  ...props\n}, ref) => {\n  return (\n    <div className=\"w-full\">\n      {label && (\n        <label className=\"block text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-2\">\n          {label}\n        </label>\n      )}\n      <textarea\n        ref={ref}\n        className={cn(\n          'w-full min-h-[120px] px-4 py-3 rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary',\n          'focus:outline-none focus:ring-2 focus:ring-banana-500 focus:border-transparent',\n          'placeholder:text-gray-400 dark:placeholder:text-gray-500 transition-all resize-y',\n          'text-gray-900 dark:text-foreground-primary',\n          error && 'border-red-500 focus:ring-red-500',\n          className\n        )}\n        {...props}\n      />\n      {error && (\n        <p className=\"mt-1 text-sm text-red-500\">{error}</p>\n      )}\n    </div>\n  );\n});\n\nTextareaComponent.displayName = 'Textarea';\n\n// 使用 memo 包装，避免父组件频繁重渲染时影响输入框\nexport const Textarea = React.memo(TextareaComponent);\n\n"
  },
  {
    "path": "frontend/src/components/shared/Toast.tsx",
    "content": "import React, { useEffect } from 'react';\nimport { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react';\nimport { cn } from '@/utils';\n\ninterface ToastProps {\n  message: string;\n  type?: 'success' | 'error' | 'info' | 'warning';\n  onClose: () => void;\n  duration?: number;\n}\n\nexport const Toast: React.FC<ToastProps> = ({\n  message,\n  type = 'info',\n  onClose,\n  duration = 3000,\n}) => {\n  useEffect(() => {\n    if (duration > 0) {\n      const timer = setTimeout(onClose, duration);\n      return () => clearTimeout(timer);\n    }\n  }, [duration, onClose]);\n\n  const icons = {\n    success: <CheckCircle size={20} />,\n    error: <AlertCircle size={20} />,\n    info: <Info size={20} />,\n    warning: <AlertTriangle size={20} />,\n  };\n\n  const styles = {\n    success: 'bg-green-500 text-white',\n    error: 'bg-red-500 text-white',\n    info: 'bg-gray-900 dark:bg-background-hover text-white',\n    warning: 'bg-amber-500 text-white',\n  };\n\n  return (\n    <div\n      className={cn(\n        'flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg',\n        'animate-in slide-in-from-right transition-all duration-300',\n        styles[type]\n      )}\n    >\n      {icons[type]}\n      <span className=\"flex-1\">{message}</span>\n      <button\n        onClick={onClose}\n        className=\"hover:opacity-75 transition-opacity\"\n      >\n        <X size={18} />\n      </button>\n    </div>\n  );\n};\n\n// Toast 管理器\nexport const useToast = () => {\n  const [toasts, setToasts] = React.useState<Array<{ id: string; props: Omit<ToastProps, 'onClose'> }>>([]);\n\n  const show = (props: Omit<ToastProps, 'onClose'>) => {\n    const id = Math.random().toString(36);\n    setToasts((prev) => {\n      const newToasts = [...prev, { id, props }];\n      // 最多保留5个toast，超过则移除最早的\n      return newToasts.length > 5 ? newToasts.slice(-5) : newToasts;\n    });\n  };\n\n  const remove = (id: string) => {\n    setToasts((prev) => prev.filter((t) => t.id !== id));\n  };\n\n  return {\n    show,\n    ToastContainer: () => (\n      <div className=\"fixed top-20 right-4 z-50 flex flex-col items-end gap-2 pointer-events-none\">\n        {toasts.map((toast) => (\n          <div key={toast.id} className=\"pointer-events-auto\">\n            <Toast\n              {...toast.props}\n              onClose={() => remove(toast.id)}\n            />\n          </div>\n        ))}\n      </div>\n    ),\n  };\n};\n\n"
  },
  {
    "path": "frontend/src/components/shared/index.ts",
    "content": "export { Button } from './Button';\nexport { Input } from './Input';\nexport { Textarea } from './Textarea';\nexport { Card } from './Card';\nexport { Modal } from './Modal';\nexport { Loading, Skeleton } from './Loading';\nexport { Toast, useToast } from './Toast';\nexport { StatusBadge } from './StatusBadge';\nexport { ContextualStatusBadge } from './ContextualStatusBadge';\nexport { ConfirmDialog, useConfirm } from './ConfirmDialog';\nexport { MaterialGeneratorModal } from './MaterialGeneratorModal';\nexport { MaterialCenterModal } from './MaterialCenterModal';\nexport { ReferenceFileCard } from './ReferenceFileCard';\nexport { ReferenceFileSelector } from './ReferenceFileSelector';\nexport { FilePreviewModal } from './FilePreviewModal';\nexport { ReferenceFileList } from './ReferenceFileList';\nexport { MaterialSelector } from './MaterialSelector';\nexport { Footer } from './Footer';\nexport { GithubBadge } from './GithubBadge';\nexport { GithubRepoCard } from './GithubRepoCard';\nexport { Markdown } from './Markdown';\nexport { AiRefineInput } from './AiRefineInput';\nexport { ShimmerOverlay } from './ShimmerOverlay';\nexport { ImagePreviewList } from './ImagePreviewList';\nexport { ProjectResourcesList } from './ProjectResourcesList';\nexport { HelpModal } from './HelpModal';\nexport { ProjectSettingsModal } from './ProjectSettingsModal';\nexport { ExportTasksPanel } from './ExportTasksPanel';\nexport { AccessCodeGuard } from './AccessCodeGuard';\nexport { TextStyleSelector } from './TextStyleSelector';\nexport { Pagination } from './Pagination';\n"
  },
  {
    "path": "frontend/src/config/aspectRatio.ts",
    "content": "export const ASPECT_RATIO_OPTIONS = [\n  { value: '16:9', label: '16:9' },\n  { value: '21:9', label: '21:9' },\n  { value: '4:3', label: '4:3' },\n  { value: '3:2', label: '3:2' },\n  { value: '5:4', label: '5:4' },\n  { value: '1:1', label: '1:1' },\n  { value: '4:5', label: '4:5' },\n  { value: '2:3', label: '2:3' },\n  { value: '3:4', label: '3:4' },\n  { value: '9:16', label: '9:16' },\n];\n"
  },
  {
    "path": "frontend/src/config/presetStyles.ts",
    "content": "// Preset PPT style configuration with i18n support\n\nexport interface PresetStyle {\n  id: string;\n  nameKey: string;  // i18n key for name\n  descriptionKey: string;  // i18n key for description\n  previewImage?: string;\n  color: string;  // accent color for visual indicator\n}\n\n// Style IDs map to i18n keys in presetStyles namespace\nexport const PRESET_STYLES: PresetStyle[] = [\n  {\n    id: 'business-simple',\n    nameKey: 'presetStyles.businessSimple.name',\n    descriptionKey: 'presetStyles.businessSimple.description',\n    previewImage: '/preset-previews/business-simple.webp',\n    color: '#0B1F3B',\n  },\n  {\n    id: 'tech-modern',\n    nameKey: 'presetStyles.techModern.name',\n    descriptionKey: 'presetStyles.techModern.description',\n    previewImage: '/preset-previews/tech-modern.webp',\n    color: '#7C3AED',\n  },\n  {\n    id: 'academic-formal',\n    nameKey: 'presetStyles.academicFormal.name',\n    descriptionKey: 'presetStyles.academicFormal.description',\n    previewImage: '/preset-previews/academic-formal.webp',\n    color: '#7F1D1D',\n  },\n  {\n    id: 'creative-fun',\n    nameKey: 'presetStyles.creativeFun.name',\n    descriptionKey: 'presetStyles.creativeFun.description',\n    previewImage: '/preset-previews/creative-fun.webp',\n    color: '#FF6A00',\n  },\n  {\n    id: 'minimalist-clean',\n    nameKey: 'presetStyles.minimalistClean.name',\n    descriptionKey: 'presetStyles.minimalistClean.description',\n    previewImage: '/preset-previews/minimalist-clean.webp',\n    color: '#6B7280',\n  },\n  {\n    id: 'luxury-premium',\n    nameKey: 'presetStyles.luxuryPremium.name',\n    descriptionKey: 'presetStyles.luxuryPremium.description',\n    previewImage: '/preset-previews/luxury-premium.webp',\n    color: '#F7E7CE',\n  },\n  {\n    id: 'nature-fresh',\n    nameKey: 'presetStyles.natureFresh.name',\n    descriptionKey: 'presetStyles.natureFresh.description',\n    previewImage: '/preset-previews/nature-fresh.webp',\n    color: '#14532D',\n  },\n  {\n    id: 'gradient-vibrant',\n    nameKey: 'presetStyles.gradientVibrant.name',\n    descriptionKey: 'presetStyles.gradientVibrant.description',\n    previewImage: '/preset-previews/gradient-vibrant.webp',\n    color: '#2563EB',\n  },\n];\n\n// Helper function to get style with translated values\nexport const getPresetStyleWithTranslation = (\n  style: PresetStyle,\n  t: (key: string) => string\n): { id: string; name: string; description: string; previewImage?: string } => {\n  return {\n    id: style.id,\n    name: t(style.nameKey),\n    description: t(style.descriptionKey),\n    previewImage: style.previewImage,\n  };\n};\n"
  },
  {
    "path": "frontend/src/config/presetStylesI18n.ts",
    "content": "// Preset style descriptions in multiple languages\n// 预设风格描述的多语言翻译\n\nexport const presetStylesI18n = {\n  zh: {\n    businessSimple: {\n      name: '简约商务',\n      description: '视觉描述：全局视觉语言严格对齐国际顶级咨询公司通用商务范式，强调专业、稳重、克制与可复用。全稿采用**极致扁平化**与**强秩序网格**，以信息清晰传达为唯一优先级。**禁止**渐变、发光、高光、拟物纹理、装饰性背景图案与任何非必要视觉元素。光照环境固定为**均匀演播室漫射光**：画面无方向性主光、无硬阴影、无戏剧化明暗对比，整体保持干净、透亮、冷静。\\n\\n配色与材质：背景色全篇锁定为海军蓝（Navy Blue，**#0B1F3B**）。所有标题与正文文字颜色固定为纯白（White，**#FFFFFF**）。唯一强调色固定为天蓝（Sky Blue，**#38BDF8**），仅用于关键数字、关键结论关键词、关键路径/连接线端点等一级强调，其使用面积占单页总面积**不超过3%**。分割线、辅助标注与次要边界仅使用浅灰（Light Gray，**#E5E7EB**）。材质表现固定为**平滑矢量色块**，**不使用**纸张纹理、颗粒噪点、金属/玻璃拟态或任何复杂材质。全稿默认**不使用阴影**；如因可读性必须使用（仅限信息卡与背景分离），阴影必须为极弱软阴影且不形成可见投影轮廓。\\n\\n内容与排版：版式必须遵循严格模块化网格系统，所有元素按统一对齐规则与基线系统排布，留白使用统一间距尺度，保证秩序感与一致性。页面结构固定为几何分区：标题区、主图/图表区、要点区、结论区；分区边界使用**1px细线**划分，线条颜色固定为 **#E5E7EB**（在深蓝底上保持低对比、克制呈现），**禁止**粗边框与装饰性图形。字体固定为无衬线体系：英文字体使用 **Roboto**，标题为 **Roboto Bold**，正文为 **Roboto Regular/Light**；中文字体使用 **思源黑体（Source Han Sans）**，标题为 **Heavy/Bold**，正文为 **Regular/Light**；同一层级禁止混用不同字体家族与字重。中英文混排必须统一基线与字重逻辑；强调仅允许通过字重/字号与天蓝（**#38BDF8**）实现。\\n\\n插图与图片规则（强制）：为保证扁平与一致性，所有视觉素材**必须**采用矢量插画形式，统一为**白色线稿**（**#FFFFFF**），关键部件或关键路径仅用天蓝（**#38BDF8**）点亮；**禁止**直接使用彩色照片、写实渲染、复杂背景与高细节纹理。线条粗细必须统一，转角规整，保持工程图式的简洁与理性。\\n\\n图表与图形：所有图表必须为**2D扁平矢量**，线宽统一、角度规整、无渐变与无立体效果。默认允许的图表类型仅限：**柱状图、折线图、流程/架构框图**；除非必须表达占比结构且分类不超过5类，否则**禁止饼图**。图表配色固定为白（**#FFFFFF**）为主体，天蓝（**#38BDF8**）仅用于关键数据点/关键路径高亮，浅灰（**#E5E7EB**）仅用于辅助刻度与分隔线。标注数量最小化，所有文本与数值严格对齐，图例与装饰网格一律从简。\\n\\n渲染要求：全稿输出为超高清矢量插画与商务信息图风格，所有文字与图形边缘必须锐利无锯齿，线条干净、层级稳定、对比克制，整体呈现严谨的企业级商务美学，适用于世界500强企业正式汇报场景。',\n    },\n    techModern: {\n      name: '现代科技',\n      description: '视觉描述：全局视觉语言要融合赛博朋克与现代SaaS产品的未来感。整体氛围神秘、深邃且富有动感，仿佛置身于高科技的数据中心或虚拟空间。光照采用暗调环境下的自发光效果，模拟霓虹灯管和激光的辉光。\\n\\n配色与材质：背景色采用深邃的\"午夜黑\"（Midnight Black, #0B0F19），以衬托前景的亮度。主色调使用高饱和度的\"电光蓝\"（Electric Blue, #00A3FF）与\"赛博紫\"（Cyber Purple, #7C3AED）进行线性渐变，营造出流动的能量感。材质上大量运用半透明的玻璃、发光的网格线以及带有金属光泽的几何体。\\n\\n内容与排版：画面中应包含悬浮的3D几何元素（如立方体、四面体或芯片结构），这些元素应带有线框渲染（Wireframe）效果。排版布局倾向于不对称的动态平衡，使用具有科技感的等宽字体或现代无衬线体。背景中可以隐约添加电路板纹理、二进制代码流或点阵地图作为装饰，增加细节密度。\\n\\n渲染要求：Octane Render渲染风格，强调光线追踪、辉光（Bloom）效果和景深控制，呈现出精细的粒子特效和充满科技张力的视觉冲击力。',\n    },\n    academicFormal: {\n      name: '严谨学术',\n      description: '视觉描述：全局视觉语言应模仿高质量印刷出版物或经典论文的排版风格，传达理性、客观和知识的厚重感。整体氛围安静、克制，没有任何干扰视线的炫光或过度设计。画面必须铺满全屏，严禁出现书本装订线、纸张边缘、卷角、阴影或任何形式的边框。背景不应该呈现三维立体，而应该以二维平面方式呈现。\\n\\n配色与材质：背景色严格限制为\"米白色\"（Off-white, #F8F7F2），模拟高级道林纸的质感。前景色仅使用纯黑（#000000）、深炭灰（Charcoal, #1F2937）和作为强调色的深红（Deep Red, #7F1D1D）或深蓝（Deep Blue, #1E3A8A）（这种强调色占比不超过5%）。材质完全呈现为高质量的纸质印刷效果，具有细腻的纸张纹理。\\n\\n内容与排版：排版必须遵循经典的版式设计原则，拥有宽阔的页边距。请使用带有衬线的字体（类似Times New Roman或Garamond）来体现传统与正式。视觉元素主要由精细的黑色线条框（Black, #000000）、标准的学术表格样式和黑白线稿插图（Black, #000000 / White, #FFFFFF）组成。布局上采用左右分栏或上下结构的严谨对齐方式。\\n\\n渲染要求：超高分辨率扫描件风格，强调字体的灰度抗锯齿效果和线条的锐度，画面如同精装学术期刊的内页，展现出绝对的专业性与权威性。不应该存在任何形式的页面边框，比如黑色边框或者阴影边线。',\n    },\n    creativeFun: {\n      name: '活泼创意',\n      description: '视觉描述：全局视觉语言要像一个充满活力的初创公司Pitch Deck或儿童教育应用界面。整体氛围轻松、愉悦、充满想象力，打破常规的束缚。光照明亮且充满阳光感，色彩之间没有阴影，呈现彻底的扁平化。\\n\\n配色与材质：背景色使用高明度的\"暖黄色\"（Warm Yellow, #FFD54A）。配色方案极其大胆，混合使用鲜艳的\"活力橙\"（Vibrant Orange, #FF6A00）、\"草绿\"（Grass Green, #22C55E）和\"天蓝\"（Sky Blue, #38BDF8），形成孟菲斯（Memphis）风格的撞色效果。材质上模拟手绘涂鸦、剪纸或粗糙边缘的矢量插画。\\n\\n内容与排版：画面内容应包含手绘风格的插图元素，如涂鸦箭头、星星、波浪线和不规则的有机形状色块。排版上允许文字倾斜、重叠或跳跃，打破僵硬的网格。字体选用圆润可爱的圆体或手写体。请在角落放置一些拟人化的可爱物体或夸张的对话气泡。\\n\\n渲染要求：Dribbble热门插画风格，色彩鲜艳平涂，线条流畅且富有弹性，视觉上给人一种快乐、友好且极具亲和力的感觉。',\n    },\n    minimalistClean: {\n      name: '极简清爽',\n      description: '视觉描述：全局视觉语言借鉴北欧设计（Scandinavian Design）和Kinfolk杂志的审美。整体氛围空灵、静谧，强调\"少即是多\"的哲学。光照采用极柔和的漫反射天光，阴影非常淡且边缘模糊，营造出空气感。\\n\\n配色与材质：背景色为极浅的\"雾霾灰\"（Haze Gray, #F5F5F7）。前景色仅使用中灰色（Mid Gray, #6B7280）和低饱和度的莫兰迪色系（如灰蓝（Morandi Gray Blue, #7A8FA6））作为微小的点缀。材质上体现细腻的哑光质感，偶尔出现一点点石膏（Plaster）的微纹理。\\n\\n内容与排版：构图的核心是\"留白\"（Negative Space），留白面积应占据画面的70%以上。排版极为克制，文字字号较小，行间距宽大，使用纤细优雅的非衬线字体。视觉锚点是简单的几何线条构成的图标，布局上追求绝对的平衡。\\n\\n渲染要求：极简主义摄影风格，高动态范围（HDR），画面极其干净，没有任何噪点，展现出一种画廊般的艺术陈列感。',\n    },\n    luxuryPremium: {\n      name: '高端奢华',\n      description: '视觉描述：全局视觉语言要融合高端腕表广告或五星级酒店的品牌形象。整体氛围神秘、高贵、独一无二。光照采用戏剧性的伦勃朗光或聚光灯效果，重点照亮关键元素，其余部分隐没在黑暗中。\\n\\n配色与材质：背景色严格锁定为深沉的\"曜石黑\"（Obsidian Black, #0B0B0F）。前景色主要由\"香槟金\"（Champagne Gold, #F7E7CE）构成。材质上必须体现昂贵的触感，核心组合为：背景呈现哑光黑天鹅绒质感，前景装饰呈现拉丝金属质感。\\n\\n内容与排版：排版采用经典的居中对齐或对称布局，强调仪式感。字体必须使用高雅的衬线体（Serif），字间距适当加宽以体现尊贵。画面中可以加入细致的金色边框线条、Art Deco风格的装饰纹样。如果有3D物体，应呈现出珠宝般的抛光质感。\\n\\n渲染要求：电影级写实渲染，强调材质的物理属性（PBR），特别是金属的高光反射和丝绒的漫反射细节，画面呈现出奢侈品广告大片的高级质感。',\n    },\n    natureFresh: {\n      name: '自然清新',\n      description: '视觉描述：全局视觉语言旨在唤起人们对大自然、环保和健康生活的向往，类似全食超市（Whole Foods）或Aesop的品牌视觉。整体氛围治愈、透气、有机。光照模拟清晨穿过树叶的斑驳阳光（丁达尔效应），温暖而柔和。\\n\\n配色与材质：背景色采用柔和的\"米色\"（Beige, #EAD9C6）。配色方案取自自然界，重点使用森林绿（Forest Green, #14532D）和大地棕（Earth Brown, #7A4E2D）。材质上强调天然纹理，如再生纸的颗粒感和植物叶片的脉络。\\n\\n内容与排版：画面中应融合真实的自然元素，主要是伸展的绿植叶片，这些元素可以作为背景装饰或前景框架。排版使用圆润亲和的字体。布局上可以稍微松散，模仿自然生长的形态。阴影处理要柔和自然，避免生硬的黑色投影。\\n\\n渲染要求：微距摄影风格结合3D渲染，强调植物表面的透光感（Subsurface Scattering）和自然材质的细腻纹理，画面清新淡雅，令人心旷神怡。',\n    },\n    gradientVibrant: {\n      name: '渐变活力',\n      description: '视觉描述：全局视觉语言对标现代科技独角兽公司（如Stripe或Linear）的官网视觉，呈现一种极光般的流动美感。整体氛围梦幻、通透且富有呼吸感，避免刺眼的撞色，强调色彩之间的优雅融合。\\n\\n配色与材质：背景即前景，使用全屏的弥散渐变色。配色方案采用高雅且和谐的\"全息色系\"，以深邃的\"宝石蓝\"（Royal Blue, #2563EB）为基底，平滑过渡到\"紫罗兰\"（Violet, #7C3AED）和明亮的\"洋红色\"（Magenta, #DB2777）。颜色之间如水彩般晕染，没有生硬的边界。材质上锁定为\"磨砂玻璃（Frosted Glass）\"质感，让色彩看起来像是透过一层雾面屏透出来的，增加朦胧的高级感。插画使用有质感的半立体彩色设计。\\n\\n内容与排版：画面核心是缓慢流动的有机波浪形状，形态柔和自然。排版上使用醒目的粗体无衬线字（Bold Sans-serif），文字颜色为纯白（#FFFFFF），以确保在多彩背景上的绝对清晰度。界面元素采用\"玻璃拟态\"（Glassmorphism），即高透明度的白色圆角卡片，带有细腻的白色描边和背景模糊效果。\\n\\n渲染要求：C4D流体模拟渲染，强调\"丝绸\"般的顺滑光泽，配合轻微的噪点（Grain）增加质感，色彩饱满但不刺眼，展现出流光溢彩的现代数字美学。',\n    },\n  },\n  en: {\n    businessSimple: {\n      name: 'Business Simple',\n      description: 'Visual Description: The global visual language strictly aligns with the universal business paradigm of top-tier international consulting firms, emphasizing professionalism, stability, restraint, and reusability. The entire presentation adopts **extreme flat design** and **strong ordered grids**, with clear information delivery as the sole priority. **Prohibit** gradients, glows, highlights, skeuomorphic textures, decorative background patterns, and any unnecessary visual elements. The lighting environment is fixed to **uniform studio diffused light**: no directional key light, no hard shadows, no dramatic light-dark contrast, maintaining a clean, bright, and calm overall appearance.\\n\\nColor & Material: The background color is locked throughout to Navy Blue (**#0B1F3B**). All heading and body text colors are fixed to pure white (White, **#FFFFFF**). The only accent color is fixed to Sky Blue (**#38BDF8**), used exclusively for key numbers, key conclusion keywords, key paths/connection line endpoints, and other primary emphasis, with its usage area **not exceeding 3%** of the total single-page area. Dividing lines, auxiliary annotations, and secondary boundaries use only Light Gray (**#E5E7EB**). Material expression is fixed to **smooth vector color blocks**; **do not use** paper textures, grain noise, metal/glass skeuomorphism, or any complex materials. The entire presentation defaults to **no shadows**; if shadows must be used for readability (limited to information cards separating from background), shadows must be extremely weak soft shadows without visible projection outlines.\\n\\nContent & Typography: Layout must follow a strict modular grid system, with all elements arranged according to unified alignment rules and baseline systems, using unified spacing scales for whitespace to ensure order and consistency. Page structure is fixed to geometric partitions: title area, main image/chart area, key points area, conclusion area; partition boundaries use **1px thin lines** for division, with line color fixed to **#E5E7EB** (maintaining low contrast and restrained presentation on dark blue background), **prohibit** thick borders and decorative graphics. Fonts are fixed to sans-serif system: English fonts use **Roboto**, headings use **Roboto Bold**, body text uses **Roboto Regular/Light**; Chinese fonts use **Source Han Sans**, headings use **Heavy/Bold**, body text uses **Regular/Light**; the same hierarchy prohibits mixing different font families and weights. Mixed Chinese-English text must unify baseline and weight logic; emphasis is only allowed through font weight/font size and Sky Blue (**#38BDF8**).\\n\\nIllustration & Image Rules (Mandatory): To ensure flatness and consistency, all visual materials **must** adopt vector illustration form, unified as **white line art** (**#FFFFFF**), with key components or key paths only highlighted using Sky Blue (**#38BDF8**); **prohibit** direct use of color photographs, realistic rendering, complex backgrounds, and high-detail textures. Line thickness must be unified, corners regular, maintaining the simplicity and rationality of engineering drawings.\\n\\nCharts & Graphics: All charts must be **2D flat vectors**, with unified line width, regular angles, no gradients, and no 3D effects. Default allowed chart types are limited to: **bar charts, line charts, flow/architecture block diagrams**; **prohibit pie charts** unless it is necessary to express proportional structure and categories do not exceed 5. Chart color scheme is fixed to white (**#FFFFFF**) as the main body, Sky Blue (**#38BDF8**) only for key data points/key path highlights, Light Gray (**#E5E7EB**) only for auxiliary scales and dividing lines. Minimize annotations, strictly align all text and values, simplify legends and decorative grids.\\n\\nRendering Requirements: The entire presentation outputs as ultra-high-definition vector illustrations and business infographic style. All text and graphic edges must be sharp without aliasing, lines clean, hierarchy stable, contrast restrained, overall presenting rigorous enterprise-level business aesthetics, suitable for Fortune 500 formal presentation scenarios.',\n    },\n    techModern: {\n      name: 'Tech Modern',\n      description: 'Visual Description: The global visual language should blend cyberpunk with modern SaaS product futurism. The overall atmosphere is mysterious, deep, and dynamic, as if set inside a high-tech data center or virtual space. Lighting uses self-luminous effects in a dark environment, simulating neon tubes and laser glow.\\n\\nColor & Material: The background is a deep Midnight Black (#0B0F19) to contrast with foreground brightness. The primary palette uses high-saturation Electric Blue (#00A3FF) and Cyber Purple (#7C3AED) in linear gradients, creating a flowing energy feel. Materials heavily feature translucent glass, glowing grid lines, and metallic-sheen geometric shapes.\\n\\nContent & Typography: The scene should contain floating 3D geometric elements (cubes, tetrahedrons, or chip structures) with wireframe rendering effects. Layout favors asymmetric dynamic balance, using tech-feel monospace or modern sans-serif fonts. The background may subtly include circuit board textures, binary code streams, or dot-matrix maps as decorative detail.\\n\\nRendering: Octane Render style, emphasizing ray tracing, bloom effects, and depth of field control, presenting refined particle effects and tech-driven visual impact.',\n    },\n    academicFormal: {\n      name: 'Academic Formal',\n      description: 'Visual Description: The global visual language should emulate high-quality print publications or classic academic paper typesetting, conveying rationality, objectivity, and intellectual gravitas. The overall atmosphere is quiet and restrained, with no distracting glare or over-design. The image must fill the entire screen — no book bindings, paper edges, curled corners, shadows, or borders of any kind. The background should be presented in 2D flat style, not 3D.\\n\\nColor & Material: The background color is strictly Off-white (#F8F7F2), simulating premium book paper texture. Foreground colors use only pure black (#000000), Charcoal (#1F2937), and sparingly used Deep Red (#7F1D1D) or Deep Blue (#1E3A8A) as accent colors (no more than 5%). The material fully presents as high-quality printed paper with fine paper grain texture.\\n\\nContent & Typography: Typography must follow classic typographic design principles with generous margins. Use serif fonts (similar to Times New Roman or Garamond) to convey tradition and formality. Visual elements consist mainly of fine black line frames (#000000), standard academic table styles, and black-and-white line illustrations (#000000 / #FFFFFF). Layout uses left-right columns or top-bottom structured strict alignment.\\n\\nRendering: Ultra-high resolution scan style, emphasizing font grayscale anti-aliasing and line sharpness, appearing like the inner pages of a hardcover academic journal, showcasing absolute professionalism and authority. No page borders, black frames, or shadow lines should exist.',\n    },\n    creativeFun: {\n      name: 'Creative Fun',\n      description: 'Visual Description: The global visual language should resemble an energetic startup pitch deck or children\\'s educational app interface. The overall atmosphere is relaxed, joyful, and imaginative, breaking conventional constraints. Lighting is bright and sunny, with no shadows between colors — completely flat design.\\n\\nColor & Material: The background uses a high-brightness Warm Yellow (#FFD54A). The color scheme is extremely bold, mixing vivid Vibrant Orange (#FF6A00), Grass Green (#22C55E), and Sky Blue (#38BDF8) to create Memphis-style color clashing effects. Materials simulate hand-drawn doodles, paper cutouts, or rough-edged vector illustrations.\\n\\nContent & Typography: The scene should contain hand-drawn illustration elements such as doodle arrows, stars, wavy lines, and irregular organic-shaped color blocks. Typography allows tilted, overlapping, or bouncing text, breaking rigid grids. Fonts should be rounded, cute bubble fonts or handwritten styles. Place some anthropomorphic cute objects or exaggerated speech bubbles in corners.\\n\\nRendering: Dribbble trending illustration style, with vivid flat colors, smooth elastic lines, visually conveying a happy, friendly, and approachable feeling.',\n    },\n    minimalistClean: {\n      name: 'Minimalist Clean',\n      description: 'Visual Description: The global visual language draws from Scandinavian Design and Kinfolk magazine aesthetics. The overall atmosphere is ethereal and tranquil, emphasizing the \"less is more\" philosophy. Lighting uses extremely soft diffused ambient light, with very faint and blurred shadows, creating an airy feel.\\n\\nColor & Material: The background is an ultra-light Haze Gray (#F5F5F7). Foreground colors use only Mid Gray (#6B7280) and low-saturation Morandi tones (such as Morandi Gray Blue #7A8FA6) as subtle accents. Materials feature fine matte finishes, with occasional slight plaster micro-texture.\\n\\nContent & Typography: The core of composition is negative space, which should occupy over 70% of the frame. Typography is extremely restrained — small font sizes, generous line spacing, using thin elegant sans-serif fonts. Visual anchors are simple geometric line icons, with layout pursuing absolute balance.\\n\\nRendering: Minimalist photography style, high dynamic range (HDR), extremely clean images with no noise, presenting a gallery-like art display aesthetic.',\n    },\n    luxuryPremium: {\n      name: 'Luxury Premium',\n      description: 'Visual Description: The global visual language should blend luxury watch advertising with five-star hotel brand imagery. The overall atmosphere is mysterious, noble, and unique. Lighting uses dramatic Rembrandt lighting or spotlight effects, illuminating key elements while the rest fades into darkness.\\n\\nColor & Material: The background is strictly locked to deep Obsidian Black (#0B0B0F). Foreground colors primarily consist of Champagne Gold (#F7E7CE). Materials must convey an expensive tactile quality — the core combination is: matte black velvet texture for backgrounds, brushed metal texture for foreground decorations.\\n\\nContent & Typography: Layout uses classic centered or symmetrical alignment, emphasizing ceremonial feel. Fonts must be elegant serif typefaces with slightly widened letter spacing to convey prestige. The scene may include delicate gold border lines and Art Deco decorative patterns. Any 3D objects should have a jewel-like polished quality.\\n\\nRendering: Cinematic photorealistic rendering, emphasizing physical material properties (PBR), particularly metallic specular reflections and velvet diffuse reflection details, presenting luxury advertising campaign-level premium quality.',\n    },\n    natureFresh: {\n      name: 'Nature Fresh',\n      description: 'Visual Description: The global visual language aims to evoke longing for nature, environmental awareness, and healthy living, similar to Whole Foods or Aesop brand visuals. The overall atmosphere is healing, breathable, and organic. Lighting simulates morning sunlight filtering through leaves (Tyndall effect), warm and soft.\\n\\nColor & Material: The background uses a soft Beige (#EAD9C6). The color palette draws from nature, primarily using Forest Green (#14532D) and Earth Brown (#7A4E2D). Materials emphasize natural textures such as recycled paper grain and plant leaf veins.\\n\\nContent & Typography: The scene should integrate real natural elements, primarily extending green plant leaves, as background decoration or foreground framing. Typography uses rounded, approachable fonts. Layout can be slightly loose, mimicking natural growth patterns. Shadow treatment should be soft and natural, avoiding harsh black drop shadows.\\n\\nRendering: Macro photography style combined with 3D rendering, emphasizing subsurface scattering on plant surfaces and fine natural material textures, creating a fresh and elegant image that feels refreshing and uplifting.',\n    },\n    gradientVibrant: {\n      name: 'Gradient Vibrant',\n      description: 'Visual Description: The global visual language benchmarks modern tech unicorn companies (such as Stripe or Linear) website visuals, presenting an aurora-like flowing beauty. The overall atmosphere is dreamy, translucent, and breathable, avoiding harsh color clashes and emphasizing elegant color fusion.\\n\\nColor & Material: The background IS the foreground, using full-screen diffused gradients. The palette uses elegant and harmonious \"holographic colors,\" with a deep Royal Blue (#2563EB) base smoothly transitioning to Violet (#7C3AED) and bright Magenta (#DB2777). Colors blend like watercolors without hard boundaries. The material is locked to \"frosted glass\" texture, making colors appear as if glowing through a matte screen, adding an elegant haziness. Illustrations use textured semi-dimensional colorful designs.\\n\\nContent & Typography: The visual core consists of slowly flowing organic wave shapes with soft, natural forms. Typography uses bold sans-serif fonts, with text color in pure white (#FFFFFF) to ensure absolute clarity on the multicolored background. Interface elements use glassmorphism — highly transparent white rounded cards with subtle white borders and background blur effects.\\n\\nRendering: C4D fluid simulation rendering, emphasizing \"silk-like\" smooth sheen, with subtle grain for texture. Colors are saturated but not glaring, showcasing an iridescent modern digital aesthetic.',\n    },\n  },\n};\n"
  },
  {
    "path": "frontend/src/hooks/useGeneratingState.ts",
    "content": "import type { Page } from '@/types';\n\n/**\n * 判断页面描述是否处于生成状态\n * 通过 page.status === 'GENERATING_DESCRIPTION' 驱动，与图片生成的 'GENERATING' 状态互不干扰\n */\nexport const useDescriptionGeneratingState = (\n  page: Page,\n  isAiRefining: boolean\n): boolean => {\n  return page.status === 'GENERATING_DESCRIPTION' || isAiRefining;\n};\n\n/**\n * 判断页面图片是否处于生成状态\n * 检查与图片生成相关的状态：\n * 1. 图片生成任务（isGenerating）\n * 2. 页面的 GENERATING 状态（在图片生成过程中设置）\n */\nexport const useImageGeneratingState = (\n  page: Page,\n  isGenerating: boolean\n): boolean => {\n  return isGenerating || page.status === 'QUEUED' || page.status === 'GENERATING';\n};\n\n/**\n * @deprecated 使用 useDescriptionGeneratingState 或 useImageGeneratingState 替代\n * 原来的通用版本：合并所有生成状态\n * 问题：无法区分描述生成和图片生成，导致在描述页面看到图片生成状态\n */\nexport const useGeneratingState = (\n  page: Page,\n  isGenerating: boolean,\n  isAiRefining: boolean\n): boolean => {\n  return isGenerating || page.status === 'QUEUED' || page.status === 'GENERATING' || isAiRefining;\n};\n\n/**\n * 简单版本：只判断页面自身的生成状态\n */\nexport const usePageGeneratingState = (\n  page: Page,\n  isGenerating: boolean\n): boolean => {\n  return isGenerating || page.status === 'QUEUED' || page.status === 'GENERATING';\n};\n\n\n"
  },
  {
    "path": "frontend/src/hooks/useImagePaste.ts",
    "content": "import { useState, useCallback, useRef } from 'react';\nimport { uploadMaterial } from '@/api/endpoints';\nimport { useT } from '@/hooks/useT';\n\nconst ALLOWED_IMAGE_TYPES = [\n  'image/png', 'image/jpeg', 'image/gif',\n  'image/webp', 'image/bmp', 'image/svg+xml',\n];\n\nconst UPLOADING_PREFIX = 'uploading:';\n\n/** Check if a URL is an uploading placeholder */\nexport const isUploadingUrl = (url: string) => url.startsWith(UPLOADING_PREFIX);\n\n/** Extract the blob preview URL from an uploading placeholder */\nexport const getUploadingPreviewUrl = (url: string) =>\n  isUploadingUrl(url) ? url.slice(UPLOADING_PREFIX.length) : url;\n\n/** Escape markdown special characters in alt text to prevent injection */\nconst escapeMarkdown = (text: string): string => {\n  return text.replace(/[[\\]()]/g, '\\\\$&');\n};\n\n/** Generate a placeholder markdown for a file (exported for MarkdownTextarea) */\nexport const generatePlaceholder = (file: File): { blobUrl: string; markdown: string } => {\n  const blobUrl = URL.createObjectURL(file);\n  const placeholderUrl = `${UPLOADING_PREFIX}${blobUrl}`;\n  const name = escapeMarkdown(file.name.replace(/\\.[^.]+$/, '') || 'image');\n  return { blobUrl, markdown: `![${name}](${placeholderUrl})` };\n};\n\nconst imagePasteI18n = {\n  zh: {\n    imagePaste: {\n      uploadSuccess: '{{count}} 张图片已插入',\n      uploadSuccessSingle: '图片已插入',\n      uploadFailed: '图片上传失败',\n      partialSuccess: '{{success}} 张上传成功，{{failed}} 张失败',\n      unsupportedType: '不支持的文件类型：{{types}}',\n      captionFailed: '图片描述识别失败，已使用文件名替代',\n    }\n  },\n  en: {\n    imagePaste: {\n      uploadSuccess: '{{count}} images inserted',\n      uploadSuccessSingle: 'Image inserted',\n      uploadFailed: 'Image upload failed',\n      partialSuccess: '{{success}} uploaded, {{failed}} failed',\n      unsupportedType: 'Unsupported file type: {{types}}',\n      captionFailed: 'Image caption recognition failed, using filename instead',\n    }\n  }\n};\n\ninterface UseImagePasteOptions {\n  projectId?: string | null;\n  setContent: (updater: (prev: string) => string) => void;\n  generateCaption?: boolean;\n  showToast: (props: { message: string; type: 'success' | 'error' | 'info' | 'warning' }) => void;\n  /** Whether to warn about non-image file types. Default: true */\n  warnUnsupportedTypes?: boolean;\n  /** If provided, use this to insert placeholder at cursor position instead of appending to end */\n  insertAtCursor?: (markdown: string) => void;\n}\n\nexport const useImagePaste = ({\n  projectId,\n  setContent,\n  generateCaption = true,\n  showToast,\n  warnUnsupportedTypes = true,\n  insertAtCursor,\n}: UseImagePasteOptions) => {\n  const t = useT(imagePasteI18n);\n  const [isUploading, setIsUploading] = useState(false);\n  const pendingCount = useRef(0);\n\n  // Use refs so handleFiles always accesses the latest setContent/insertAtCursor\n  const setContentRef = useRef(setContent);\n  setContentRef.current = setContent;\n  const insertAtCursorRef = useRef(insertAtCursor);\n  insertAtCursorRef.current = insertAtCursor;\n\n  /** Core: upload image files with placeholder insertion */\n  const handleFiles = useCallback(async (files: File[]) => {\n    const imageFiles = files.filter(f => ALLOWED_IMAGE_TYPES.includes(f.type));\n\n    if (imageFiles.length === 0) {\n      if (warnUnsupportedTypes && files.length > 0) {\n        const types = files.map(f => f.name.split('.').pop() || f.type);\n        showToast({\n          message: t('imagePaste.unsupportedType', { types: types.join(', ') }),\n          type: 'warning',\n        });\n      }\n      return;\n    }\n\n    const placeholders = imageFiles.map(file => {\n      const { blobUrl, markdown } = generatePlaceholder(file);\n      return { file, blobUrl, markdown };\n    });\n\n    // Insert placeholders - use insertAtCursor if provided, otherwise append to end\n    const placeholderInsert = placeholders.map(p => p.markdown).join('\\n');\n    if (insertAtCursorRef.current) {\n      insertAtCursorRef.current(placeholderInsert + '\\n');\n    } else {\n      setContentRef.current(prev => {\n        // Check if placeholders already exist (in case MarkdownTextarea inserted them)\n        const newPlaceholders = placeholders.filter(p => !prev.includes(p.markdown));\n        if (newPlaceholders.length === 0) {\n          return prev; // All placeholders already exist, skip insertion\n        }\n        const insert = newPlaceholders.map(p => p.markdown).join('\\n');\n        const prefix = prev && !prev.endsWith('\\n') ? '\\n' : '';\n        return prev + prefix + insert + '\\n';\n      });\n    }\n\n    pendingCount.current += placeholders.length;\n    setIsUploading(true);\n\n    const results = await Promise.allSettled(\n      placeholders.map(async ({ file, blobUrl, markdown }) => {\n        try {\n          const response = await uploadMaterial(file, projectId ?? null, generateCaption);\n          const realUrl = response?.data?.url;\n          const rawCaption = response?.data?.caption || file.name.replace(/\\.[^.]+$/, '') || 'image';\n          const caption = escapeMarkdown(rawCaption);\n          if (!realUrl) throw new Error('No URL in response');\n\n          // Track whether caption generation was requested but failed\n          const captionFailed = generateCaption && !response?.data?.caption;\n\n          setContentRef.current(prev => prev.replace(markdown, `![${caption}](${realUrl})`));\n          return { success: true, captionFailed };\n        } catch {\n          setContentRef.current(prev => prev.replace(markdown + '\\n', '').replace(markdown, ''));\n          return { success: false };\n        } finally {\n          URL.revokeObjectURL(blobUrl);\n          pendingCount.current--;\n          if (pendingCount.current === 0) setIsUploading(false);\n        }\n      })\n    );\n\n    const successCount = results.filter(r => r.status === 'fulfilled' && r.value.success).length;\n    const failedCount = placeholders.length - successCount;\n\n    if (failedCount === 0 && successCount > 0) {\n      showToast({\n        message: successCount === 1\n          ? t('imagePaste.uploadSuccessSingle')\n          : t('imagePaste.uploadSuccess', { count: String(successCount) }),\n        type: 'success',\n      });\n    } else if (failedCount > 0 && successCount > 0) {\n      showToast({\n        message: t('imagePaste.partialSuccess', {\n          success: String(successCount),\n          failed: String(failedCount),\n        }),\n        type: 'warning',\n      });\n    } else if (failedCount > 0 && successCount === 0) {\n      showToast({ message: t('imagePaste.uploadFailed'), type: 'error' });\n    }\n\n    // Warn about caption generation failures (separate from upload success/failure)\n    const captionFailedCount = results.filter(\n      r => r.status === 'fulfilled' && r.value.captionFailed\n    ).length;\n    if (captionFailedCount > 0) {\n      showToast({ message: t('imagePaste.captionFailed'), type: 'warning' });\n    }\n  }, [projectId, generateCaption, warnUnsupportedTypes, showToast, t]);\n\n  /** Handle clipboard paste event */\n  const handlePaste = useCallback(async (e: React.ClipboardEvent<HTMLElement>) => {\n    const items = e.clipboardData?.items;\n    if (!items) return;\n\n    const imageFiles: File[] = [];\n    const unsupportedTypes: string[] = [];\n\n    for (let i = 0; i < items.length; i++) {\n      const item = items[i];\n      if (item.kind !== 'file') continue;\n      const file = item.getAsFile();\n      if (!file) continue;\n\n      if (ALLOWED_IMAGE_TYPES.includes(item.type)) {\n        imageFiles.push(file);\n      } else if (warnUnsupportedTypes) {\n        unsupportedTypes.push(file.name.split('.').pop() || item.type);\n      }\n    }\n\n    if (imageFiles.length === 0) {\n      if (unsupportedTypes.length > 0) {\n        showToast({\n          message: t('imagePaste.unsupportedType', { types: unsupportedTypes.join(', ') }),\n          type: 'warning',\n        });\n      }\n      return;\n    }\n\n    e.preventDefault();\n    await handleFiles(imageFiles);\n  }, [handleFiles, warnUnsupportedTypes, showToast, t]);\n\n  return { handlePaste, handleFiles, isUploading };\n};\n"
  },
  {
    "path": "frontend/src/hooks/usePageStatus.ts",
    "content": "import { useT } from '@/hooks/useT';\nimport type { Page, PageStatus } from '@/types';\n\n// usePageStatus hook 自包含翻译\nconst pageStatusI18n = {\n  zh: {\n    status: {\n      draft: \"草稿\", generatingDescription: \"描述生成中\", descriptionGenerated: \"描述已生成\", queued: \"排队中\", generating: \"生成中\",\n      completed: \"已完成\", failed: \"失败\", unknown: \"未知\",\n      notGeneratedDesc: \"未生成描述\", noDescription: \"还没有生成描述\",\n      descGenerated: \"描述已生成\", notGeneratedImage: \"未生成图片\",\n      waitingForImage: \"等待生成图片\", queuedImage: \"排队等待生成\", generatingImage: \"正在生成图片\",\n      imageFailed: \"图片生成失败\", imageCompleted: \"图片已生成\",\n      statusUnknown: \"状态未知\", draftStage: \"草稿阶段\", allCompleted: \"全部完成\"\n    }\n  },\n  en: {\n    status: {\n      draft: \"Draft\", generatingDescription: \"Generating Description\", descriptionGenerated: \"Description Generated\", queued: \"Queued\", generating: \"Generating\",\n      completed: \"Completed\", failed: \"Failed\", unknown: \"Unknown\",\n      notGeneratedDesc: \"No Description\", noDescription: \"No description generated yet\",\n      descGenerated: \"Description Generated\", notGeneratedImage: \"No Image\",\n      waitingForImage: \"Waiting for image generation\", queuedImage: \"Queued for generation\", generatingImage: \"Generating image\",\n      imageFailed: \"Image generation failed\", imageCompleted: \"Image generated\",\n      statusUnknown: \"Status unknown\", draftStage: \"Draft stage\", allCompleted: \"All completed\"\n    }\n  }\n};\n\nexport type PageStatusContext = 'description' | 'image' | 'full';\n\nexport interface DerivedPageStatus {\n  status: PageStatus;\n  label: string;\n  description: string;\n}\n\nexport const usePageStatus = (\n  page: Page,\n  context: PageStatusContext = 'full'\n): DerivedPageStatus => {\n  const t = useT(pageStatusI18n);\n  const hasDescription = !!page.description_content;\n  const hasImage = !!page.generated_image_path;\n  const pageStatus = page.status;\n\n  switch (context) {\n    case 'description':\n      if (pageStatus === 'GENERATING_DESCRIPTION') {\n        return {\n          status: 'GENERATING_DESCRIPTION',\n          label: t('status.generatingDescription'),\n          description: t('status.generatingDescription')\n        };\n      }\n      if (!hasDescription) {\n        return {\n          status: 'DRAFT',\n          label: t('status.notGeneratedDesc'),\n          description: t('status.noDescription')\n        };\n      }\n      return {\n        status: 'DESCRIPTION_GENERATED',\n        label: t('status.descriptionGenerated'),\n        description: t('status.descGenerated')\n      };\n\n    case 'image':\n      if (!hasDescription) {\n        return {\n          status: 'DRAFT',\n          label: t('status.notGeneratedDesc'),\n          description: t('status.noDescription')\n        };\n      }\n      if (pageStatus === 'QUEUED') {\n        return {\n          status: 'QUEUED',\n          label: t('status.queued'),\n          description: t('status.queuedImage')\n        };\n      }\n      if (!hasImage && pageStatus !== 'GENERATING') {\n        return {\n          status: 'DESCRIPTION_GENERATED',\n          label: t('status.notGeneratedImage'),\n          description: t('status.waitingForImage')\n        };\n      }\n      if (pageStatus === 'GENERATING') {\n        return {\n          status: 'GENERATING',\n          label: t('status.generating'),\n          description: t('status.generatingImage')\n        };\n      }\n      if (pageStatus === 'FAILED') {\n        return {\n          status: 'FAILED',\n          label: t('status.failed'),\n          description: t('status.imageFailed')\n        };\n      }\n      if (hasImage) {\n        return {\n          status: 'COMPLETED',\n          label: t('status.completed'),\n          description: t('status.imageCompleted')\n        };\n      }\n      return {\n        status: pageStatus,\n        label: t('status.unknown'),\n        description: t('status.statusUnknown')\n      };\n\n    case 'full':\n    default:\n      return {\n        status: pageStatus,\n        label: getStatusLabel(pageStatus, t),\n        description: getStatusDescription(pageStatus, t)\n      };\n  }\n};\n\nfunction getStatusLabel(status: PageStatus, t: (key: string) => string): string {\n  const labels: Record<PageStatus, string> = {\n    DRAFT: t('status.draft'),\n    GENERATING_DESCRIPTION: t('status.generatingDescription'),\n    DESCRIPTION_GENERATED: t('status.descriptionGenerated'),\n    QUEUED: t('status.queued'),\n    GENERATING: t('status.generating'),\n    COMPLETED: t('status.completed'),\n    FAILED: t('status.failed'),\n  };\n  return labels[status] || t('status.unknown');\n}\n\nfunction getStatusDescription(status: PageStatus, t: (key: string) => string): string {\n  if (status === 'DRAFT') return t('status.draftStage');\n  if (status === 'GENERATING_DESCRIPTION') return t('status.generatingDescription');\n  if (status === 'DESCRIPTION_GENERATED') return t('status.descGenerated');\n  if (status === 'QUEUED') return t('status.queuedImage');\n  if (status === 'GENERATING') return t('status.generating');\n  if (status === 'FAILED') return t('status.failed');\n  if (status === 'COMPLETED') return t('status.allCompleted');\n  return t('status.statusUnknown');\n}\n"
  },
  {
    "path": "frontend/src/hooks/useT.ts",
    "content": "import { useTranslation } from 'react-i18next';\n\ntype NestedRecord = Record<string, unknown>;\n\ntype Translations = {\n  zh: NestedRecord;\n  en: NestedRecord;\n};\n\n/**\n * 获取嵌套对象的值，支持点号路径\n * 例如：getNestedValue(obj, 'home.title') 获取 obj.home.title\n */\nfunction getNestedValue(obj: NestedRecord, path: string): string | undefined {\n  const keys = path.split('.');\n  let current: unknown = obj;\n  \n  for (const key of keys) {\n    if (current && typeof current === 'object' && key in current) {\n      current = (current as NestedRecord)[key];\n    } else {\n      return undefined;\n    }\n  }\n  \n  return typeof current === 'string' ? current : undefined;\n}\n\n/**\n * 组件级 i18n hook - 智能 fallback（方案 B）\n * \n * 优先从组件内翻译查找，找不到自动 fallback 到全局翻译。\n * 这样只需要一个 t()，调用方式完全不变！\n * \n * 使用方式：\n * ```tsx\n * const homeI18n = {\n *   zh: {\n *     home: {\n *       title: '蕉幻',\n *       messages: { success: '成功' }\n *     }\n *   },\n *   en: {\n *     home: {\n *       title: 'Banana Slides',\n *       messages: { success: 'Success' }\n *     }\n *   }\n * };\n * \n * const t = useT(homeI18n);\n * \n * t('home.title')     // 从组件内翻译获取: \"蕉幻\"\n * t('common.save')    // 组件内没有，自动 fallback 到全局: \"保存\"\n * ```\n * \n * 这样翻译和组件放在一起，AI 一眼就能看到所有上下文！\n */\nexport function useT<T extends Translations>(translations: T) {\n  const { t: globalT, i18n } = useTranslation();\n  const lang = i18n.language?.startsWith('zh') ? 'zh' : 'en';\n  const dict = translations[lang] || translations['zh'];\n\n  // 兼容 react-i18next 的多种调用方式：\n  // t('key') / t('key', '默认值') / t('key', { param: value })\n  return (key: string, defaultOrParams?: string | Record<string, string | number>): string => {\n    // 解析第二个参数\n    const params = typeof defaultOrParams === 'object' ? defaultOrParams : undefined;\n    \n    // 优先从组件内翻译查找\n    const localValue = getNestedValue(dict, key);\n    \n    if (localValue !== undefined) {\n      // 组件内找到了，处理插值\n      let text = localValue;\n      if (params) {\n        Object.entries(params).forEach(([k, v]) => {\n          text = text.replace(new RegExp(`{{${k}}}`, 'g'), String(v));\n        });\n      }\n      return text;\n    }\n    \n    // 组件内没找到，fallback 到全局翻译（保持原始参数传递）\n    return globalT(key, defaultOrParams as any);\n  };\n}\n"
  },
  {
    "path": "frontend/src/hooks/useTheme.ts",
    "content": "import { useState, useEffect, useCallback } from 'react';\n\nexport type Theme = 'light' | 'dark' | 'system';\n\nconst THEME_KEY = 'banana-slides-theme';\n\nfunction getSystemTheme(): 'light' | 'dark' {\n  if (typeof window !== 'undefined' && window.matchMedia) {\n    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';\n  }\n  return 'light';\n}\n\nfunction applyTheme(theme: Theme) {\n  const root = document.documentElement;\n  const effectiveTheme = theme === 'system' ? getSystemTheme() : theme;\n\n  if (effectiveTheme === 'dark') {\n    root.classList.add('dark');\n  } else {\n    root.classList.remove('dark');\n  }\n}\n\nexport function useTheme() {\n  const [theme, setThemeState] = useState<Theme>(() => {\n    if (typeof window !== 'undefined') {\n      const stored = localStorage.getItem(THEME_KEY) as Theme | null;\n      return stored || 'system';\n    }\n    return 'system';\n  });\n\n  const setTheme = useCallback((newTheme: Theme) => {\n    setThemeState(newTheme);\n    localStorage.setItem(THEME_KEY, newTheme);\n    applyTheme(newTheme);\n  }, []);\n\n  // Apply theme on mount and when theme changes\n  useEffect(() => {\n    applyTheme(theme);\n  }, [theme]);\n\n  // Listen for system theme changes\n  useEffect(() => {\n    if (theme !== 'system') return;\n\n    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n    const handleChange = () => applyTheme('system');\n\n    mediaQuery.addEventListener('change', handleChange);\n    return () => mediaQuery.removeEventListener('change', handleChange);\n  }, [theme]);\n\n  const effectiveTheme = theme === 'system' ? getSystemTheme() : theme;\n\n  return {\n    theme,\n    setTheme,\n    effectiveTheme,\n    isDark: effectiveTheme === 'dark',\n  };\n}\n"
  },
  {
    "path": "frontend/src/i18n.ts",
    "content": "import i18n from 'i18next';\nimport { initReactI18next } from 'react-i18next';\nimport LanguageDetector from 'i18next-browser-languagedetector';\n\nimport zh from './locales/zh.json';\nimport en from './locales/en.json';\n\ni18n\n  .use(LanguageDetector)\n  .use(initReactI18next)\n  .init({\n    resources: {\n      zh: { translation: zh },\n      en: { translation: en },\n    },\n    fallbackLng: 'zh',\n    debug: false,\n    interpolation: {\n      escapeValue: false,\n    },\n    detection: {\n      order: ['localStorage', 'navigator'],\n      caches: ['localStorage'],\n      lookupLocalStorage: 'banana-slides-language',\n    },\n  });\n\nexport default i18n;\n"
  },
  {
    "path": "frontend/src/index.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n:root {\n  /* 主色 - 黄色系 */\n  --banana-yellow: #FFD700;\n  --banana-yellow-light: #FFE44D;\n  --banana-yellow-dark: #FFC700;\n  --banana-yellow-pale: #FFF9E6;\n\n  /* 中性色 - 黑白灰 */\n  --black: #1A1A1A;\n  --black-80: #4A4A4A;\n  --black-60: #757575;\n  --black-10: #F5F5F5;\n  --white: #FFFFFF;\n\n  /* 功能色 */\n  --success: #52C41A;\n  --warning: #FAAD14;\n  --error: #FF4D4F;\n  --info: #1890FF;\n\n  /* 过渡 */\n  --transition-fast: 150ms ease-in-out;\n  --transition-base: 250ms ease-in-out;\n  --transition-slow: 350ms ease-in-out;\n\n  /* 背景色 */\n  --bg-primary: #FFFFFF;\n  --bg-secondary: #F5F5F5;\n  --bg-tertiary: #FAFAFA;\n  --bg-elevated: #FFFFFF;\n  --bg-hover: #F5F5F5;\n\n  /* 文字色 */\n  --text-primary: #1A1A1A;\n  --text-secondary: #4A4A4A;\n  --text-tertiary: #757575;\n\n  /* 边框色 */\n  --border-primary: #E5E5E5;\n  --border-secondary: #F0F0F0;\n  --border-hover: #D4D4D4;\n}\n\n.dark {\n  /* 主色 - 黄色系（深色模式下稍微调亮） */\n  --banana-yellow: #F5A623;\n  --banana-yellow-light: #FFD700;\n  --banana-yellow-dark: #E6930E;\n  --banana-yellow-pale: #2A2520;\n\n  /* 中性色 - 深色模式 */\n  --black: #F5F5F5;\n  --black-80: #D4D4D4;\n  --black-60: #8E8E8E;\n  --black-10: #1E1E24;\n  --white: #13131A;\n\n  /* 背景色 */\n  --bg-primary: #13131A;\n  --bg-secondary: #1E1E24;\n  --bg-tertiary: #17171E;\n  --bg-elevated: #252530;\n  --bg-hover: #2E2E38;\n\n  /* 文字色 */\n  --text-primary: #FFFFFF;\n  --text-secondary: #B0B0B0;\n  --text-tertiary: #6E6E6E;\n\n  /* 边框色 */\n  --border-primary: #2E2E38;\n  --border-secondary: #252530;\n  --border-hover: #3E3E48;\n}\n\n* {\n  margin: 0;\n  padding: 0;\n  box-sizing: border-box;\n}\n\nbody {\n  font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  /* 移动端优化 */\n  -webkit-tap-highlight-color: transparent;\n  touch-action: manipulation;\n  background-color: var(--bg-primary);\n  color: var(--text-primary);\n  transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out;\n}\n\n#root {\n  min-height: 100vh;\n}\n\n/* 自定义滚动条 */\n::-webkit-scrollbar {\n  width: 8px;\n  height: 8px;\n}\n\n::-webkit-scrollbar-track {\n  background: var(--bg-secondary);\n}\n\n::-webkit-scrollbar-thumb {\n  background: var(--banana-yellow);\n  border-radius: 4px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n  background: var(--banana-yellow-dark);\n}\n\n/* 描述卡片极轻滚动条 */\n.desc-card-scroll::-webkit-scrollbar {\n  width: 3px;\n}\n.desc-card-scroll::-webkit-scrollbar-track {\n  background: transparent;\n}\n.desc-card-scroll::-webkit-scrollbar-thumb {\n  background: rgba(0, 0, 0, 0.1);\n  border-radius: 3px;\n}\n.desc-card-scroll::-webkit-scrollbar-thumb:hover {\n  background: rgba(0, 0, 0, 0.2);\n}\n.dark .desc-card-scroll::-webkit-scrollbar-thumb {\n  background: rgba(255, 255, 255, 0.1);\n}\n.dark .desc-card-scroll::-webkit-scrollbar-thumb:hover {\n  background: rgba(255, 255, 255, 0.2);\n}\n\n/* 移动端触摸优化 */\n.touch-manipulation {\n  touch-action: manipulation;\n  -webkit-tap-highlight-color: transparent;\n}\n\n/* 移动端按钮优化 - 仅针对主要交互按钮 */\n@media (max-width: 640px) {\n  .btn-touch-target,\n  button:not(.no-min-touch-target):not([class*=\"w-\"]):not([class*=\"h-\"]),\n  a:not(.no-min-touch-target):not([class*=\"w-\"]):not([class*=\"h-\"]) {\n    min-height: 44px; /* iOS推荐的最小触摸目标 */\n    min-width: 44px;\n  }\n}\n\n/* 渐变文本动画 */\n@keyframes gradient {\n  0%, 100% {\n    background-position: 0% 50%;\n  }\n  50% {\n    background-position: 100% 50%;\n  }\n}\n\n/* Markdown 内容样式 - 仅保留基础容器样式，具体样式由 Markdown.tsx 组件内的 Tailwind 类处理 */\n.markdown-content {\n  line-height: 1.6;\n}\n\n"
  },
  {
    "path": "frontend/src/locales/en.json",
    "content": "{\n  \"_note\": \"This file ONLY contains truly common/generic translations (buttons, status words, etc). All component-specific translations should be self-contained within component files using useT hook. Do NOT add component-specific translations here.\",\n  \"_principle\": \"If a translation is only used by one component, it belongs IN that component, not here.\",\n  \"common\": {\n    \"save\": \"Save\",\n    \"cancel\": \"Cancel\",\n    \"confirm\": \"Confirm\",\n    \"delete\": \"Delete\",\n    \"edit\": \"Edit\",\n    \"loading\": \"Loading...\",\n    \"retry\": \"Retry\",\n    \"back\": \"Back\",\n    \"next\": \"Next\",\n    \"previous\": \"Previous\",\n    \"close\": \"Close\",\n    \"search\": \"Search\",\n    \"reset\": \"Reset\",\n    \"submit\": \"Submit\",\n    \"success\": \"Success\",\n    \"error\": \"Error\",\n    \"warning\": \"Warning\",\n    \"info\": \"Info\",\n    \"yes\": \"Yes\",\n    \"no\": \"No\",\n    \"all\": \"All\",\n    \"none\": \"None\",\n    \"selected\": \"Selected\",\n    \"selectAll\": \"Select All\",\n    \"deselectAll\": \"Deselect All\",\n    \"noData\": \"No data\",\n    \"unknownError\": \"Unknown error\",\n    \"refresh\": \"Refresh\",\n    \"upload\": \"Upload\",\n    \"uploading\": \"Uploading...\",\n    \"download\": \"Download\",\n    \"downloading\": \"Packaging...\",\n    \"clearSelection\": \"Clear Selection\",\n    \"generating\": \"Generating...\",\n    \"completed\": \"Completed\",\n    \"failed\": \"Failed\",\n    \"pending\": \"Pending...\",\n    \"add\": \"Add\",\n    \"runInBackground\": \"Run in Background\",\n    \"confirmDeleteTitle\": \"Confirm Delete\",\n    \"page\": \"Page {{num}}\",\n    \"saving\": \"Saving...\"\n  }\n}\n"
  },
  {
    "path": "frontend/src/locales/zh.json",
    "content": "{\n  \"_note\": \"此文件仅包含真正通用的翻译（按钮、状态词等）。所有组件特有的翻译应使用 useT hook 自包含在组件文件内。请勿在此添加组件特有的翻译。\",\n  \"_principle\": \"如果一个翻译只被一个组件使用，它就应该放在那个组件里，而不是这里。\",\n  \"common\": {\n    \"save\": \"保存\",\n    \"cancel\": \"取消\",\n    \"confirm\": \"确认\",\n    \"delete\": \"删除\",\n    \"edit\": \"编辑\",\n    \"loading\": \"加载中...\",\n    \"retry\": \"重试\",\n    \"back\": \"返回\",\n    \"next\": \"下一步\",\n    \"previous\": \"上一步\",\n    \"close\": \"关闭\",\n    \"search\": \"搜索\",\n    \"reset\": \"重置\",\n    \"submit\": \"提交\",\n    \"success\": \"成功\",\n    \"error\": \"错误\",\n    \"warning\": \"警告\",\n    \"info\": \"提示\",\n    \"yes\": \"是\",\n    \"no\": \"否\",\n    \"all\": \"全部\",\n    \"none\": \"无\",\n    \"selected\": \"已选择\",\n    \"selectAll\": \"全选\",\n    \"deselectAll\": \"取消全选\",\n    \"noData\": \"暂无数据\",\n    \"unknownError\": \"未知错误\",\n    \"refresh\": \"刷新\",\n    \"upload\": \"上传\",\n    \"uploading\": \"上传中...\",\n    \"download\": \"下载\",\n    \"downloading\": \"打包中...\",\n    \"clearSelection\": \"清空选择\",\n    \"generating\": \"生成中...\",\n    \"completed\": \"已完成\",\n    \"failed\": \"失败\",\n    \"pending\": \"等待中...\",\n    \"add\": \"添加\",\n    \"runInBackground\": \"在后台执行\",\n    \"confirmDeleteTitle\": \"确认删除\",\n    \"page\": \"第 {{num}} 页\",\n    \"saving\": \"保存中...\"\n  }\n}\n"
  },
  {
    "path": "frontend/src/main.tsx",
    "content": "import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport App from './App.tsx'\nimport './i18n'\nimport './index.css'\n\nReactDOM.createRoot(document.getElementById('root')!).render(\n  <React.StrictMode>\n    <App />\n  </React.StrictMode>,\n)\n\n"
  },
  {
    "path": "frontend/src/pages/DetailEditor.tsx",
    "content": "import React, { useEffect, useCallback, useState, useRef } from 'react';\nimport { useNavigate, useParams, useLocation } from 'react-router-dom';\nimport { ArrowLeft, ArrowRight, FileText, Sparkles, Download, Upload, ChevronDown, Settings2, X, Plus, HelpCircle, ImageIcon } from 'lucide-react';\nimport { useT } from '@/hooks/useT';\nimport { MarkdownTextarea, type MarkdownTextareaRef } from '@/components/shared/MarkdownTextarea';\nimport PresetCapsules from '@/components/shared/PresetCapsules';\nimport { useImagePaste } from '@/hooks/useImagePaste';\nimport {\n  DndContext, closestCenter, PointerSensor, useSensor, useSensors,\n  type DragEndEvent,\n} from '@dnd-kit/core';\nimport {\n  arrayMove, SortableContext, useSortable, rectSortingStrategy,\n} from '@dnd-kit/sortable';\nimport { CSS } from '@dnd-kit/utilities';\n\n// 组件内翻译\nconst detailI18n = {\n  zh: {\n    home: { title: '蕉幻' },\n    detail: {\n      title: \"编辑页面描述\", pageCount: \"共 {{count}} 页\", generateImages: \"生成图片\",\n      generating: \"生成中...\", page: \"第 {{num}} 页\", titleLabel: \"标题\",\n      description: \"描述\", batchGenerate: \"批量生成描述\", export: \"导出描述\", exportFull: \"导出大纲和描述\", import: \"导入\", importExport: \"导入/导出\",\n      pagesCompleted: \"页已完成\", noPages: \"还没有页面\",\n      noPagesHint: \"请先返回大纲编辑页添加页面\", backToOutline: \"返回大纲编辑\",\n      aiPlaceholder: \"例如：让描述更详细、删除第2页的某个要点、强调XXX的重要性... · Ctrl+Enter提交\",\n      aiPlaceholderShort: \"例如：让描述更详细... · Ctrl+Enter\",\n      renovationProcessing: \"正在解析页面内容...\",\n      renovationProgress: \"{{completed}}/{{total}} 页\",\n      renovationFailed: \"PDF 解析失败，请返回重试\",\n      renovationPollFailed: \"与服务器通信失败，请检查网络后刷新页面重试\",\n      disabledNextTip: \"还有 {{count}} 页缺少描述，请先完成所有页面的描述\",\n      detailLevel: { label: \"详细程度\", concise: \"精简\", default: \"默认\", detailed: \"详细\" },\n      descSettings: \"描述设置\",\n      generationMode: \"生成模式\",\n      generationModeHint: \"流式：AI 从第一页开始逐页输出，速度慢但效果更好。并行：AI 根据大纲并行生成每页描述，速度快但可能不够细致。\",\n      streaming: \"流式\",\n      parallel: \"并行\",\n      extraFields: \"额外字段\",\n      extraFieldsHint: \"启用后，AI 生成描述时会带上这些字段。可通过「描述生成要求」进一步约束字段输出的内容。点击胶囊启用/禁用，拖拽调整顺序。\",\n      imagePromptOn: \"该字段会影响生成的图片效果，点击可关闭\",\n      imagePromptOff: \"该字段不会影响生成的图片，点击可开启\",\n      addField: \"添加字段\",\n      descRequirements: \"描述生成要求\",\n      descRequirementsPlaceholder: \"例如：每页描述控制在100字以内、多使用数据和案例、强调关键指标...\",\n      messages: {\n        confirmRegenerate: \"部分页面已有描述，重新生成将覆盖，确定继续吗？\",\n        confirmRegenerateTitle: \"确认重新生成\",\n        confirmRegeneratePage: \"该页面已有描述，重新生成将覆盖现有内容，确定继续吗？\",\n        confirmRenovationRegenerate: \"您现在是 PPT 翻新模式，重新生成会依照原 PPT 相同页码页面，重新解析并生成该页的大纲和描述，覆盖已有内容。确定要继续吗？\",\n        confirmRenovationRegenerateTitle: \"重新解析此页\",\n        refineSuccess: \"页面描述修改成功\", refineFailed: \"修改失败，请稍后重试\",\n        exportSuccess: \"导出成功\", importSuccess: \"导入成功\", importFailed: \"导入失败，请检查文件格式\", importEmpty: \"文件中未找到有效页面\",\n        loadingProject: \"加载项目中...\"\n      }\n    }\n  },\n  en: {\n    home: { title: 'Banana Slides' },\n    detail: {\n      title: \"Edit Descriptions\", pageCount: \"{{count}} pages\", generateImages: \"Generate Images\",\n      generating: \"Generating...\", page: \"Page {{num}}\", titleLabel: \"Title\",\n      description: \"Description\", batchGenerate: \"Batch Generate Descriptions\", export: \"Export Descriptions\", exportFull: \"Export Outline & Descriptions\", import: \"Import\", importExport: \"Import/Export\",\n      pagesCompleted: \"pages completed\", noPages: \"No pages yet\",\n      noPagesHint: \"Please go back to outline editor to add pages first\", backToOutline: \"Back to Outline Editor\",\n      aiPlaceholder: \"e.g., Make descriptions more detailed, remove a point from page 2, emphasize XXX... · Ctrl+Enter to submit\",\n      aiPlaceholderShort: \"e.g., Make descriptions more detailed... · Ctrl+Enter\",\n      renovationProcessing: \"Parsing page content...\",\n      renovationProgress: \"{{completed}}/{{total}} pages\",\n      renovationFailed: \"PDF parsing failed, please go back and retry\",\n      renovationPollFailed: \"Lost connection to server. Please check your network and refresh the page.\",\n      disabledNextTip: \"{{count}} page(s) are missing descriptions. Please complete all page descriptions first\",\n      detailLevel: { label: \"Detail Level\", concise: \"Concise\", default: \"Default\", detailed: \"Detailed\" },\n      descSettings: \"Description Settings\",\n      generationMode: \"Generation Mode\",\n      generationModeHint: \"Streaming: AI outputs pages sequentially from first to last, slower but better quality. Parallel: AI generates each page independently based on the outline, faster but may be less detailed.\",\n      streaming: \"Streaming\",\n      parallel: \"Parallel\",\n      extraFields: \"Extra Fields\",\n      extraFieldsHint: \"When enabled, AI will include these fields when generating descriptions. Use \\\"Generation Requirements\\\" to further constrain field output. Click pills to enable/disable, drag to reorder.\",\n      imagePromptOn: \"This field affects generated image results, click to disable\",\n      imagePromptOff: \"This field does not affect generated images, click to enable\",\n      addField: \"Add Field\",\n      descRequirements: \"Generation Requirements\",\n      descRequirementsPlaceholder: \"e.g., Keep each page under 100 words, use data and examples, highlight key metrics...\",\n      messages: {\n        generateSuccess: \"Generated successfully\", generateFailed: \"Generation failed\",\n        confirmRegenerate: \"Some pages already have descriptions. Regenerating will overwrite them. Continue?\",\n        confirmRegenerateTitle: \"Confirm Regenerate\",\n        confirmRegeneratePage: \"This page already has a description. Regenerating will overwrite it. Continue?\",\n        confirmRenovationRegenerate: \"You are in PPT renovation mode. Regenerating will re-parse the original PDF page and regenerate the outline and description, overwriting existing content. Continue?\",\n        confirmRenovationRegenerateTitle: \"Re-parse This Page\",\n        refineSuccess: \"Descriptions modified successfully\", refineFailed: \"Modification failed, please try again\",\n        exportSuccess: \"Export successful\", importSuccess: \"Import successful\", importFailed: \"Import failed, please check file format\", importEmpty: \"No valid pages found in file\",\n        loadingProject: \"Loading project...\"\n      }\n    }\n  }\n};\nimport { Button, Loading, useToast, useConfirm, AiRefineInput, FilePreviewModal, ReferenceFileList } from '@/components/shared';\nimport { DescriptionCard } from '@/components/preview/DescriptionCard';\nimport { useProjectStore } from '@/store/useProjectStore';\nimport { refineDescriptions, getTaskStatus, addPage, updateProject, getSettings, updateSettings } from '@/api/endpoints';\nimport { exportProjectToMarkdown, parseMarkdownPages } from '@/utils/projectUtils';\n\n// 详细程度图标 — 暂时屏蔽，效果不够理想\n// const DETAIL_LEVEL_LINES: Record<string, number[]> = {\n//   concise:  [5, 8],\n//   default:  [4, 7, 10],\n//   detailed: [3.5, 5.5, 7.5, 9.5, 11.5],\n// };\n// const DetailLevelIcon: React.FC<{ level: string }> = ({ level }) => ( ... );\n\nconst PRESET_EXTRA_FIELDS = new Set(['视觉元素', '视觉焦点', '排版布局', '演讲者备注']);\n\n// 可拖拽排序的额外字段胶囊\nconst SortableFieldPill: React.FC<{\n  name: string;\n  active: boolean;\n  removable?: boolean;\n  inImagePrompt?: boolean;\n  imagePromptTooltip?: string;\n  onToggle: () => void;\n  onRemove: () => void;\n  onToggleImagePrompt?: () => void;\n}> = ({ name, active, onToggle, onRemove, removable = true, inImagePrompt, imagePromptTooltip, onToggleImagePrompt }) => {\n  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: name });\n  const style: React.CSSProperties = {\n    transform: CSS.Translate.toString(transform),\n    transition: isDragging ? undefined : transition,\n    opacity: isDragging ? 0.5 : undefined,\n    zIndex: isDragging ? 10 : undefined,\n  };\n  return (\n    <button\n      ref={setNodeRef}\n      style={style}\n      {...attributes}\n      {...listeners}\n      type=\"button\"\n      className={`group inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-full border cursor-grab active:cursor-grabbing ${\n        isDragging ? '' : 'transition-colors duration-150 '\n      }${\n        active\n          ? 'bg-banana-50 dark:bg-banana-900/20 border-banana-300 dark:border-banana-700 text-banana-700 dark:text-banana-400'\n          : 'bg-gray-50 dark:bg-background-hover border-gray-200 dark:border-border-primary text-gray-400 dark:text-foreground-tertiary line-through'\n      }`}\n      onClick={onToggle}\n    >\n      {name}\n      {active && onToggleImagePrompt && (\n        <span\n          role=\"button\"\n          className={`relative group/img ml-0.5 transition-colors ${inImagePrompt ? 'text-banana-500' : 'text-gray-300 dark:text-gray-600'}`}\n          onClick={e => { e.stopPropagation(); onToggleImagePrompt(); }}\n        >\n          <ImageIcon size={10} />\n          {imagePromptTooltip && (\n            <span className=\"absolute left-1/2 -translate-x-1/2 bottom-full mb-1.5 w-40 px-2 py-1 text-[10px] leading-snug text-gray-600 dark:text-foreground-secondary bg-white dark:bg-background-primary border border-gray-200 dark:border-border-primary rounded-md shadow-md opacity-0 pointer-events-none group-hover/img:opacity-100 transition-opacity z-50\">\n              {imagePromptTooltip}\n            </span>\n          )}\n        </span>\n      )}\n      {!active && removable && (\n        <span\n          role=\"button\"\n          className=\"opacity-0 group-hover:opacity-100 ml-0.5 text-gray-400 hover:text-red-500 transition-all\"\n          onClick={e => { e.stopPropagation(); onRemove(); }}\n        >\n          <X size={10} />\n        </span>\n      )}\n    </button>\n  );\n};\n\nexport const DetailEditor: React.FC = () => {\n  const navigate = useNavigate();\n  const location = useLocation();\n  const t = useT(detailI18n);\n  const { projectId } = useParams<{ projectId: string }>();\n  const fromHistory = (location.state as any)?.from === 'history';\n  const importFileRef = useRef<HTMLInputElement>(null);\n  const {\n    currentProject,\n    syncProject,\n    updatePageLocal,\n    generateDescriptions,\n    generatePageDescription,\n    regenerateRenovationPage,\n  } = useProjectStore();\n  const { show, ToastContainer } = useToast();\n  const { confirm, ConfirmDialog } = useConfirm();\n  const [isAiRefining, setIsAiRefining] = React.useState(false);\n  const [previewFileId, setPreviewFileId] = useState<string | null>(null);\n  const [isRenovationProcessing, setIsRenovationProcessing] = useState(false);\n  const [renovationProgress, setRenovationProgress] = useState<{ total: number; completed: number } | null>(null);\n  const [detailLevel, setDetailLevel] = useState<string>('default');\n  const [generationMode, setGenerationMode] = useState<'streaming' | 'parallel'>('streaming');\n  const [extraFieldNames, setExtraFieldNames] = useState<string[]>(['视觉元素', '视觉焦点', '排版布局', '演讲者备注']);\n  const [imagePromptFields, setImagePromptFields] = useState<string[]>(['视觉元素', '视觉焦点', '排版布局']);\n  // 可选字段池（localStorage 持久化，包含所有已知字段名）\n  const [availableFields, setAvailableFields] = useState<string[]>(() => {\n    try {\n      const stored = localStorage.getItem('banana-available-extra-fields');\n      return stored ? JSON.parse(stored) : ['视觉元素', '视觉焦点', '排版布局', '演讲者备注'];\n    } catch { return ['视觉元素', '视觉焦点', '排版布局', '演讲者备注']; }\n  });\n  const [settingsOpen, setSettingsOpen] = useState(false);\n  const settingsRef = useRef<HTMLDivElement>(null);\n  const [newFieldName, setNewFieldName] = useState('');\n  const [fileMenuOpen, setFileMenuOpen] = useState(false);\n  const fileMenuRef = useRef<HTMLDivElement>(null);\n  const settingsSaveTimerRef = useRef<ReturnType<typeof setTimeout>>();\n\n  // Load settings from DB on mount\n  useEffect(() => {\n    (async () => {\n      try {\n        const res = await getSettings();\n        const s = res.data;\n        if (!s) return;\n        setDetailLevel('default');\n        // detail level from sessionStorage (backwards compat, then from DB if we add it later)\n        const storedLevel = sessionStorage.getItem('banana-detail-level');\n        if (storedLevel) setDetailLevel(storedLevel);\n        setGenerationMode(s.description_generation_mode || 'streaming');\n        const activeFields = s.description_extra_fields || ['视觉元素', '视觉焦点', '排版布局', '演讲者备注'];\n        setExtraFieldNames(activeFields);\n        if (s.image_prompt_extra_fields) setImagePromptFields(s.image_prompt_extra_fields);\n        // 合并活跃字段到可选池\n        setAvailableFields(prev => {\n          const merged = [...new Set([...prev, ...activeFields])];\n          localStorage.setItem('banana-available-extra-fields', JSON.stringify(merged));\n          return merged;\n        });\n        // Cache settings in sessionStorage for store to read\n        sessionStorage.setItem('banana-settings', JSON.stringify(s));\n      } catch { /* ignore */ }\n    })();\n  }, []);\n\n  // Debounced save settings to DB\n  const saveSettingsDebounced = useCallback((updates: Record<string, unknown>) => {\n    if (settingsSaveTimerRef.current) clearTimeout(settingsSaveTimerRef.current);\n    settingsSaveTimerRef.current = setTimeout(async () => {\n      try {\n        const res = await updateSettings(updates as any);\n        if (res.data) {\n          sessionStorage.setItem('banana-settings', JSON.stringify(res.data));\n        }\n      } catch (e) {\n        console.error('Failed to save settings:', e);\n      }\n    }, 800);\n  }, []);\n\n  // 额外字段拖拽排序\n  const fieldSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));\n  const handleFieldDragEnd = useCallback((event: DragEndEvent) => {\n    const { active, over } = event;\n    if (!over || active.id === over.id) return;\n    const oldIdx = availableFields.indexOf(active.id as string);\n    const newIdx = availableFields.indexOf(over.id as string);\n    if (oldIdx === -1 || newIdx === -1) return;\n    const nextPool = arrayMove(availableFields, oldIdx, newIdx);\n    setAvailableFields(nextPool);\n    localStorage.setItem('banana-available-extra-fields', JSON.stringify(nextPool));\n    // 激活字段按新池顺序重排\n    const activeSet = new Set(extraFieldNames);\n    const nextActive = nextPool.filter(f => activeSet.has(f));\n    setExtraFieldNames(nextActive);\n    saveSettingsDebounced({ description_extra_fields: nextActive });\n  }, [availableFields, extraFieldNames, saveSettingsDebounced]);\n\n  const [descRequirements, setDescRequirements] = useState('');\n  const [isDescReqDirty, setIsDescReqDirty] = useState(false);\n  const reqTextareaRef = useRef<MarkdownTextareaRef>(null);\n  const [isDescReqOpen, setIsDescReqOpen] = useState(\n    () => localStorage.getItem('descReqOpen') !== 'false'\n  );\n\n  // 点击外部关闭下拉\n  useEffect(() => {\n    const handleClickOutside = (e: MouseEvent) => {\n      if (settingsRef.current && !settingsRef.current.contains(e.target as Node)) {\n        setSettingsOpen(false);\n      }\n      if (fileMenuRef.current && !fileMenuRef.current.contains(e.target as Node)) {\n        setFileMenuOpen(false);\n      }\n    };\n    if (settingsOpen || fileMenuOpen) {\n      document.addEventListener('mousedown', handleClickOutside);\n      return () => document.removeEventListener('mousedown', handleClickOutside);\n    }\n  }, [settingsOpen, fileMenuOpen]);\n\n  // PPT 翻新：异步任务轮询\n  useEffect(() => {\n    if (!projectId) return;\n    const taskId = localStorage.getItem('renovationTaskId');\n    if (!taskId) return;\n\n    setIsRenovationProcessing(true);\n    let cancelled = false;\n    let pollFailCount = 0;\n\n    const poll = async () => {\n      try {\n        const response = await getTaskStatus(projectId, taskId);\n        if (cancelled) return;\n        const task = response.data;\n        if (!task) return;\n        pollFailCount = 0; // reset on success\n\n        if (task.progress) {\n          setRenovationProgress({\n            total: task.progress.total || 0,\n            completed: task.progress.completed || 0,\n          });\n        }\n\n        // Sync project to get latest page data (incremental updates)\n        await syncProject(projectId);\n\n        if (task.status === 'COMPLETED') {\n          localStorage.removeItem('renovationTaskId');\n          setIsRenovationProcessing(false);\n          setRenovationProgress(null);\n          await syncProject(projectId);\n          return;\n        }\n\n        if (task.status === 'FAILED') {\n          localStorage.removeItem('renovationTaskId');\n          setIsRenovationProcessing(false);\n          setRenovationProgress(null);\n          show({ message: task.error_message || t('detail.renovationFailed'), type: 'error' });\n          return;\n        }\n\n        // Still processing — poll again\n        setTimeout(poll, 2000);\n      } catch (err) {\n        if (cancelled) return;\n        pollFailCount++;\n        console.error('Renovation task poll error:', err);\n        if (pollFailCount >= 5) {\n          localStorage.removeItem('renovationTaskId');\n          setIsRenovationProcessing(false);\n          setRenovationProgress(null);\n          show({ message: t('detail.renovationPollFailed'), type: 'error' });\n          return;\n        }\n        setTimeout(poll, 3000);\n      }\n    };\n\n    poll();\n    return () => { cancelled = true; };\n  }, [projectId]);\n\n  // 加载项目数据\n  useEffect(() => {\n    if (projectId && (!currentProject || currentProject.id !== projectId)) {\n      // 直接使用 projectId 同步项目数据\n      syncProject(projectId);\n    } else if (projectId && currentProject && currentProject.id === projectId) {\n      // 如果项目已存在，也同步一次以确保数据是最新的（特别是从描述生成后）\n      // 但只在首次加载时同步，避免频繁请求\n      const shouldSync = !currentProject.pages.some(p => p.description_content);\n      if (shouldSync) {\n        syncProject(projectId);\n      }\n    }\n  }, [projectId, currentProject?.id]); // 只在 projectId 或项目ID变化时更新\n\n  // 同步描述生成要求\n  useEffect(() => {\n    if (currentProject) {\n      setDescRequirements(currentProject.description_requirements || '');\n      setIsDescReqDirty(false);\n    }\n  }, [currentProject?.id]);\n\n  // Debounced auto-save for description requirements\n  useEffect(() => {\n    if (!isDescReqDirty || !projectId) return;\n    const timer = setTimeout(async () => {\n      try {\n        await updateProject(projectId, { description_requirements: descRequirements });\n        setIsDescReqDirty(false);\n      } catch (e) {\n        console.error('保存描述要求失败:', e);\n      }\n    }, 1000);\n    return () => clearTimeout(timer);\n  }, [descRequirements, isDescReqDirty, projectId]);\n\n  const insertAtReqCursor = useCallback((markdown: string) => {\n    reqTextareaRef.current?.insertAtCursor(markdown);\n  }, []);\n\n  const { handlePaste: handleReqImagePaste, handleFiles: handleReqImageFiles } = useImagePaste({\n    projectId: projectId || null,\n    setContent: (updater) => {\n      setDescRequirements(updater);\n      setIsDescReqDirty(true);\n    },\n    showToast: show,\n    insertAtCursor: insertAtReqCursor,\n  });\n\n  const handleGenerateAll = async () => {\n    const hasDescriptions = currentProject?.pages.some(\n      (p) => p.description_content\n    );\n    \n    const executeGenerate = async () => {\n      await generateDescriptions(detailLevel);\n    };\n    \n    if (hasDescriptions) {\n      confirm(\n        t('detail.messages.confirmRegenerate'),\n        executeGenerate,\n        { title: t('detail.messages.confirmRegenerateTitle'), variant: 'warning' }\n      );\n    } else {\n      await executeGenerate();\n    }\n  };\n\n  const handleRegeneratePage = async (pageId: string) => {\n    if (!currentProject) return;\n\n    const page = currentProject.pages.find((p) => p.id === pageId);\n    if (!page) return;\n\n    // 判断是否是 PPT 翻新模式\n    const isRenovation = currentProject.creation_type === 'ppt_renovation';\n\n    const executeRegenerate = async () => {\n      try {\n        if (isRenovation) {\n          await regenerateRenovationPage(pageId);\n        } else {\n          await generatePageDescription(pageId, detailLevel);\n        }\n        show({ message: t('detail.messages.generateSuccess'), type: 'success' });\n      } catch (error: any) {\n        show({\n          message: `${t('detail.messages.generateFailed')}: ${error.message || t('common.unknownError')}`,\n          type: 'error'\n        });\n      }\n    };\n\n    // PPT 翻新模式 或 已有描述时，需要确认\n    if (isRenovation) {\n      confirm(\n        t('detail.messages.confirmRenovationRegenerate'),\n        executeRegenerate,\n        { title: t('detail.messages.confirmRenovationRegenerateTitle'), variant: 'warning' }\n      );\n    } else if (page.description_content) {\n      confirm(\n        t('detail.messages.confirmRegeneratePage'),\n        executeRegenerate,\n        { title: t('detail.messages.confirmRegenerateTitle'), variant: 'warning' }\n      );\n    } else {\n      await executeRegenerate();\n    }\n  };\n\n  // Stable ref for handleRegeneratePage to avoid stale closures in memoized DescriptionCard\n  const handleRegeneratePageRef = useRef(handleRegeneratePage);\n  handleRegeneratePageRef.current = handleRegeneratePage;\n  const stableHandleRegeneratePage = useCallback((pageId: string) => {\n    handleRegeneratePageRef.current(pageId);\n  }, []);\n\n  const handleAiRefineDescriptions = useCallback(async (requirement: string, previousRequirements: string[]) => {\n    if (!currentProject || !projectId) return;\n    \n    try {\n      const response = await refineDescriptions(projectId, requirement, previousRequirements);\n      await syncProject(projectId);\n      show({ \n        message: response.data?.message || t('detail.messages.refineSuccess'), \n        type: 'success' \n      });\n    } catch (error: any) {\n      console.error('修改页面描述失败:', error);\n      const errorMessage = error?.response?.data?.error?.message \n        || error?.message \n        || t('detail.messages.refineFailed');\n      show({ message: errorMessage, type: 'error' });\n      throw error; // 抛出错误让组件知道失败了\n    }\n  }, [currentProject, projectId, syncProject, show, t]);\n\n  // 导出页面描述为 Markdown 文件\n  const handleExportDescriptions = useCallback(() => {\n    if (!currentProject) return;\n    exportProjectToMarkdown(currentProject, { outline: false, description: true });\n    show({ message: t('detail.messages.exportSuccess'), type: 'success' });\n  }, [currentProject, show, t]);\n\n  // 导出大纲+描述\n  const handleExportFull = useCallback(() => {\n    if (!currentProject) return;\n    exportProjectToMarkdown(currentProject);\n    show({ message: t('detail.messages.exportSuccess'), type: 'success' });\n  }, [currentProject, show, t]);\n\n  // 导入描述 Markdown 文件（追加新页面）\n  const handleImportDescriptions = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    if (importFileRef.current) importFileRef.current.value = '';\n    if (!file || !currentProject || !projectId) return;\n    try {\n      const text = await file.text();\n      const parsed = parseMarkdownPages(text);\n      if (parsed.length === 0) {\n        show({ message: t('detail.messages.importEmpty'), type: 'error' });\n        return;\n      }\n      const startIndex = currentProject.pages.reduce((max, p) => Math.max(max, (p.order_index ?? 0) + 1), 0);\n      await Promise.all(parsed.map(({ title, points, text: desc, part, extra_fields }, i) =>\n        addPage(projectId, {\n          outline_content: { title, points },\n          description_content: desc ? { text: desc, ...(extra_fields ? { extra_fields } : {}) } : undefined,\n          part,\n          order_index: startIndex + i,\n        })\n      ));\n      await syncProject(projectId);\n      show({ message: t('detail.messages.importSuccess'), type: 'success' });\n    } catch {\n      show({ message: t('detail.messages.importFailed'), type: 'error' });\n    }\n  }, [currentProject, projectId, syncProject, show, t]);\n\n  if (!currentProject) {\n    return <Loading fullscreen message={t('detail.messages.loadingProject')} />;\n  }\n\n  const hasAllDescriptions = currentProject.pages.every(\n    (p) => p.description_content\n  );\n  const missingDescCount = currentProject.pages.filter(p => !p.description_content).length;\n\n  return (\n    <div className=\"min-h-screen bg-gray-50 dark:bg-background-primary flex flex-col\">\n      {/* 顶栏 */}\n      <header className=\"bg-white dark:bg-background-secondary shadow-sm dark:shadow-background-primary/30 border-b border-gray-200 dark:border-border-primary px-3 md:px-6 py-2 md:py-3 flex-shrink-0\">\n        <div className=\"flex items-center justify-between gap-2 md:gap-4\">\n          {/* 左侧：Logo 和标题 */}\n          <div className=\"flex items-center gap-2 md:gap-4 flex-shrink-0\">\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              icon={<ArrowLeft size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={() => {\n                if (fromHistory) {\n                  navigate('/history');\n                } else {\n                  navigate(`/project/${projectId}/outline`);\n                }\n              }}\n              disabled={isRenovationProcessing}\n              className=\"flex-shrink-0\"\n            >\n              <span className=\"hidden sm:inline\">{t('common.back')}</span>\n            </Button>\n            <div className=\"flex items-center gap-1.5 md:gap-2\">\n              <span className=\"text-xl md:text-2xl\">🍌</span>\n              <span className=\"text-base md:text-xl font-bold\">{t('home.title')}</span>\n            </div>\n            <span className=\"text-gray-400 hidden lg:inline\">|</span>\n            <span className=\"text-sm md:text-lg font-semibold hidden lg:inline\">{t('detail.title')}</span>\n          </div>\n          \n          {/* 中间：AI 修改输入框 */}\n          <div className=\"flex-1 max-w-xl mx-auto hidden md:block md:-translate-x-3 pr-10\">\n            <AiRefineInput\n              title=\"\"\n              placeholder={t('detail.aiPlaceholder')}\n              onSubmit={handleAiRefineDescriptions}\n              disabled={isRenovationProcessing}\n              className=\"!p-0 !bg-transparent !border-0\"\n              onStatusChange={setIsAiRefining}\n            />\n          </div>\n\n          {/* 右侧：操作按钮 */}\n          <div className=\"flex items-center gap-1.5 md:gap-2 flex-shrink-0\">\n            <Button\n              variant=\"secondary\"\n              size=\"sm\"\n              icon={<ArrowLeft size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={() => navigate(`/project/${projectId}/outline`)}\n              disabled={isRenovationProcessing}\n              className=\"hidden md:inline-flex\"\n            >\n              <span className=\"hidden lg:inline\">{t('common.previous')}</span>\n            </Button>\n            <Button\n              variant=\"primary\"\n              size=\"sm\"\n              icon={<ArrowRight size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={() => navigate(`/project/${projectId}/preview`)}\n              disabled={!hasAllDescriptions || isRenovationProcessing}\n              title={!hasAllDescriptions && !isRenovationProcessing ? t('detail.disabledNextTip', { count: missingDescCount }) : undefined}\n              className=\"text-xs md:text-sm\"\n            >\n              <span className=\"hidden sm:inline\">{t('detail.generateImages')}</span>\n            </Button>\n          </div>\n        </div>\n        \n        {/* 移动端：AI 输入框 */}\n        <div className=\"mt-2 md:hidden\">\n            <AiRefineInput\n            title=\"\"\n            placeholder={t('detail.aiPlaceholderShort')}\n            onSubmit={handleAiRefineDescriptions}\n            disabled={isRenovationProcessing}\n            className=\"!p-0 !bg-transparent !border-0\"\n            onStatusChange={setIsAiRefining}\n          />\n        </div>\n      </header>\n\n      {/* 操作栏 */}\n      <div className=\"bg-white dark:bg-background-secondary border-b border-gray-200 dark:border-border-primary px-3 md:px-6 py-3 md:py-4 flex-shrink-0\">\n        {isRenovationProcessing ? (\n          <div className=\"max-w-xl mx-auto\">\n            <div className=\"flex items-center justify-between mb-1.5\">\n              <span className=\"text-sm font-medium text-gray-700 dark:text-foreground-secondary\">\n                {t('detail.renovationProcessing')}\n              </span>\n              {renovationProgress && renovationProgress.total > 0 && (\n                <span className=\"text-sm font-medium text-banana-600 dark:text-banana\">\n                  {t('detail.renovationProgress', { completed: String(renovationProgress.completed), total: String(renovationProgress.total) })}\n                </span>\n              )}\n            </div>\n            <div className=\"w-full h-2.5 bg-gray-200 dark:bg-background-hover rounded-full overflow-hidden\">\n              <div\n                className=\"h-full bg-gradient-to-r from-banana-400 to-banana-500 rounded-full transition-all duration-500 ease-out\"\n                style={{\n                  width: renovationProgress && renovationProgress.total > 0\n                    ? `${Math.round((renovationProgress.completed / renovationProgress.total) * 100)}%`\n                    : '0%',\n                  animation: !renovationProgress || renovationProgress.total === 0\n                    ? 'pulse 1.5s ease-in-out infinite'\n                    : undefined,\n                  minWidth: !renovationProgress || renovationProgress.completed === 0 ? '10%' : undefined,\n                }}\n              />\n            </div>\n          </div>\n        ) : (\n        <div className=\"flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 sm:gap-3\">\n          <div className=\"flex items-center gap-2 sm:gap-3 flex-1\">\n            <Button\n              variant=\"primary\"\n              icon={<Sparkles size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={handleGenerateAll}\n              className=\"flex-1 sm:flex-initial text-sm md:text-base\"\n            >\n              {t('detail.batchGenerate')}\n            </Button>\n\n            {/* 描述设置面板 */}\n            <div className=\"relative\" ref={settingsRef}>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={() => setSettingsOpen(!settingsOpen)}\n                icon={<Settings2 size={16} />}\n                title={t('detail.descSettings')}\n              />\n              {settingsOpen && (\n                <div className=\"absolute top-full left-0 mt-1 z-50 w-72 rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary shadow-lg dark:shadow-none p-4 space-y-4\">\n                  {/* 生成模式 */}\n                  <div>\n                    <label className=\"flex items-center gap-1 text-xs font-medium text-gray-600 dark:text-foreground-tertiary mb-1.5\">\n                      {t('detail.generationMode')}\n                      <span className=\"relative group\">\n                        <HelpCircle size={12} className=\"text-gray-400 cursor-help\" />\n                        <span className=\"absolute left-1/2 -translate-x-1/2 bottom-full mb-1.5 w-52 px-2.5 py-1.5 text-[11px] leading-relaxed text-gray-600 dark:text-foreground-secondary bg-white dark:bg-background-primary border border-gray-200 dark:border-border-primary rounded-md shadow-md opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity z-50\">{t('detail.generationModeHint')}</span>\n                      </span>\n                    </label>\n                    <div className=\"flex gap-1\">\n                      {(['streaming', 'parallel'] as const).map(mode => (\n                        <button\n                          key={mode}\n                          type=\"button\"\n                          className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${\n                            generationMode === mode\n                              ? 'bg-banana-500 text-white'\n                              : 'bg-gray-100 dark:bg-background-hover text-gray-600 dark:text-foreground-tertiary hover:bg-gray-200 dark:hover:bg-background-primary'\n                          }`}\n                          onClick={() => {\n                            setGenerationMode(mode);\n                            saveSettingsDebounced({ description_generation_mode: mode });\n                          }}\n                        >\n                          {t(`detail.${mode}`)}\n                        </button>\n                      ))}\n                    </div>\n                  </div>\n\n                  {/* 详细程度 — 暂时屏蔽，效果不够理想，始终使用默认值 */}\n\n                  {/* 额外字段 */}\n                  <div>\n                    <label className=\"flex items-center gap-1 text-xs font-medium text-gray-600 dark:text-foreground-tertiary mb-1.5\">\n                      {t('detail.extraFields')}\n                      <span className=\"relative group\">\n                        <HelpCircle size={12} className=\"text-gray-400 cursor-help\" />\n                        <span className=\"absolute left-1/2 -translate-x-1/2 bottom-full mb-1.5 w-52 px-2.5 py-1.5 text-[11px] leading-relaxed text-gray-600 dark:text-foreground-secondary bg-white dark:bg-background-primary border border-gray-200 dark:border-border-primary rounded-md shadow-md opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity z-50\">{t('detail.extraFieldsHint')}</span>\n                      </span>\n                    </label>\n                    <DndContext sensors={fieldSensors} collisionDetection={closestCenter} onDragEnd={handleFieldDragEnd}>\n                      <SortableContext items={availableFields} strategy={rectSortingStrategy}>\n                        <div className=\"flex flex-wrap gap-1.5 mb-2\">\n                          {availableFields.map(name => {\n                            const active = extraFieldNames.includes(name);\n                            return (\n                              <SortableFieldPill\n                                key={name}\n                                name={name}\n                                active={active}\n                                removable={!PRESET_EXTRA_FIELDS.has(name)}\n                                onToggle={() => {\n                                  const next = active\n                                    ? extraFieldNames.filter(f => f !== name)\n                                    : [...extraFieldNames, name];\n                                  setExtraFieldNames(next);\n                                  saveSettingsDebounced({ description_extra_fields: next.length > 0 ? next : ['视觉元素', '视觉焦点', '排版布局', '演讲者备注'] });\n                                }}\n                                inImagePrompt={imagePromptFields.includes(name)}\n                                imagePromptTooltip={imagePromptFields.includes(name) ? t('detail.imagePromptOn') : t('detail.imagePromptOff')}\n                                onToggleImagePrompt={() => {\n                                  const next = imagePromptFields.includes(name)\n                                    ? imagePromptFields.filter(f => f !== name)\n                                    : [...imagePromptFields, name];\n                                  setImagePromptFields(next);\n                                  saveSettingsDebounced({ image_prompt_extra_fields: next });\n                                }}\n                                onRemove={() => {\n                                  const nextPool = availableFields.filter(f => f !== name);\n                                  setAvailableFields(nextPool);\n                                  localStorage.setItem('banana-available-extra-fields', JSON.stringify(nextPool));\n                                }}\n                              />\n                            );\n                          })}\n                        </div>\n                      </SortableContext>\n                    </DndContext>\n                    <div className=\"flex gap-1\">\n                      <input\n                        type=\"text\"\n                        className=\"flex-1 min-w-0 px-2 py-1 text-xs rounded-md border border-gray-200 dark:border-border-primary bg-white dark:bg-background-primary text-gray-700 dark:text-foreground-secondary focus:outline-none focus:ring-1 focus:ring-banana-500/30\"\n                        placeholder={t('detail.addField')}\n                        value={newFieldName}\n                        onChange={e => setNewFieldName(e.target.value)}\n                        onKeyDown={e => {\n                          if (e.key === 'Enter' && newFieldName.trim()) {\n                            e.preventDefault();\n                            const trimmed = newFieldName.trim();\n                            if (!availableFields.includes(trimmed) && availableFields.length < 10) {\n                              const nextPool = [...availableFields, trimmed];\n                              setAvailableFields(nextPool);\n                              localStorage.setItem('banana-available-extra-fields', JSON.stringify(nextPool));\n                              // 新增字段默认勾选\n                              const nextActive = [...extraFieldNames, trimmed];\n                              setExtraFieldNames(nextActive);\n                              saveSettingsDebounced({ description_extra_fields: nextActive });\n                              setNewFieldName('');\n                            }\n                          }\n                        }}\n                      />\n                      <button\n                        type=\"button\"\n                        className=\"p-1 rounded-md text-gray-400 hover:text-banana-500 hover:bg-gray-100 dark:hover:bg-background-hover transition-colors disabled:opacity-40\"\n                        disabled={!newFieldName.trim() || availableFields.includes(newFieldName.trim()) || availableFields.length >= 10}\n                        onClick={() => {\n                          const trimmed = newFieldName.trim();\n                          if (trimmed && !availableFields.includes(trimmed) && availableFields.length < 10) {\n                            const nextPool = [...availableFields, trimmed];\n                            setAvailableFields(nextPool);\n                            localStorage.setItem('banana-available-extra-fields', JSON.stringify(nextPool));\n                            const nextActive = [...extraFieldNames, trimmed];\n                            setExtraFieldNames(nextActive);\n                            saveSettingsDebounced({ description_extra_fields: nextActive });\n                            setNewFieldName('');\n                          }\n                        }}\n                      >\n                        <Plus size={14} />\n                      </button>\n                    </div>\n                  </div>\n                </div>\n              )}\n            </div>\n\n            <div className=\"w-px h-6 bg-gray-200 dark:bg-border-primary flex-shrink-0\" />\n            {/* 导入导出下拉菜单 */}\n            <div className=\"relative\" ref={fileMenuRef}>\n              <Button\n                variant=\"secondary\"\n                onClick={() => setFileMenuOpen(!fileMenuOpen)}\n                icon={<FileText size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n                className=\"text-sm md:text-base\"\n              >\n                {t('detail.importExport')}\n                <ChevronDown size={14} className={`ml-1 transition-transform duration-200 ${fileMenuOpen ? 'rotate-180' : ''}`} />\n              </Button>\n              {fileMenuOpen && (\n                <div className=\"absolute top-full right-0 mt-1 z-50 min-w-[160px] rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary shadow-lg dark:shadow-none overflow-hidden\">\n                  <button\n                    type=\"button\"\n                    onClick={() => { handleExportDescriptions(); setFileMenuOpen(false); }}\n                    disabled={!currentProject.pages.some(p => p.description_content)}\n                    className=\"w-full flex items-center gap-2 px-3 py-2 text-xs font-medium text-gray-600 dark:text-foreground-tertiary hover:bg-gray-50 dark:hover:bg-background-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors duration-150\"\n                  >\n                    <Download size={14} />\n                    {t('detail.export')}\n                  </button>\n                  <button\n                    type=\"button\"\n                    onClick={() => { handleExportFull(); setFileMenuOpen(false); }}\n                    disabled={!currentProject.pages.some(p => p.description_content)}\n                    className=\"w-full flex items-center gap-2 px-3 py-2 text-xs font-medium text-gray-600 dark:text-foreground-tertiary hover:bg-gray-50 dark:hover:bg-background-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors duration-150\"\n                  >\n                    <Download size={14} />\n                    {t('detail.exportFull')}\n                  </button>\n                  <div className=\"border-t border-gray-100 dark:border-border-primary\" />\n                  <button\n                    type=\"button\"\n                    onClick={() => { importFileRef.current?.click(); setFileMenuOpen(false); }}\n                    className=\"w-full flex items-center gap-2 px-3 py-2 text-xs font-medium text-gray-600 dark:text-foreground-tertiary hover:bg-gray-50 dark:hover:bg-background-hover transition-colors duration-150\"\n                  >\n                    <Upload size={14} />\n                    {t('detail.import')}\n                  </button>\n                </div>\n              )}\n            </div>\n            <input ref={importFileRef} type=\"file\" accept=\".md,.txt\" className=\"hidden\" onChange={handleImportDescriptions} />\n            <span className=\"text-xs md:text-sm text-gray-500 dark:text-foreground-tertiary whitespace-nowrap\">\n              {currentProject.pages.filter((p) => p.description_content).length} /{' '}\n              {currentProject.pages.length} {t('detail.pagesCompleted')}\n            </span>\n          </div>\n        </div>\n        )}\n      </div>\n\n      {/* 描述生成要求 - 可折叠 */}\n      <div className=\"bg-white dark:bg-background-secondary border-b border-gray-200 dark:border-border-primary flex-shrink-0\">\n        <button\n          type=\"button\"\n          data-testid=\"desc-requirements-toggle\"\n          onClick={() => { const next = !isDescReqOpen; setIsDescReqOpen(next); localStorage.setItem('descReqOpen', String(next)); }}\n          className=\"w-full px-3 md:px-6 py-2 flex items-center gap-2 text-xs text-gray-500 dark:text-foreground-tertiary hover:text-gray-700 dark:hover:text-foreground-secondary hover:bg-gray-50 dark:hover:bg-background-hover transition-colors\"\n        >\n          <Settings2 size={12} className=\"flex-shrink-0\" />\n          <span className=\"font-medium\">{t('detail.descRequirements')}</span>\n          {descRequirements && !isDescReqOpen && (\n            <span className=\"w-1.5 h-1.5 rounded-full bg-banana-400 flex-shrink-0\" />\n          )}\n          <ChevronDown\n            size={12}\n            className={`ml-auto transition-transform duration-200 ${isDescReqOpen ? 'rotate-180' : ''}`}\n          />\n        </button>\n        <div\n          className=\"overflow-hidden transition-all duration-200 ease-in-out\"\n          style={{ maxHeight: isDescReqOpen ? '600px' : '0px' }}\n        >\n          <div className=\"px-3 md:px-6 pb-3\">\n            <div data-testid=\"desc-requirements-textarea\">\n              <MarkdownTextarea\n                ref={reqTextareaRef}\n                value={descRequirements}\n                onChange={(val) => { setDescRequirements(val); setIsDescReqDirty(true); }}\n                onPaste={handleReqImagePaste}\n                onFiles={handleReqImageFiles}\n                placeholder={t('detail.descRequirementsPlaceholder')}\n                className=\"ring-inset\"\n                rows={2}\n                showImagePreview={false}\n              />\n            </div>\n            <PresetCapsules\n              type=\"description\"\n              onAppend={(text) => {\n                setDescRequirements((prev) => prev ? `${prev}\\n${text}` : text);\n                setIsDescReqDirty(true);\n              }}\n            />\n          </div>\n        </div>\n      </div>\n\n      {/* 主内容区 */}\n      <main className=\"flex-1 p-3 md:p-6 overflow-y-auto min-h-0\">\n        <div className=\"max-w-7xl mx-auto\">\n          <ReferenceFileList\n            projectId={projectId}\n            onFileClick={setPreviewFileId}\n            className=\"mb-4\"\n            showToast={show}\n          />\n          {currentProject.pages.length === 0 && !isRenovationProcessing ? (\n            <div className=\"text-center py-12 md:py-20\">\n              <div className=\"flex justify-center mb-4\"><FileText size={48} className=\"text-gray-300\" /></div>\n              <h3 className=\"text-lg md:text-xl font-semibold text-gray-700 dark:text-foreground-secondary mb-2\">\n                {t('detail.noPages')}\n              </h3>\n              <p className=\"text-sm md:text-base text-gray-500 dark:text-foreground-tertiary mb-6\">\n                {t('detail.noPagesHint')}\n              </p>\n              <Button\n                variant=\"primary\"\n                onClick={() => navigate(`/project/${projectId}/outline`)}\n                className=\"text-sm md:text-base\"\n              >\n                {t('detail.backToOutline')}\n              </Button>\n            </div>\n          ) : (\n            <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-6\">\n              {isRenovationProcessing && currentProject.pages.length === 0 ? (\n                /* Placeholder skeleton cards while renovation creates pages */\n                Array.from({ length: renovationProgress?.total || 6 }).map((_, index) => (\n                  <DescriptionCard\n                    key={`skeleton-${index}`}\n                    page={{ id: `skeleton-${index}`, title: '', sort_order: index, status: 'GENERATING_DESCRIPTION' } as any}\n                    index={index}\n                    projectId={currentProject.id}\n                    extraFieldNames={extraFieldNames}\n                    imagePromptFields={imagePromptFields}\n                    showToast={show}\n                    onUpdate={() => {}}\n                    onRegenerate={() => {}}\n                  />\n                ))\n              ) : (\n                currentProject.pages.map((page, index) => {\n                const pageId = page.id || page.page_id;\n                // Renovation processing: treat pages without description as generating\n                const hasDescription = page.description_content && (\n                  (typeof page.description_content === 'object' && 'text' in page.description_content && page.description_content.text?.trim())\n                );\n                const effectivePage = (isRenovationProcessing && !hasDescription)\n                  ? { ...page, status: 'GENERATING_DESCRIPTION' as const }\n                  : page;\n                return (\n                  <DescriptionCard\n                    key={pageId}\n                    page={effectivePage}\n                    index={index}\n                    projectId={currentProject.id}\n                    extraFieldNames={extraFieldNames}\n                    imagePromptFields={imagePromptFields}\n                    showToast={show}\n                    onUpdate={(data) => updatePageLocal(pageId, data)}\n                    onRegenerate={() => stableHandleRegeneratePage(pageId)}\n                    isAiRefining={isAiRefining}\n                  />\n                );\n              })\n              )}\n            </div>\n          )}\n        </div>\n      </main>\n      <ToastContainer />\n      {ConfirmDialog}\n      <FilePreviewModal fileId={previewFileId} onClose={() => setPreviewFileId(null)} />\n    </div>\n  );\n};\n\n"
  },
  {
    "path": "frontend/src/pages/History.tsx",
    "content": "import React, { useState, useEffect, useCallback } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { useTranslation } from 'react-i18next';\nimport { Home, Trash2, Sun, Moon } from 'lucide-react';\nimport { Button, Loading, Card, Pagination, useToast, useConfirm } from '@/components/shared';\nimport { ProjectCard } from '@/components/history/ProjectCard';\nimport { useProjectStore } from '@/store/useProjectStore';\nimport { useTheme } from '@/hooks/useTheme';\nimport { useT } from '@/hooks/useT';\nimport * as api from '@/api/endpoints';\nimport { normalizeProject } from '@/utils';\nimport { getProjectTitle, getProjectRoute } from '@/utils/projectUtils';\nimport type { Project } from '@/types';\n\n// 页面特有翻译 - AI 可以直接看到所有文案\nconst historyI18n = {\n  zh: {\n    home: { title: '蕉幻', actions: { createProject: '创建新项目' } },\n    nav: { home: '主页' },\n    settings: { language: { label: '界面语言' }, theme: { light: '浅色', dark: '深色' } },\n    history: {\n      title: '历史项目',\n      subtitle: '查看和管理你的所有项目',\n      noProjects: '暂无历史项目',\n      createFirst: '创建你的第一个项目开始使用吧',\n      selectedCount: '已选择 {{count}} 项',\n      cancelSelect: '取消选择',\n      batchDelete: '批量删除',\n      confirmDelete: '确定要删除项目「{{title}}」吗？此操作不可恢复。',\n      confirmBatchDelete: '确定要删除选中的 {{count}} 个项目吗？此操作不可恢复。',\n      deleteTitle: '确认删除',\n      batchDeleteTitle: '确认批量删除',\n      deleteSuccess: '成功删除 {{count}} 个项目',\n      deletePartial: '成功删除 {{success}} 个项目，{{fail}} 个删除失败',\n      deleteCurrentProject: '已删除项目，包括当前打开的项目',\n      deleteFailed: '删除项目失败',\n      openFailed: '打开项目失败',\n      loadFailed: '加载历史项目失败',\n      perPage: '条/页',\n      titleEmpty: '项目名称不能为空',\n      titleUpdated: '项目名称已更新',\n      titleUpdateFailed: '更新项目名称失败',\n    },\n  },\n  en: {\n    home: { title: 'Banana Slides', actions: { createProject: 'Create New Project' } },\n    nav: { home: 'Home' },\n    settings: { language: { label: 'Interface Language' }, theme: { light: 'Light', dark: 'Dark' } },\n    history: {\n      title: 'Project History',\n      subtitle: 'View and manage all your projects',\n      noProjects: 'No projects yet',\n      createFirst: 'Create your first project to get started',\n      selectedCount: '{{count}} selected',\n      cancelSelect: 'Cancel Selection',\n      batchDelete: 'Batch Delete',\n      confirmDelete: 'Are you sure you want to delete project \"{{title}}\"? This action cannot be undone.',\n      confirmBatchDelete: 'Are you sure you want to delete {{count}} selected project(s)? This action cannot be undone.',\n      deleteTitle: 'Confirm Delete',\n      batchDeleteTitle: 'Confirm Batch Delete',\n      deleteSuccess: 'Successfully deleted {{count}} project(s)',\n      deletePartial: 'Deleted {{success}} project(s), {{fail}} failed',\n      deleteCurrentProject: 'Deleted projects including the currently open one',\n      deleteFailed: 'Failed to delete project',\n      openFailed: 'Failed to open project',\n      loadFailed: 'Failed to load project history',\n      perPage: '/ page',\n      titleEmpty: 'Project name cannot be empty',\n      titleUpdated: 'Project name updated',\n      titleUpdateFailed: 'Failed to update project name',\n    },\n  },\n};\n\nconst DEFAULT_PAGE_SIZE = 5;\nconst PAGE_SIZE_KEY = 'history_page_size';\n\nexport const History: React.FC = () => {\n  const navigate = useNavigate();\n  const { i18n } = useTranslation();\n  const t = useT(historyI18n); // 组件内翻译 + 自动 fallback 到全局\n  const { isDark, setTheme } = useTheme();\n  const { syncProject, setCurrentProject } = useProjectStore();\n\n  const [projects, setProjects] = useState<Project[]>([]);\n  const [totalProjects, setTotalProjects] = useState(0);\n  const [currentPage, setCurrentPage] = useState(1);\n  const [pageSize, setPageSize] = useState(() => {\n    const saved = localStorage.getItem(PAGE_SIZE_KEY);\n    return saved ? Number(saved) : DEFAULT_PAGE_SIZE;\n  });\n  const [isLoading, setIsLoading] = useState(true);\n  const [error, setError] = useState<string | null>(null);\n  const [selectedProjects, setSelectedProjects] = useState<Set<string>>(new Set());\n  const [isDeleting, setIsDeleting] = useState(false);\n  const [editingProjectId, setEditingProjectId] = useState<string | null>(null);\n  const [editingTitle, setEditingTitle] = useState<string>('');\n  const { show, ToastContainer } = useToast();\n  const { confirm, ConfirmDialog } = useConfirm();\n\n  const totalPages = Math.ceil(totalProjects / pageSize);\n\n  const loadProjects = useCallback(async (page: number) => {\n    setIsLoading(true);\n    setError(null);\n    try {\n      const offset = (page - 1) * pageSize;\n      const response = await api.listProjects(pageSize, offset);\n      if (response.data?.projects) {\n        const normalizedProjects = response.data.projects.map(normalizeProject);\n        setProjects(normalizedProjects);\n        setTotalProjects(response.data.total ?? 0);\n      }\n    } catch (err: any) {\n      console.error('加载历史项目失败:', err);\n      setError(err.message || t('history.loadFailed'));\n    } finally {\n      setIsLoading(false);\n    }\n  }, [pageSize]);\n\n  useEffect(() => {\n    loadProjects(currentPage);\n  }, [currentPage, pageSize]);\n\n  const handlePageChange = useCallback((page: number) => {\n    setSelectedProjects(new Set());\n    setCurrentPage(page);\n    window.scrollTo({ top: 0, behavior: 'smooth' });\n  }, []);\n\n  const handlePageSizeChange = useCallback((size: number) => {\n    localStorage.setItem(PAGE_SIZE_KEY, String(size));\n    setPageSize(size);\n    setCurrentPage(1);\n    setSelectedProjects(new Set());\n  }, []);\n\n  // ===== 项目选择与导航 =====\n\n  const handleSelectProject = useCallback(async (project: Project) => {\n    const projectId = project.id || project.project_id;\n    if (!projectId) return;\n\n    // 如果正在批量选择模式，不跳转\n    if (selectedProjects.size > 0) {\n      return;\n    }\n\n    // 如果正在编辑该项目，不跳转\n    if (editingProjectId === projectId) {\n      return;\n    }\n\n    try {\n      // 设置当前项目\n      setCurrentProject(project);\n      localStorage.setItem('currentProjectId', projectId);\n      \n      // 同步项目数据\n      await syncProject(projectId);\n      \n      // 根据项目状态跳转到不同页面\n      const route = getProjectRoute(project);\n      navigate(route, { state: { from: 'history' } });\n    } catch (err: any) {\n      console.error('打开项目失败:', err);\n      show({\n        message: t('history.openFailed') + ': ' + (err.message || t('common.unknownError')),\n        type: 'error'\n      });\n    }\n   \n  }, [selectedProjects, editingProjectId, setCurrentProject, syncProject, navigate, show]);\n\n  // ===== 批量选择操作 =====\n\n  const handleToggleSelect = useCallback((projectId: string) => {\n    setSelectedProjects(prev => {\n      const newSelected = new Set(prev);\n      if (newSelected.has(projectId)) {\n        newSelected.delete(projectId);\n      } else {\n        newSelected.add(projectId);\n      }\n      return newSelected;\n    });\n  }, []);\n\n  const handleSelectAll = useCallback(() => {\n    setSelectedProjects(prev => {\n      if (prev.size === projects.length) {\n        return new Set();\n      } else {\n        const allIds = projects.map(p => p.id || p.project_id).filter(Boolean) as string[];\n        return new Set(allIds);\n      }\n    });\n  }, [projects]);\n\n  // ===== 删除操作 =====\n\n  const deleteProjects = useCallback(async (projectIds: string[]) => {\n    setIsDeleting(true);\n    const currentProjectId = localStorage.getItem('currentProjectId');\n    let deletedCurrentProject = false;\n\n    try {\n      // 批量删除 - 使用 allSettled 处理部分失败\n      const results = await Promise.allSettled(\n        projectIds.map(projectId => api.deleteProject(projectId))\n      );\n\n      const successIds = projectIds.filter((_, i) => results[i].status === 'fulfilled');\n      const failCount = results.filter(r => r.status === 'rejected').length;\n\n      // 检查是否删除了当前项目\n      if (currentProjectId && successIds.includes(currentProjectId)) {\n        localStorage.removeItem('currentProjectId');\n        setCurrentProject(null);\n        deletedCurrentProject = true;\n      }\n\n      // 清空选择\n      setSelectedProjects(new Set());\n\n      // Reload current page; if all items on this page were deleted, go back one page\n      if (successIds.length > 0) {\n        const remainingOnPage = projects.length - successIds.length;\n        const newPage = remainingOnPage <= 0 && currentPage > 1 ? currentPage - 1 : currentPage;\n        if (newPage !== currentPage) {\n          // setCurrentPage triggers the useEffect which calls loadProjects\n          setCurrentPage(newPage);\n        } else {\n          await loadProjects(newPage);\n        }\n      }\n\n      if (failCount > 0 && successIds.length > 0) {\n        show({\n          message: t('history.deletePartial', { success: successIds.length, fail: failCount }),\n          type: 'warning'\n        });\n      } else if (deletedCurrentProject) {\n        show({\n          message: t('history.deleteCurrentProject'),\n          type: 'info'\n        });\n      } else if (successIds.length > 0) {\n        show({\n          message: t('history.deleteSuccess', { count: successIds.length }),\n          type: 'success'\n        });\n      } else {\n        show({\n          message: t('history.deleteFailed'),\n          type: 'error'\n        });\n      }\n    } catch (err: any) {\n      console.error('删除项目失败:', err);\n      show({\n        message: t('history.deleteFailed') + ': ' + (err.message || t('common.unknownError')),\n        type: 'error'\n      });\n    } finally {\n      setIsDeleting(false);\n    }\n  }, [setCurrentProject, show, projects, currentPage, loadProjects]);\n\n  const handleDeleteProject = useCallback(async (e: React.MouseEvent, project: Project) => {\n    e.stopPropagation(); // 阻止事件冒泡，避免触发项目选择\n    \n    const projectId = project.id || project.project_id;\n    if (!projectId) return;\n\n    const projectTitle = getProjectTitle(project);\n    confirm(\n      t('history.confirmDelete', { title: projectTitle }),\n      async () => {\n        await deleteProjects([projectId]);\n      },\n      { title: t('history.deleteTitle'), variant: 'danger' }\n    );\n   \n  }, [confirm, deleteProjects]);\n\n  const handleBatchDelete = useCallback(async () => {\n    if (selectedProjects.size === 0) return;\n\n    const count = selectedProjects.size;\n    confirm(\n      t('history.confirmBatchDelete', { count }),\n      async () => {\n        const projectIds = Array.from(selectedProjects);\n        await deleteProjects(projectIds);\n      },\n      { title: t('history.batchDeleteTitle'), variant: 'danger' }\n    );\n  }, [selectedProjects, confirm, deleteProjects, t]);\n\n  // ===== 编辑操作 =====\n\n  const handleStartEdit = useCallback((e: React.MouseEvent, project: Project) => {\n    e.stopPropagation(); // 阻止事件冒泡，避免触发项目选择\n    \n    // 如果正在批量选择模式，不允许编辑\n    if (selectedProjects.size > 0) {\n      return;\n    }\n    \n    const projectId = project.id || project.project_id;\n    if (!projectId) return;\n    \n    const currentTitle = getProjectTitle(project);\n    setEditingProjectId(projectId);\n    setEditingTitle(currentTitle);\n  }, [selectedProjects]);\n\n  const handleCancelEdit = useCallback(() => {\n    setEditingProjectId(null);\n    setEditingTitle('');\n  }, []);\n\n  const handleSaveEdit = useCallback(async (projectId: string) => {\n    if (!editingTitle.trim()) {\n      show({ message: t('history.titleEmpty'), type: 'error' });\n      return;\n    }\n\n    try {\n      // 调用API更新项目名称\n      await api.updateProject(projectId, { idea_prompt: editingTitle.trim() });\n\n      // 更新本地状态\n      setProjects(prev => prev.map(p => {\n        const id = p.id || p.project_id;\n        if (id === projectId) {\n          return { ...p, idea_prompt: editingTitle.trim() };\n        }\n        return p;\n      }));\n\n      setEditingProjectId(null);\n      setEditingTitle('');\n      show({ message: t('history.titleUpdated'), type: 'success' });\n    } catch (err: any) {\n      console.error('更新项目名称失败:', err);\n      show({\n        message: t('history.titleUpdateFailed') + ': ' + (err.message || t('common.unknownError')),\n        type: 'error'\n      });\n    }\n   \n  }, [editingTitle, show]);\n\n  const handleTitleKeyDown = useCallback((e: React.KeyboardEvent, projectId: string) => {\n    if (e.key === 'Enter') {\n      e.preventDefault();\n      handleSaveEdit(projectId);\n    } else if (e.key === 'Escape') {\n      e.preventDefault();\n      handleCancelEdit();\n    }\n  }, [handleSaveEdit, handleCancelEdit]);\n\n  return (\n    <div className=\"min-h-screen bg-gradient-to-br from-banana-50 dark:from-background-primary via-white dark:via-background-primary to-gray-50 dark:to-background-primary\">\n      {/* 导航栏 */}\n      <nav className=\"h-14 md:h-16 bg-white dark:bg-background-secondary shadow-sm dark:shadow-background-primary/30 border-b border-gray-100 dark:border-border-primary\">\n        <div className=\"max-w-7xl mx-auto px-3 md:px-4 h-full flex items-center justify-between\">\n          <div className=\"flex items-center gap-2\">\n            <div className=\"w-8 h-8 md:w-10 md:h-10 bg-gradient-to-br from-banana-500 to-banana-600 rounded-lg flex items-center justify-center text-xl md:text-2xl\">\n              🍌\n            </div>\n            <span className=\"text-lg md:text-xl font-bold text-gray-900 dark:text-foreground-primary\">{t('home.title')}</span>\n          </div>\n          <div className=\"flex items-center gap-2 md:gap-4\">\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              icon={<Home size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={() => navigate('/')}\n              className=\"text-xs md:text-sm\"\n            >\n              {t('nav.home')}\n            </Button>\n            {/* 分隔线 */}\n            <div className=\"h-5 w-px bg-gray-300 dark:bg-border-primary\" />\n            {/* 语言切换按钮 */}\n            <button\n              onClick={() => i18n.changeLanguage(i18n.language?.startsWith('zh') ? 'en' : 'zh')}\n              className=\"px-2 py-1 text-xs font-medium text-gray-600 dark:text-foreground-tertiary hover:text-gray-900 dark:hover:text-gray-100 hover:bg-banana-100/60 dark:hover:bg-background-hover rounded-md transition-all\"\n              title={t('settings.language.label')}\n            >\n              {i18n.language?.startsWith('zh') ? 'EN' : '中'}\n            </button>\n            {/* 主题切换按钮 */}\n            <button\n              onClick={() => setTheme(isDark ? 'light' : 'dark')}\n              className=\"p-1.5 text-gray-600 dark:text-foreground-tertiary hover:text-gray-900 dark:hover:text-gray-100 hover:bg-banana-100/60 dark:hover:bg-background-hover rounded-md transition-all\"\n              title={isDark ? t('settings.theme.light') : t('settings.theme.dark')}\n            >\n              {isDark ? <Sun size={16} /> : <Moon size={16} />}\n            </button>\n          </div>\n        </div>\n      </nav>\n\n      {/* 主内容 */}\n      <main className=\"max-w-6xl mx-auto px-3 md:px-4 py-6 md:py-8\">\n        <div className=\"mb-6 md:mb-8 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4\">\n          <div>\n            <h1 className=\"text-2xl md:text-3xl font-bold text-gray-900 dark:text-foreground-primary mb-1 md:mb-2\">{t('history.title')}</h1>\n            <p className=\"text-sm md:text-base text-gray-600 dark:text-foreground-tertiary\">{t('history.subtitle')}</p>\n          </div>\n          {projects.length > 0 && selectedProjects.size > 0 && (\n            <div className=\"flex items-center gap-3\">\n              <span className=\"text-sm text-gray-600 dark:text-foreground-tertiary\">\n                {t('history.selectedCount', { count: selectedProjects.size })}\n              </span>\n              <Button\n                variant=\"secondary\"\n                size=\"sm\"\n                onClick={() => setSelectedProjects(new Set())}\n                disabled={isDeleting}\n              >\n                {t('history.cancelSelect')}\n              </Button>\n              <Button\n                variant=\"secondary\"\n                size=\"sm\"\n                icon={<Trash2 size={16} />}\n                onClick={handleBatchDelete}\n                disabled={isDeleting}\n                loading={isDeleting}\n              >\n                {t('history.batchDelete')}\n              </Button>\n            </div>\n          )}\n        </div>\n\n        {isLoading ? (\n          <div className=\"flex items-center justify-center py-12\">\n            <Loading message={t('common.loading')} />\n          </div>\n        ) : error ? (\n          <Card className=\"p-8 text-center\">\n            <div className=\"text-6xl mb-4\">⚠️</div>\n            <p className=\"text-gray-600 dark:text-foreground-tertiary mb-4\">{error}</p>\n            <Button variant=\"primary\" onClick={() => loadProjects(currentPage)}>\n              {t('common.retry')}\n            </Button>\n          </Card>\n        ) : projects.length === 0 ? (\n          <Card className=\"p-12 text-center\">\n            <div className=\"text-6xl mb-4\">📭</div>\n            <h3 className=\"text-xl font-semibold text-gray-700 dark:text-foreground-secondary mb-2\">\n              {t('history.noProjects')}\n            </h3>\n            <p className=\"text-gray-500 dark:text-foreground-tertiary mb-6\">\n              {t('history.createFirst')}\n            </p>\n            <Button variant=\"primary\" onClick={() => navigate('/')}>\n              {t('home.actions.createProject')}\n            </Button>\n          </Card>\n        ) : (\n          <div className=\"space-y-4\">\n            {/* 全选工具栏 */}\n            {projects.length > 0 && (\n              <div className=\"flex items-center gap-3 pb-2 border-b border-gray-200 dark:border-border-primary\">\n                <label className=\"flex items-center gap-2 cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={selectedProjects.size === projects.length && projects.length > 0}\n                    onChange={handleSelectAll}\n                    className=\"w-4 h-4 text-banana-600 border-gray-300 dark:border-border-primary rounded focus:ring-banana-500\"\n                  />\n                  <span className=\"text-sm text-gray-700 dark:text-foreground-secondary\">\n                    {selectedProjects.size === projects.length ? t('common.deselectAll') : t('common.selectAll')}\n                  </span>\n                </label>\n              </div>\n            )}\n            \n            {projects.map((project) => {\n              const projectId = project.id || project.project_id;\n              if (!projectId) return null;\n\n              return (\n                <ProjectCard\n                  key={projectId}\n                  project={project}\n                  isSelected={selectedProjects.has(projectId)}\n                  isEditing={editingProjectId === projectId}\n                  editingTitle={editingTitle}\n                  onSelect={handleSelectProject}\n                  onToggleSelect={handleToggleSelect}\n                  onDelete={handleDeleteProject}\n                  onStartEdit={handleStartEdit}\n                  onTitleChange={setEditingTitle}\n                  onTitleKeyDown={handleTitleKeyDown}\n                  onSaveEdit={handleSaveEdit}\n                  isBatchMode={selectedProjects.size > 0}\n                />\n              );\n            })}\n\n            {/* 分页 */}\n            <div className=\"pt-4\">\n              <Pagination\n                currentPage={currentPage}\n                totalPages={totalPages}\n                onPageChange={handlePageChange}\n                pageSize={pageSize}\n                onPageSizeChange={handlePageSizeChange}\n                pageSizeLabel={t('history.perPage')}\n              />\n            </div>\n          </div>\n        )}\n      </main>\n      <ToastContainer />\n      {ConfirmDialog}\n    </div>\n  );\n};\n\n"
  },
  {
    "path": "frontend/src/pages/Home.tsx",
    "content": "import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { useTranslation } from 'react-i18next';\nimport { Sparkles, FileText, FileEdit, ImagePlus, Paperclip, Palette, Lightbulb, Search, Settings, FolderOpen, HelpCircle, Sun, Moon, Globe, Monitor, ChevronDown, Upload, RefreshCw } from 'lucide-react';\nimport { Button, Card, useToast, MaterialGeneratorModal, MaterialCenterModal, ReferenceFileList, ReferenceFileSelector, FilePreviewModal, HelpModal, Footer, GithubRepoCard, TextStyleSelector } from '@/components/shared';\nimport { MarkdownTextarea, type MarkdownTextareaRef } from '@/components/shared/MarkdownTextarea';\nimport { TemplateSelector, getTemplateFile } from '@/components/shared/TemplateSelector';\nimport { listUserTemplates, type UserTemplate, uploadReferenceFile, type ReferenceFile, associateFileToProject, triggerFileParse, associateMaterialsToProject, createPptRenovationProject } from '@/api/endpoints';\nimport { useProjectStore } from '@/store/useProjectStore';\nimport { devLog } from '@/utils/logger';\nimport { useTheme } from '@/hooks/useTheme';\nimport { useImagePaste } from '@/hooks/useImagePaste';\nimport { useT } from '@/hooks/useT';\nimport { ASPECT_RATIO_OPTIONS } from '@/config/aspectRatio';\n\ntype CreationType = 'idea' | 'outline' | 'description' | 'ppt_renovation';\n\n// 页面特有翻译 - AI 可以直接看到所有文案，保留原始 key 结构\nconst homeI18n = {\n  zh: {\n    nav: {\n      materialGenerate: '素材生成', materialCenter: '素材中心',\n      history: '历史项目', settings: '设置', help: '帮助'\n    },\n    settings: {\n      language: { label: '界面语言' },\n      theme: { label: '主题模式', light: '浅色', dark: '深色', system: '跟随系统' }\n    },\n    home: {\n      title: '蕉幻',\n      subtitle: 'Vibe your slides like vibe coding',\n      tagline: '基于 nano banana pro🍌 的原生 AI PPT 生成器',\n      features: {\n        oneClick: '一句话生成 PPT',\n        naturalEdit: '自然语言修改',\n        regionEdit: '指定区域编辑',\n        export: '一键导出 PPTX/PDF',\n      },\n      tabs: {\n        idea: '一句话生成',\n        outline: '从大纲生成',\n        description: '从描述生成',\n        ppt_renovation: 'PPT 翻新',\n      },\n      tabDescriptions: {\n        idea: '输入你的想法，AI 将为你生成完整的 PPT',\n        outline: '已有大纲？直接粘贴，AI 将自动切分为结构化大纲',\n        description: '已有完整描述？AI 将自动解析并直接生成图片，跳过大纲步骤',\n        ppt_renovation: '上传已有的 PDF/PPTX 文件，AI 将解析内容并重新生成翻新后的PPT',\n      },\n      placeholders: {\n        idea: '例如：生成一份关于 AI 发展史的演讲 PPT',\n        outline: '粘贴你的 PPT 大纲...',\n        description: '粘贴你的完整页面描述...',\n      },\n      examples: {\n        outline: '格式示例：\\n\\n第一页：AI 的起源\\n- 1956年达特茅斯会议\\n- 早期研究者的愿景\\n\\n第二页：机器学习的发展\\n- 从规则驱动到数据驱动\\n- 经典算法介绍\\n\\n第三页：未来展望\\n- 趋势与挑战\\n\\n支持标题+要点的形式，也可以只写标题。AI 会自动切分为结构化大纲。',\n        description: '格式示例：\\n\\n第一页：AI 的起源\\n介绍人工智能概念的诞生，从1956年达特茅斯会议讲起。页面采用左文右图布局，左侧展示时间线，右侧配一张复古风格的计算机插画。\\n\\n第二页：机器学习的发展\\n讲解从规则驱动到数据驱动的转变。使用深蓝色背景，中央放置算法对比图表，底部列出关键里程碑。\\n\\n每页可包含内容描述、排版布局、视觉风格等，用空行分隔各页。',\n      },\n      template: {\n        title: '选择风格模板',\n        useTextStyle: '使用文字描述风格',\n      },\n      actions: {\n        selectFile: '选择参考文件',\n        parsing: '解析中...',\n        createProject: '创建新项目',\n      },\n      renovation: {\n        uploadHint: '点击或拖拽上传 PDF / PPTX 文件',\n        formatHint: '支持 .pdf, .pptx, .ppt 格式（推荐上传 PDF）',\n        keepLayout: '保留原始排版布局',\n        onlyPdfPptx: '仅支持 PDF 和 PPTX 文件',\n        uploadFile: '请先上传 PDF 或 PPTX 文件',\n      },\n      messages: {\n        enterContent: '请输入内容',\n        filesParsing: '还有 {{count}} 个参考文件正在解析中，请等待解析完成',\n        projectCreateFailed: '项目创建失败',\n        uploadingImage: '正在上传图片并识别内容...',\n        imageUploadSuccess: '图片上传成功！已插入到光标位置',\n        imageUploadFailed: '图片上传失败',\n        fileUploadSuccess: '文件上传成功',\n        fileUploadFailed: '文件上传失败',\n        fileTooLarge: '文件过大：{{size}}MB，最大支持 200MB',\n        unsupportedFileType: '不支持的文件类型: {{type}}',\n        pptTip: '建议先在本地将 PPTX 转为 PDF 后再上传，可获得更好的兼容性和更快的处理速度',\n        filesAdded: '已添加 {{count}} 个参考文件',\n        imageRemoved: '已移除图片',\n        serviceTestTip: '建议先到设置页底部进行服务测试，避免后续功能异常',\n        verifying: '正在验证 API 配置...',\n        verifyFailed: '请在设置页配置正确的 API Key，并在页面底部点击「服务测试」验证',\n      },\n    },\n  },\n  en: {\n    nav: {\n      materialGenerate: 'Generate Material', materialCenter: 'Material Center',\n      history: 'History', settings: 'Settings', help: 'Help'\n    },\n    settings: {\n      language: { label: 'Interface Language' },\n      theme: { label: 'Theme', light: 'Light', dark: 'Dark', system: 'System' }\n    },\n    home: {\n      title: 'Banana Slides',\n      subtitle: 'Vibe your slides like vibe coding',\n      tagline: 'AI-native PPT generator powered by nano banana pro🍌',\n      features: {\n        oneClick: 'One-click PPT generation',\n        naturalEdit: 'Natural language editing',\n        regionEdit: 'Region-specific editing',\n        export: 'Export to PPTX/PDF',\n      },\n      tabs: {\n        idea: 'From Idea',\n        outline: 'From Outline',\n        description: 'From Description',\n        ppt_renovation: 'PPT Renovation',\n      },\n      tabDescriptions: {\n        idea: 'Enter your idea, AI will generate a complete PPT for you',\n        outline: 'Have an outline? Paste it directly, AI will split it into a structured outline',\n        description: 'Have detailed descriptions? AI will parse and generate images directly, skipping the outline step',\n        ppt_renovation: 'Upload an existing PDF/PPTX file, AI will parse its content and regenerate the renovated PPT',\n      },\n      placeholders: {\n        idea: 'e.g., Generate a presentation about the history of AI',\n        outline: 'Paste your PPT outline...',\n        description: 'Paste your complete page descriptions...',\n      },\n      examples: {\n        outline: 'Format example:\\n\\nSlide 1: The Origins of AI\\n- 1956 Dartmouth Conference\\n- Vision of early researchers\\n\\nSlide 2: The Rise of Machine Learning\\n- From rule-based to data-driven\\n- Classic algorithms overview\\n\\nSlide 3: Future Outlook\\n- Trends and challenges\\n\\nTitles with bullet points, or titles only. AI will split it into a structured outline.',\n        description: 'Format example:\\n\\nSlide 1: The Origins of AI\\nIntroduce the birth of AI, starting from the 1956 Dartmouth Conference. Use a left-text right-image layout with a timeline on the left and a retro-style computer illustration on the right.\\n\\nSlide 2: The Rise of Machine Learning\\nExplain the shift from rule-based to data-driven approaches. Dark blue background, algorithm comparison chart in the center, key milestones at the bottom.\\n\\nEach slide can include content, layout, and visual style. Separate slides with blank lines.',\n      },\n      template: {\n        title: 'Select Style Template',\n        useTextStyle: 'Use text description for style',\n      },\n      actions: {\n        selectFile: 'Select reference file',\n        parsing: 'Parsing...',\n        createProject: 'Create New Project',\n      },\n      renovation: {\n        uploadHint: 'Click or drag to upload PDF / PPTX file',\n        formatHint: 'Supports .pdf, .pptx, .ppt formats (PDF recommended)',\n        keepLayout: 'Keep original layout',\n        onlyPdfPptx: 'Only PDF and PPTX files are supported',\n        uploadFile: 'Please upload a PDF or PPTX file first',\n      },\n      messages: {\n        enterContent: 'Please enter content',\n        filesParsing: '{{count}} reference file(s) are still parsing, please wait',\n        projectCreateFailed: 'Failed to create project',\n        uploadingImage: 'Uploading and recognizing image...',\n        imageUploadSuccess: 'Image uploaded! Inserted at cursor position',\n        imageUploadFailed: 'Failed to upload image',\n        fileUploadSuccess: 'File uploaded successfully',\n        fileUploadFailed: 'Failed to upload file',\n        fileTooLarge: 'File too large: {{size}}MB, maximum 200MB',\n        unsupportedFileType: 'Unsupported file type: {{type}}',\n        pptTip: 'We recommend converting your PPTX to PDF locally before uploading for better compatibility and faster processing',\n        filesAdded: 'Added {{count}} reference file(s)',\n        imageRemoved: 'Image removed',\n        serviceTestTip: 'Test services in Settings first to avoid issues',\n        verifying: 'Verifying API configuration...',\n        verifyFailed: 'Please configure a valid API Key in Settings and click \"Service Test\" at the bottom to verify',\n      },\n    },\n  },\n};\n\nexport const Home: React.FC = () => {\n  const navigate = useNavigate();\n  const { i18n } = useTranslation();\n  const t = useT(homeI18n); // 组件内翻译 + 自动 fallback 到全局\n  const { theme, isDark, setTheme } = useTheme();\n  const { initializeProject, isGlobalLoading } = useProjectStore();\n  const { show, ToastContainer } = useToast();\n  \n  const [activeTab, setActiveTab] = useState<CreationType>('idea');\n  const [content, setContent] = useState('');\n  const [selectedTemplate, setSelectedTemplate] = useState<File | null>(null);\n  const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);\n  const [selectedPresetTemplateId, setSelectedPresetTemplateId] = useState<string | null>(null);\n  const [isMaterialModalOpen, setIsMaterialModalOpen] = useState(false);\n  const [isMaterialCenterOpen, setIsMaterialCenterOpen] = useState(false);\n  const [isHelpModalOpen, setIsHelpModalOpen] = useState(false);\n  const [isThemeMenuOpen, setIsThemeMenuOpen] = useState(false);\n  const [currentProjectId, setCurrentProjectId] = useState<string | null>(null);\n  const [userTemplates, setUserTemplates] = useState<UserTemplate[]>([]);\n  const [referenceFiles, setReferenceFiles] = useState<ReferenceFile[]>([]);\n  const [isUploadingFile, setIsUploadingFile] = useState(false);\n  const [isFileSelectorOpen, setIsFileSelectorOpen] = useState(false);\n  const [previewFileId, setPreviewFileId] = useState<string | null>(null);\n\n  const [useTemplateStyle, setUseTemplateStyle] = useState(false);\n  const [templateStyle, setTemplateStyle] = useState('');\n  const [aspectRatio, setAspectRatio] = useState('16:9');\n  const [isAspectRatioOpen, setIsAspectRatioOpen] = useState(false);\n  const [renovationFile, setRenovationFile] = useState<File | null>(null);\n  const [keepLayout, setKeepLayout] = useState(false);\n  const renovationFileInputRef = useRef<HTMLInputElement>(null);\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const themeMenuRef = useRef<HTMLDivElement>(null);\n\n  // 持久化草稿到 sessionStorage，确保跳转设置页后返回时内容不丢失\n  useEffect(() => {\n    if (content) {\n      sessionStorage.setItem('home-draft-content', content);\n    }\n  }, [content]);\n\n  useEffect(() => {\n    sessionStorage.setItem('home-draft-tab', activeTab);\n  }, [activeTab]);\n\n\n  // 检查是否有当前项目 & 加载用户模板\n  useEffect(() => {\n    const projectId = localStorage.getItem('currentProjectId');\n    setCurrentProjectId(projectId);\n\n    // 加载用户模板列表（用于按需获取File）\n    const loadTemplates = async () => {\n      try {\n        const response = await listUserTemplates();\n        if (response.data?.templates) {\n          setUserTemplates(response.data.templates);\n        }\n      } catch (error) {\n        console.error('加载用户模板失败:', error);\n      }\n    };\n    loadTemplates();\n  }, []);\n\n  // 首次访问自动弹出帮助模态框\n  useEffect(() => {\n    const hasSeenHelp = localStorage.getItem('hasSeenHelpModal');\n    if (!hasSeenHelp) {\n      // 延迟500ms打开，让页面先渲染完成\n      const timer = setTimeout(() => {\n        setIsHelpModalOpen(true);\n        localStorage.setItem('hasSeenHelpModal', 'true');\n      }, 500);\n      return () => clearTimeout(timer);\n    }\n  }, []);\n\n  const handleOpenMaterialModal = () => {\n    // 在主页始终生成全局素材，不关联任何项目\n    setIsMaterialModalOpen(true);\n  };\n\n  const textareaRef = useRef<MarkdownTextareaRef>(null);\n\n  // Callback to insert at cursor position in the textarea\n  const insertAtCursor = useCallback((markdown: string) => {\n    textareaRef.current?.insertAtCursor(markdown);\n  }, []);\n\n  // 图片粘贴使用统一 hook（批量支持，不对非图片文件发出警告，由下方 handlePaste 处理文档）\n  const { handlePaste: handleImagePaste, handleFiles: handleImageFiles, isUploading: isUploadingImage } = useImagePaste({\n    projectId: null,\n    setContent,\n    showToast: show,\n    warnUnsupportedTypes: false,\n    insertAtCursor,\n  });\n\n  // 检测粘贴事件，图片走 hook，文档走独立逻辑\n  const handlePaste = async (e: React.ClipboardEvent<HTMLElement>) => {\n    const items = e.clipboardData?.items;\n    if (!items) return;\n\n    // 分类：图片 vs 文档 vs 不支持\n    let hasImages = false;\n    const docFiles: File[] = [];\n    const unsupportedExts: string[] = [];\n\n    const allowedDocExtensions = ['pdf', 'docx', 'pptx', 'doc', 'ppt', 'xlsx', 'xls', 'csv', 'txt', 'md'];\n\n    for (let i = 0; i < items.length; i++) {\n      const item = items[i];\n      if (item.kind !== 'file') continue;\n      const file = item.getAsFile();\n      if (!file) continue;\n\n      if (file.type.startsWith('image/')) {\n        hasImages = true;\n      } else {\n        const fileExt = file.name.split('.').pop()?.toLowerCase();\n        if (fileExt && allowedDocExtensions.includes(fileExt)) {\n          docFiles.push(file);\n        } else {\n          unsupportedExts.push(fileExt || file.type);\n        }\n      }\n    }\n\n    // 图片交给 hook 处理（批量上传）\n    if (hasImages) {\n      handleImagePaste(e);\n    }\n\n    // 文档文件逐个上传\n    if (docFiles.length > 0) {\n      if (!hasImages) e.preventDefault();\n      for (const file of docFiles) {\n        await handleFileUpload(file);\n      }\n    }\n\n    // 不支持的文件类型提示\n    if (unsupportedExts.length > 0 && !hasImages && docFiles.length === 0) {\n      show({ message: t('home.messages.unsupportedFileType', { type: unsupportedExts.join(', ') }), type: 'info' });\n    }\n  };\n\n  // 上传文件\n  // 在 Home 页面，文件始终上传为全局文件（不关联项目），因为此时还没有项目\n  const handleFileUpload = async (file: File) => {\n    if (isUploadingFile) return;\n\n    // 检查文件大小（前端预检查）\n    const maxSize = 200 * 1024 * 1024; // 200MB\n    if (file.size > maxSize) {\n      show({ \n        message: t('home.messages.fileTooLarge', { size: (file.size / 1024 / 1024).toFixed(1) }), \n        type: 'error' \n      });\n      return;\n    }\n\n    // 检查是否是PPT文件，提示建议使用PDF\n    const fileExt = file.name.split('.').pop()?.toLowerCase();\n    if (fileExt === 'ppt' || fileExt === 'pptx') \n      show({ message: `💡 ${t('home.messages.pptTip')}`, type: 'info' });\n    \n    setIsUploadingFile(true);\n    try {\n      // 在 Home 页面，始终上传为全局文件\n      const response = await uploadReferenceFile(file, null);\n      if (response?.data?.file) {\n        const uploadedFile = response.data.file;\n        setReferenceFiles(prev => [...prev, uploadedFile]);\n        show({ message: t('home.messages.fileUploadSuccess'), type: 'success' });\n        \n        // 如果文件状态为 pending，自动触发解析\n        if (uploadedFile.parse_status === 'pending') {\n          try {\n            const parseResponse = await triggerFileParse(uploadedFile.id);\n            // 使用解析接口返回的文件对象更新状态\n            if (parseResponse?.data?.file) {\n              const parsedFile = parseResponse.data.file;\n              setReferenceFiles(prev => \n                prev.map(f => f.id === uploadedFile.id ? parsedFile : f)\n              );\n            } else {\n              // 如果没有返回文件对象，手动更新状态为 parsing（异步线程会稍后更新）\n              setReferenceFiles(prev => \n                prev.map(f => f.id === uploadedFile.id ? { ...f, parse_status: 'parsing' as const } : f)\n              );\n            }\n          } catch (parseError: any) {\n            console.error('触发文件解析失败:', parseError);\n            // 解析触发失败不影响上传成功提示\n          }\n        }\n      } else {\n        show({ message: t('home.messages.fileUploadFailed'), type: 'error' });\n      }\n    } catch (error: any) {\n      console.error('文件上传失败:', error);\n      \n      // 特殊处理413错误\n      if (error?.response?.status === 413) {\n        show({\n          message: t('home.messages.fileTooLarge', { size: (file.size / 1024 / 1024).toFixed(1) }),\n          type: 'error'\n        });\n      } else {\n        show({\n          message: `${t('home.messages.fileUploadFailed')}: ${error?.response?.data?.error?.message || error.message || ''}`.replace(/: $/, ''),\n          type: 'error'\n        });\n      }\n    } finally {\n      setIsUploadingFile(false);\n    }\n  };\n\n  // 从当前项目移除文件引用（不删除文件本身）\n  const handleFileRemove = (fileId: string) => {\n    setReferenceFiles(prev => prev.filter(f => f.id !== fileId));\n  };\n\n  // 文件状态变化回调\n  const handleFileStatusChange = (updatedFile: ReferenceFile) => {\n    setReferenceFiles(prev => \n      prev.map(f => f.id === updatedFile.id ? updatedFile : f)\n    );\n  };\n\n  // 点击回形针按钮 - 打开文件选择器\n  const handlePaperclipClick = () => {\n    setIsFileSelectorOpen(true);\n  };\n\n  // 从选择器选择文件后的回调\n  const handleFilesSelected = (selectedFiles: ReferenceFile[]) => {\n    // 合并新选择的文件到列表（去重）\n    setReferenceFiles(prev => {\n      const existingIds = new Set(prev.map(f => f.id));\n      const newFiles = selectedFiles.filter(f => !existingIds.has(f.id));\n      // 合并时，如果文件已存在，更新其状态（可能解析状态已改变）\n      const updated = prev.map(f => {\n        const updatedFile = selectedFiles.find(sf => sf.id === f.id);\n        return updatedFile || f;\n      });\n      return [...updated, ...newFiles];\n    });\n    show({ message: t('home.messages.filesAdded', { count: selectedFiles.length }), type: 'success' });\n  };\n\n  // 获取当前已选择的文件ID列表，传递给选择器（使用 useMemo 避免每次渲染都重新计算）\n  const selectedFileIds = useMemo(() => {\n    return referenceFiles.map(f => f.id);\n  }, [referenceFiles]);\n\n  // 文件选择变化\n  const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {\n    const files = e.target.files;\n    if (!files || files.length === 0) return;\n\n    for (let i = 0; i < files.length; i++) {\n      await handleFileUpload(files[i]);\n    }\n\n    // 清空 input，允许重复选择同一文件\n    e.target.value = '';\n  };\n\n  const tabConfig = {\n    idea: {\n      icon: <Sparkles size={20} />,\n      label: t('home.tabs.idea'),\n      placeholder: t('home.placeholders.idea'),\n      description: t('home.tabDescriptions.idea'),\n      example: null as string | null,\n    },\n    outline: {\n      icon: <FileText size={20} />,\n      label: t('home.tabs.outline'),\n      placeholder: t('home.placeholders.outline'),\n      description: t('home.tabDescriptions.outline'),\n      example: t('home.examples.outline'),\n    },\n    description: {\n      icon: <FileEdit size={20} />,\n      label: t('home.tabs.description'),\n      placeholder: t('home.placeholders.description'),\n      description: t('home.tabDescriptions.description'),\n      example: t('home.examples.description'),\n    },\n    ppt_renovation: {\n      icon: <RefreshCw size={20} />,\n      label: t('home.tabs.ppt_renovation'),\n      placeholder: '',\n      description: t('home.tabDescriptions.ppt_renovation'),\n      example: null as string | null,\n    },\n  };\n\n  const handleTemplateSelect = async (templateFile: File | null, templateId?: string) => {\n    // 总是设置文件（如果提供）\n    if (templateFile) {\n      setSelectedTemplate(templateFile);\n    }\n    \n    // 处理模板 ID\n    if (templateId) {\n      // 判断是用户模板还是预设模板\n      // 预设模板 ID 通常是 '1', '2', '3' 等短字符串\n      // 用户模板 ID 通常较长（UUID 格式）\n      if (templateId.length <= 3 && /^\\d+$/.test(templateId)) {\n        // 预设模板\n        setSelectedPresetTemplateId(templateId);\n        setSelectedTemplateId(null);\n      } else {\n        // 用户模板\n        setSelectedTemplateId(templateId);\n        setSelectedPresetTemplateId(null);\n      }\n    } else {\n      // 如果没有 templateId，可能是直接上传的文件\n      // 清空所有选择状态\n      setSelectedTemplateId(null);\n      setSelectedPresetTemplateId(null);\n    }\n  };\n\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  const handleSubmit = async () => {\n    // For ppt_renovation, validate file instead of content\n    if (activeTab === 'ppt_renovation') {\n      if (!renovationFile) {\n        show({ message: t('home.renovation.uploadFile'), type: 'error' });\n        return;\n      }\n    } else if (!content.trim()) {\n      show({ message: t('home.messages.enterContent'), type: 'error' });\n      return;\n    }\n\n    // 检查是否有正在解析的文件\n    const parsingFiles = referenceFiles.filter(f =>\n      f.parse_status === 'pending' || f.parse_status === 'parsing'\n    );\n    if (parsingFiles.length > 0) {\n      show({\n        message: t('home.messages.filesParsing', { count: parsingFiles.length }),\n        type: 'info'\n      });\n      return;\n    }\n\n    setIsSubmitting(true);\n    try {\n      // PPT 翻新模式：走独立的上传+异步解析流程\n      if (activeTab === 'ppt_renovation' && renovationFile) {\n        const styleDesc = templateStyle.trim() ? templateStyle.trim() : undefined;\n        const result = await createPptRenovationProject(renovationFile, {\n          keepLayout,\n          templateStyle: styleDesc,\n        });\n\n        const projectId = result.data?.project_id;\n        const taskId = result.data?.task_id;\n        if (!projectId) {\n          show({ message: t('home.messages.projectCreateFailed'), type: 'error' });\n          return;\n        }\n\n        // Save project ID and task ID for DetailEditor to poll\n        localStorage.setItem('currentProjectId', projectId);\n        if (taskId) {\n          localStorage.setItem('renovationTaskId', taskId);\n        }\n\n        // Clear draft\n        sessionStorage.removeItem('home-draft-content');\n        sessionStorage.removeItem('home-draft-tab');\n\n        // Navigate to detail editor (will poll for task completion with skeleton UI)\n        navigate(`/project/${projectId}/detail`);\n        return;\n      }\n\n      // 如果有模板ID但没有File，按需加载\n      let templateFile = selectedTemplate;\n      if (!templateFile && (selectedTemplateId || selectedPresetTemplateId)) {\n        const templateId = selectedTemplateId || selectedPresetTemplateId;\n        if (templateId) {\n          templateFile = await getTemplateFile(templateId, userTemplates);\n        }\n      }\n      \n      // 传递风格描述（只要有内容就传递，不管开关状态）\n      const styleDesc = templateStyle.trim() ? templateStyle.trim() : undefined;\n\n      // 传递参考文件ID列表，确保 AI 生成时能读取参考文件内容\n      const refFileIds = referenceFiles\n        .filter(f => f.parse_status === 'completed')\n        .map(f => f.id);\n\n      await initializeProject(activeTab as 'idea' | 'outline' | 'description', content, templateFile || undefined, styleDesc, refFileIds.length > 0 ? refFileIds : undefined, aspectRatio);\n      \n      // 根据类型跳转到不同页面\n      const projectId = localStorage.getItem('currentProjectId');\n      if (!projectId) {\n        show({ message: t('home.messages.projectCreateFailed'), type: 'error' });\n        return;\n      }\n      \n      // 关联未完成解析的参考文件（已完成的在 initializeProject 中关联）\n      if (referenceFiles.length > 0) {\n        const unassociatedFiles = referenceFiles.filter(f => f.parse_status !== 'completed');\n        if (unassociatedFiles.length > 0) {\n          devLog(`Associating ${unassociatedFiles.length} remaining reference files to project ${projectId}:`, unassociatedFiles);\n          try {\n            await Promise.all(\n              unassociatedFiles.map(async file => {\n                const response = await associateFileToProject(file.id, projectId);\n                return response;\n              })\n            );\n          } catch (error) {\n            console.error('Failed to associate reference files:', error);\n          }\n        }\n      }\n      \n      // 关联图片素材到项目（解析content中的markdown图片链接）\n      const imageRegex = /!\\[([^\\]]*)\\]\\(([^)]+)\\)/g;\n      const materialUrls: string[] = [];\n      let match;\n      while ((match = imageRegex.exec(content)) !== null) {\n        materialUrls.push(match[2]); // match[2] 是 URL\n      }\n      \n      if (materialUrls.length > 0) {\n        devLog(`Associating ${materialUrls.length} materials to project ${projectId}:`, materialUrls);\n        try {\n          const response = await associateMaterialsToProject(projectId, materialUrls);\n          devLog('Materials associated successfully:', response);\n        } catch (error) {\n          console.error('Failed to associate materials:', error);\n          // 不影响主流程，继续执行\n        }\n      } else {\n        devLog('No materials to associate');\n      }\n      \n      if (activeTab === 'idea' || activeTab === 'outline') {\n        navigate(`/project/${projectId}/outline`);\n      } else if (activeTab === 'description') {\n        // 从描述生成：直接跳到描述生成页（因为已经自动生成了大纲和描述）\n        navigate(`/project/${projectId}/detail`);\n      }\n    } catch (error: any) {\n      console.error('创建项目失败:', error);\n      const msg = error?.response?.data?.error?.message || error?.message || t('home.messages.projectCreateFailed');\n      show({ message: msg, type: 'error' });\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  return (\n    <div className=\"min-h-screen bg-gradient-to-br from-yellow-50 via-orange-50/30 to-pink-50/50 dark:from-background-primary dark:via-background-primary dark:to-background-primary relative overflow-hidden\">\n      {/* 背景装饰元素 - 仅在亮色模式显示 */}\n      <div className=\"absolute inset-0 overflow-hidden pointer-events-none dark:hidden\">\n        <div className=\"absolute -top-40 -right-40 w-80 h-80 bg-banana-500/10 rounded-full blur-3xl animate-pulse\"></div>\n        <div className=\"absolute -bottom-40 -left-40 w-96 h-96 bg-orange-400/10 rounded-full blur-3xl animate-pulse\" style={{ animationDelay: '1s' }}></div>\n        <div className=\"absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-72 h-72 bg-yellow-400/5 rounded-full blur-3xl\"></div>\n      </div>\n\n      {/* 导航栏 */}\n      <nav className=\"relative z-50 h-16 md:h-18 bg-white/40 dark:bg-background-primary backdrop-blur-2xl dark:backdrop-blur-none dark:border-b dark:border-border-primary\">\n\n        <div className=\"max-w-7xl mx-auto px-4 md:px-6 h-full flex items-center justify-between\">\n          <div className=\"flex items-center gap-3\">\n            <div className=\"flex items-center\">\n              <img\n                src=\"/logo.png\"\n                alt=\"蕉幻 Banana Slides Logo\"\n                className=\"h-10 md:h-12 w-auto rounded-lg object-contain\"\n              />\n            </div>\n            <span className=\"text-xl md:text-2xl font-bold bg-gradient-to-r from-banana-600 via-orange-500 to-pink-500 bg-clip-text text-transparent\">\n              蕉幻\n            </span>\n          </div>\n          <div className=\"flex items-center gap-2 md:gap-3\">\n            {/* 桌面端：带文字的素材生成按钮 */}\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              icon={<ImagePlus size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={handleOpenMaterialModal}\n              className=\"hidden sm:inline-flex hover:bg-banana-100/60 hover:shadow-sm hover:scale-105 transition-all duration-200 font-medium\"\n            >\n              <span className=\"hidden md:inline\">{t('nav.materialGenerate')}</span>\n            </Button>\n            {/* 手机端：仅图标的素材生成按钮 */}\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              icon={<ImagePlus size={16} />}\n              onClick={handleOpenMaterialModal}\n              className=\"sm:hidden hover:bg-banana-100/60 hover:shadow-sm hover:scale-105 transition-all duration-200\"\n              title={t('nav.materialGenerate')}\n            />\n            {/* 桌面端：带文字的素材中心按钮 */}\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              icon={<FolderOpen size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={() => setIsMaterialCenterOpen(true)}\n              className=\"hidden sm:inline-flex hover:bg-banana-100/60 hover:shadow-sm hover:scale-105 transition-all duration-200 font-medium\"\n            >\n              <span className=\"hidden md:inline\">{t('nav.materialCenter')}</span>\n            </Button>\n            {/* 手机端：仅图标的素材中心按钮 */}\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              icon={<FolderOpen size={16} />}\n              onClick={() => setIsMaterialCenterOpen(true)}\n              className=\"sm:hidden hover:bg-banana-100/60 hover:shadow-sm hover:scale-105 transition-all duration-200\"\n              title={t('nav.materialCenter')}\n            />\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              onClick={() => navigate('/history')}\n              className=\"text-xs md:text-sm hover:bg-banana-100/60 hover:shadow-sm hover:scale-105 transition-all duration-200 font-medium\"\n            >\n              <span className=\"hidden sm:inline\">{t('nav.history')}</span>\n              <span className=\"sm:hidden\">{t('nav.history')}</span>\n            </Button>\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              icon={<Settings size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={() => navigate('/settings')}\n              className=\"text-xs md:text-sm hover:bg-banana-100/60 hover:shadow-sm hover:scale-105 transition-all duration-200 font-medium\"\n            >\n              <span className=\"hidden md:inline\">{t('nav.settings')}</span>\n            </Button>\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              onClick={() => setIsHelpModalOpen(true)}\n              className=\"hidden md:inline-flex hover:bg-banana-50/50\"\n            >\n              {t('nav.help')}\n            </Button>\n            {/* 移动端帮助按钮 */}\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              icon={<HelpCircle size={16} />}\n              onClick={() => setIsHelpModalOpen(true)}\n              className=\"md:hidden hover:bg-banana-100/60 hover:shadow-sm hover:scale-105 transition-all duration-200\"\n              title={t('nav.help')}\n            />\n            {/* 分隔线 */}\n            <div className=\"h-5 w-px bg-gray-300 dark:bg-border-primary mx-1\" />\n            {/* 语言切换按钮 */}\n            <button\n              onClick={() => i18n.changeLanguage(i18n.language?.startsWith('zh') ? 'en' : 'zh')}\n              className=\"flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-600 dark:text-foreground-tertiary hover:text-gray-900 dark:hover:text-gray-100 hover:bg-banana-100/60 dark:hover:bg-background-hover rounded-md transition-all\"\n              title={t('settings.language.label')}\n            >\n              <Globe size={14} />\n              <span>{i18n.language?.startsWith('zh') ? 'EN' : '中'}</span>\n            </button>\n            {/* 主题切换按钮 */}\n            <div className=\"relative\" ref={themeMenuRef}>\n              <button\n                onClick={() => setIsThemeMenuOpen(!isThemeMenuOpen)}\n                className=\"flex items-center gap-1 p-1.5 text-gray-600 dark:text-foreground-tertiary hover:text-gray-900 dark:hover:text-gray-100 hover:bg-banana-100/60 dark:hover:bg-background-hover rounded-md transition-all\"\n                title={t('settings.theme.label')}\n              >\n                {theme === 'system' ? <Monitor size={16} /> : isDark ? <Moon size={16} /> : <Sun size={16} />}\n                <ChevronDown size={12} className={`transition-transform ${isThemeMenuOpen ? 'rotate-180' : ''}`} />\n              </button>\n              {/* 主题下拉菜单 */}\n              {isThemeMenuOpen && (\n                <>\n                  <div className=\"fixed inset-0 z-40\" onClick={() => setIsThemeMenuOpen(false)} />\n                  <div className=\"absolute right-0 top-full mt-1 z-50 bg-white dark:bg-background-secondary border border-gray-200 dark:border-border-primary rounded-lg shadow-lg dark:shadow-none py-1 min-w-[120px]\">\n                    <button\n                      onClick={() => { setTheme('light'); setIsThemeMenuOpen(false); }}\n                      className={`w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-background-hover transition-colors ${theme === 'light' ? 'text-banana' : 'text-gray-700 dark:text-foreground-secondary'}`}\n                    >\n                      <Sun size={14} />\n                      <span>{t('settings.theme.light')}</span>\n                    </button>\n                    <button\n                      onClick={() => { setTheme('dark'); setIsThemeMenuOpen(false); }}\n                      className={`w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-background-hover transition-colors ${theme === 'dark' ? 'text-banana' : 'text-gray-700 dark:text-foreground-secondary'}`}\n                    >\n                      <Moon size={14} />\n                      <span>{t('settings.theme.dark')}</span>\n                    </button>\n                    <button\n                      onClick={() => { setTheme('system'); setIsThemeMenuOpen(false); }}\n                      className={`w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-background-hover transition-colors ${theme === 'system' ? 'text-banana' : 'text-gray-700 dark:text-foreground-secondary'}`}\n                    >\n                      <Monitor size={14} />\n                      <span>{t('settings.theme.system')}</span>\n                    </button>\n                  </div>\n                </>\n              )}\n            </div>\n            {/* 分隔线 */}\n            <div className=\"h-5 w-px bg-gray-300 dark:bg-border-primary mx-1\" />\n            {/* GitHub 仓库卡片 */}\n            <GithubRepoCard />\n            {/* 分隔线 */}\n          </div>\n        </div>\n      </nav>\n\n      {/* 主内容 */}\n      <main className=\"relative max-w-5xl mx-auto px-3 md:px-4 py-8 md:py-12\">\n        {/* Hero 标题区 */}\n        <div className=\"text-center mb-10 md:mb-16 space-y-4 md:space-y-6\">\n          <div className=\"inline-flex items-center gap-2 px-4 py-2 bg-white/60 dark:bg-background-secondary backdrop-blur-sm rounded-full shadow-sm dark:shadow-none mb-4\">\n            <span className=\"text-2xl animate-pulse\"><Sparkles size={20} className=\"text-orange-500 dark:text-banana\" /></span>\n            <span className=\"text-sm font-medium text-gray-700 dark:text-foreground-secondary\">{t('home.tagline')}</span>\n          </div>\n\n          <h1 className=\"text-4xl md:text-6xl lg:text-7xl font-extrabold leading-tight\">\n            <span className=\"bg-gradient-to-r from-yellow-600 via-orange-500 to-pink-500 dark:from-banana-dark dark:via-banana dark:to-banana-light bg-clip-text text-transparent dark:italic\" style={{\n              backgroundSize: '200% auto',\n              animation: 'gradient 3s ease infinite',\n            }}>\n              {i18n.language?.startsWith('zh') ? `${t('home.title')} · Banana Slides` : 'Banana Slides'}\n            </span>\n          </h1>\n\n          <p className=\"text-lg md:text-xl text-gray-600 dark:text-foreground-secondary max-w-2xl mx-auto font-light\">\n            {t('home.subtitle')}\n          </p>\n\n          {/* 特性标签 */}\n          <div className=\"flex flex-wrap items-center justify-center gap-2 md:gap-3 pt-4\">\n            {[\n              { icon: <Sparkles size={14} className=\"text-yellow-600 dark:text-banana\" />, label: t('home.features.oneClick') },\n              { icon: <FileEdit size={14} className=\"text-blue-500 dark:text-blue-400\" />, label: t('home.features.naturalEdit') },\n              { icon: <Search size={14} className=\"text-orange-500 dark:text-orange-400\" />, label: t('home.features.regionEdit') },\n\n              { icon: <Paperclip size={14} className=\"text-green-600 dark:text-green-400\" />, label: t('home.features.export') },\n            ].map((feature, idx) => (\n              <span\n                key={idx}\n                className=\"inline-flex items-center gap-1 px-3 py-1.5 bg-white/70 dark:bg-background-secondary backdrop-blur-sm rounded-full text-xs md:text-sm text-gray-700 dark:text-foreground-secondary border border-gray-200/50 dark:border-border-primary shadow-sm dark:shadow-none hover:shadow-md dark:hover:border-border-hover transition-all hover:scale-105 cursor-default\"\n              >\n                {feature.icon}\n                {feature.label}\n              </span>\n            ))}\n          </div>\n        </div>\n\n        {/* 创建卡片 */}\n        <Card className=\"p-4 md:p-10 bg-white/90 dark:bg-background-secondary backdrop-blur-xl dark:backdrop-blur-none shadow-2xl dark:shadow-none border-0 dark:border dark:border-border-primary hover:shadow-3xl dark:hover:shadow-none transition-all duration-300 dark:rounded-2xl\">\n          {/* 选项卡 */}\n          <div className=\"flex flex-col sm:flex-row gap-2 sm:gap-4 mb-6 md:mb-8\">\n            {(Object.keys(tabConfig) as CreationType[]).map((type) => {\n              const config = tabConfig[type];\n              return (\n                <button\n                  key={type}\n                  onClick={() => setActiveTab(type)}\n                  className={`flex-1 flex items-center justify-center gap-1.5 md:gap-2 px-3 md:px-6 py-2.5 md:py-3 rounded-lg dark:rounded-xl font-medium transition-all text-sm md:text-base touch-manipulation ${\n                    activeTab === type\n                      ? 'bg-gradient-to-r from-banana-500 to-banana-600 dark:from-banana dark:to-banana text-black shadow-yellow dark:shadow-lg dark:shadow-banana/20'\n                      : 'bg-white dark:bg-background-elevated border border-gray-200 dark:border-border-primary text-gray-700 dark:text-foreground-secondary hover:bg-banana-50 dark:hover:bg-background-hover active:bg-banana-100'\n                  }`}\n                >\n                  <span className=\"scale-90 md:scale-100\">{config.icon}</span>\n                  <span className=\"truncate\">{config.label}</span>\n                </button>\n              );\n            })}\n          </div>\n\n          {/* 描述 */}\n          <div className=\"relative\">\n            <p className=\"text-sm md:text-base mb-4 md:mb-6 leading-relaxed\">\n              <span className=\"inline-flex items-center gap-2 text-gray-600 dark:text-foreground-tertiary\">\n                <Lightbulb size={16} className=\"text-banana-600 dark:text-banana flex-shrink-0\" />\n                <span className=\"font-semibold\">\n                  {tabConfig[activeTab].description}\n                </span>\n                {tabConfig[activeTab].example && (\n                  <span className=\"relative group/tip inline-flex\">\n                    <HelpCircle size={15} className=\"text-gray-400 dark:text-foreground-tertiary hover:text-banana-600 dark:hover:text-banana cursor-help transition-colors\" />\n                    <span className=\"absolute left-1/2 -translate-x-1/2 bottom-full mb-2 hidden group-hover/tip:block z-50 w-72 md:w-80 p-3 bg-white dark:bg-background-elevated border border-gray-200 dark:border-border-primary rounded-lg shadow-xl dark:shadow-none text-xs text-gray-700 dark:text-foreground-secondary whitespace-pre-line leading-relaxed\">\n                      {tabConfig[activeTab].example}\n                      <span className=\"absolute left-1/2 -translate-x-1/2 top-full -mt-px w-2 h-2 bg-white dark:bg-background-elevated border-r border-b border-gray-200 dark:border-border-primary rotate-45\" />\n                    </span>\n                  </span>\n                )}\n              </span>\n            </p>\n          </div>\n\n          {/* 输入区 - 带工具栏 */}\n          <div className=\"mb-2\">\n            {activeTab === 'ppt_renovation' ? (\n              /* PPT 翻新：文件上传区 */\n              <div className=\"space-y-4\">\n                <div\n                  className=\"border-2 border-dashed border-gray-300 dark:border-border-primary rounded-xl p-8 text-center cursor-pointer hover:border-banana-400 dark:hover:border-banana transition-colors duration-200\"\n                  onClick={() => renovationFileInputRef.current?.click()}\n                  onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}\n                  onDrop={(e) => {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    const file = e.dataTransfer.files[0];\n                    if (file && (file.name.toLowerCase().endsWith('.pdf') || file.name.toLowerCase().endsWith('.pptx') || file.name.toLowerCase().endsWith('.ppt'))) {\n                      setRenovationFile(file);\n                      const ext = file.name.split('.').pop()?.toLowerCase();\n                      if (ext === 'ppt' || ext === 'pptx') {\n                        show({ message: `💡 ${t('home.messages.pptTip')}`, type: 'info' });\n                      }\n                    } else {\n                      show({ message: t('home.renovation.onlyPdfPptx'), type: 'error' });\n                    }\n                  }}\n                >\n                  {renovationFile ? (\n                    <div className=\"flex items-center justify-center gap-3\">\n                      <FileText size={24} className=\"text-banana-600 dark:text-banana\" />\n                      <div className=\"text-left\">\n                        <p className=\"text-sm font-medium text-gray-900 dark:text-white\">{renovationFile.name}</p>\n                        <p className=\"text-xs text-gray-500 dark:text-foreground-tertiary\">{(renovationFile.size / 1024 / 1024).toFixed(1)} MB</p>\n                      </div>\n                      <button\n                        type=\"button\"\n                        onClick={(e) => { e.stopPropagation(); setRenovationFile(null); }}\n                        className=\"ml-2 text-gray-400 hover:text-red-500 transition-colors\"\n                      >\n                        ✕\n                      </button>\n                    </div>\n                  ) : (\n                    <div className=\"space-y-2\">\n                      <Upload size={32} className=\"mx-auto text-gray-400 dark:text-foreground-tertiary\" />\n                      <p className=\"text-sm text-gray-600 dark:text-foreground-secondary\">{t('home.renovation.uploadHint')}</p>\n                      <p className=\"text-xs text-gray-400 dark:text-foreground-tertiary\">{t('home.renovation.formatHint')}</p>\n                    </div>\n                  )}\n                </div>\n                <input\n                  ref={renovationFileInputRef}\n                  type=\"file\"\n                  accept=\".pdf,.pptx,.ppt\"\n                  onChange={(e) => {\n                    const file = e.target.files?.[0];\n                    if (file) {\n                      setRenovationFile(file);\n                      const ext = file.name.split('.').pop()?.toLowerCase();\n                      if (ext === 'ppt' || ext === 'pptx') {\n                        show({ message: `💡 ${t('home.messages.pptTip')}`, type: 'info' });\n                      }\n                    }\n                    e.target.value = '';\n                  }}\n                  className=\"hidden\"\n                />\n\n                {/* 保留布局 toggle */}\n                <div className=\"flex items-center justify-between\">\n                  <label className=\"flex items-center gap-2 cursor-pointer group\">\n                    <span className=\"text-sm text-gray-600 dark:text-foreground-tertiary group-hover:text-gray-900 dark:group-hover:text-white transition-colors\">\n                      {t('home.renovation.keepLayout')}\n                    </span>\n                    <div className=\"relative\">\n                      <input\n                        type=\"checkbox\"\n                        checked={keepLayout}\n                        onChange={(e) => setKeepLayout(e.target.checked)}\n                        className=\"sr-only peer\"\n                      />\n                      <div className=\"w-11 h-6 bg-gray-200 dark:bg-background-hover peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-banana-300 dark:peer-focus:ring-banana/30 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white dark:after:bg-foreground-secondary after:border-gray-300 dark:after:border-border-hover after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-banana\"></div>\n                    </div>\n                  </label>\n                  <Button\n                    size=\"sm\"\n                    onClick={handleSubmit}\n                    loading={isSubmitting || isGlobalLoading}\n                    disabled={!renovationFile}\n                    className=\"shadow-sm dark:shadow-background-primary/30 text-xs md:text-sm px-3 md:px-4\"\n                  >\n                    {t('common.next')}\n                  </Button>\n                </div>\n              </div>\n            ) : (\n            <MarkdownTextarea\n              ref={textareaRef}\n              placeholder={tabConfig[activeTab].placeholder}\n              value={content}\n              onChange={setContent}\n              onPaste={handlePaste}\n              onFiles={handleImageFiles}\n              rows={activeTab === 'idea' ? 4 : 8}\n              className=\"text-sm md:text-base border-2 border-gray-200 dark:border-border-primary dark:bg-background-tertiary dark:text-white focus-within:border-banana-400 dark:focus-within:border-banana transition-colors duration-200\"\n              toolbarLeft={\n                <div className=\"flex items-center gap-1\">\n                  <button\n                    type=\"button\"\n                    onClick={handlePaperclipClick}\n                    className=\"p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:text-foreground-tertiary dark:hover:text-foreground-secondary dark:hover:bg-background-hover rounded transition-colors active:scale-95 touch-manipulation\"\n                    title={t('home.actions.selectFile')}\n                  >\n                    <Paperclip size={18} />\n                  </button>\n                  {/* 画面比例选择 */}\n                  <div className=\"relative\">\n                    <button\n                      type=\"button\"\n                      onClick={() => setIsAspectRatioOpen(!isAspectRatioOpen)}\n                      className=\"flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:text-foreground-tertiary dark:hover:text-foreground-secondary dark:hover:bg-background-hover rounded transition-colors\"\n                      title={i18n.language?.startsWith('zh') ? '画面比例' : 'Aspect Ratio'}\n                    >\n                      <span>{aspectRatio}</span>\n                      <ChevronDown size={12} className={`transition-transform ${isAspectRatioOpen ? 'rotate-180' : ''}`} />\n                    </button>\n                    {isAspectRatioOpen && (\n                      <>\n                        <div className=\"fixed inset-0 z-40\" onClick={() => setIsAspectRatioOpen(false)} />\n                        <div className=\"absolute left-0 bottom-full mb-1 z-50 bg-white dark:bg-background-elevated border border-gray-200 dark:border-border-primary rounded-lg shadow-lg dark:shadow-none py-1 min-w-[80px]\">\n                          {ASPECT_RATIO_OPTIONS.map((opt) => (\n                            <button\n                              key={opt.value}\n                              onClick={() => { setAspectRatio(opt.value); setIsAspectRatioOpen(false); }}\n                              className={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-background-hover transition-colors ${aspectRatio === opt.value ? 'text-banana font-semibold' : 'text-gray-700 dark:text-foreground-secondary'}`}\n                            >\n                              {opt.label}\n                            </button>\n                          ))}\n                        </div>\n                      </>\n                    )}\n                  </div>\n                </div>\n              }\n              toolbarRight={\n                <Button\n                  size=\"sm\"\n                  onClick={handleSubmit}\n                  loading={isSubmitting || isGlobalLoading}\n                  disabled={\n                    !content.trim() ||\n                    isUploadingImage ||\n                    referenceFiles.some(f => f.parse_status === 'pending' || f.parse_status === 'parsing')\n                  }\n                  className=\"shadow-sm dark:shadow-background-primary/30 text-xs md:text-sm px-3 md:px-4\"\n                >\n                  {referenceFiles.some(f => f.parse_status === 'pending' || f.parse_status === 'parsing')\n                    ? t('home.actions.parsing')\n                    : t('common.next')}\n                </Button>\n              }\n            />\n            )}\n          </div>\n\n          {/* 隐藏的文件输入 */}\n          <input\n            ref={fileInputRef}\n            type=\"file\"\n            multiple\n            accept=\".pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.csv,.txt,.md\"\n            onChange={handleFileSelect}\n            className=\"hidden\"\n          />\n\n          <ReferenceFileList\n            files={referenceFiles}\n            onFileClick={setPreviewFileId}\n            onFileDelete={handleFileRemove}\n            onFileStatusChange={handleFileStatusChange}\n            deleteMode=\"remove\"\n            className=\"mb-4\"\n            showToast={show}\n          />\n\n          {/* 模板选择 */}\n          <div className=\"mb-6 md:mb-8 pt-4 border-t border-gray-100 dark:border-border-primary\">\n            <div className=\"flex items-center justify-between mb-3 md:mb-4\">\n              <div className=\"flex items-center gap-2\">\n                <Palette size={18} className=\"text-orange-600 dark:text-banana flex-shrink-0\" />\n                <h3 className=\"text-base md:text-lg font-semibold text-gray-900 dark:text-white\">\n                  {t('home.template.title')}\n                </h3>\n              </div>\n              {/* 无模板图模式开关 */}\n              <label className=\"flex items-center gap-2 cursor-pointer group\">\n                <span className=\"text-sm text-gray-600 dark:text-foreground-tertiary group-hover:text-gray-900 dark:group-hover:text-white transition-colors\">\n                  {t('home.template.useTextStyle')}\n                </span>\n                <div className=\"relative\">\n                  <input\n                    type=\"checkbox\"\n                    checked={useTemplateStyle}\n                    onChange={(e) => {\n                      setUseTemplateStyle(e.target.checked);\n                      // 切换到无模板图模式时，清空模板选择\n                      if (e.target.checked) {\n                        setSelectedTemplate(null);\n                        setSelectedTemplateId(null);\n                        setSelectedPresetTemplateId(null);\n                      }\n                      // 不再清空风格描述，允许用户保留已输入的内容\n                    }}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-gray-200 dark:bg-background-hover peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-banana-300 dark:peer-focus:ring-banana/30 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white dark:after:bg-foreground-secondary after:border-gray-300 dark:after:border-border-hover after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-banana\"></div>\n                </div>\n              </label>\n            </div>\n            \n            {/* 根据模式显示不同的内容 */}\n            {useTemplateStyle ? (\n              <TextStyleSelector\n                value={templateStyle}\n                onChange={setTemplateStyle}\n                onToast={show}\n              />\n            ) : (\n              <TemplateSelector\n                onSelect={handleTemplateSelect}\n                selectedTemplateId={selectedTemplateId}\n                selectedPresetTemplateId={selectedPresetTemplateId}\n                showUpload={true} // 在主页上传的模板保存到用户模板库\n                projectId={currentProjectId}\n              />\n            )}\n          </div>\n\n        </Card>\n      </main>\n      <ToastContainer />\n      {/* 素材生成模态 - 在主页始终生成全局素材 */}\n      <MaterialGeneratorModal\n        projectId={null}\n        isOpen={isMaterialModalOpen}\n        onClose={() => setIsMaterialModalOpen(false)}\n      />\n      {/* 素材中心模态 */}\n      <MaterialCenterModal\n        isOpen={isMaterialCenterOpen}\n        onClose={() => setIsMaterialCenterOpen(false)}\n      />\n      {/* 参考文件选择器 */}\n      {/* 在 Home 页面，始终查询全局文件，因为此时还没有项目 */}\n      <ReferenceFileSelector\n        projectId={null}\n        isOpen={isFileSelectorOpen}\n        onClose={() => setIsFileSelectorOpen(false)}\n        onSelect={handleFilesSelected}\n        multiple={true}\n        initialSelectedIds={selectedFileIds}\n      />\n      \n      <FilePreviewModal fileId={previewFileId} onClose={() => setPreviewFileId(null)} />\n      {/* 帮助模态框 */}\n      <HelpModal\n        isOpen={isHelpModalOpen}\n        onClose={() => setIsHelpModalOpen(false)}\n      />\n      {/* Footer */}\n      <Footer />\n    </div>\n  );\n};\n"
  },
  {
    "path": "frontend/src/pages/Landing.tsx",
    "content": "import React, { useState, useEffect } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { useTranslation } from 'react-i18next';\nimport { Sparkles, FileText, MessageSquare, Download, ChevronRight, Github, ChevronLeft } from 'lucide-react';\nimport { Button, Footer } from '@/components/shared';\nimport { useT } from '@/hooks/useT';\n\n// 组件内翻译\nconst landingI18n = {\n  zh: {\n    landing: {\n      nav: { enter: \"进入应用\" },\n      hero: {\n        badge: \"新一代 AI 演示文稿生成器\",\n        title_start: \"让创意\",\n        title_highlight: \"瞬间落地\",\n        title_end: \"无需繁琐排版\",\n        subtitle: \"专注于您的内容与想法，剩下的交给 Banana Slides。从大纲到精美幻灯片，只需几分钟。\",\n        cta_primary: \"免费开始使用\"\n      }\n    }\n  },\n  en: {\n    landing: {\n      nav: { enter: \"Enter App\" },\n      hero: {\n        badge: \"Next Gen AI Presentation Generator\",\n        title_start: \"Turn Ideas into\",\n        title_highlight: \"Reality Instantly\",\n        title_end: \"No Formatting Hassle\",\n        subtitle: \"Focus on your content and ideas, leave the rest to Banana Slides. From outline to beautiful slides in seconds.\",\n        cta_primary: \"Get Started for Free\"\n      }\n    }\n  }\n};\n\n// Feature keys consistent with HelpModal\nconst _featureKeys = ['flexiblePaths', 'materialParsing', 'vibeEditing', 'easyExport'] as const;\n\n// Showcase data consistent with HelpModal\nconst showcaseKeys = [\n  { image: 'https://github.com/user-attachments/assets/d58ce3f7-bcec-451d-a3b9-ca3c16223644', titleKey: 'softwareDev' },\n  { image: 'https://github.com/user-attachments/assets/c64cd952-2cdf-4a92-8c34-0322cbf3de4e', titleKey: 'deepseek' },\n  { image: 'https://github.com/user-attachments/assets/383eb011-a167-4343-99eb-e1d0568830c7', titleKey: 'prefabFood' },\n  { image: 'https://github.com/user-attachments/assets/1a63afc9-ad05-4755-8480-fc4aa64987f1', titleKey: 'moneyHistory' },\n];\n\nexport const Landing: React.FC = () => {\n  const navigate = useNavigate();\n  const { i18n } = useTranslation();\n  const t = useT(landingI18n);\n  const [currentShowcase, setCurrentShowcase] = useState(0);\n\n  // Auto-rotate showcase\n  useEffect(() => {\n    const timer = setInterval(() => {\n      setCurrentShowcase((prev) => (prev + 1) % showcaseKeys.length);\n    }, 5000);\n    return () => clearInterval(timer);\n  }, []);\n\n  const features = [\n    {\n      key: 'flexiblePaths',\n      icon: <Sparkles size={24} className=\"text-yellow-600 dark:text-banana\" />,\n      bg: \"bg-yellow-50 dark:bg-yellow-900/10 border-yellow-100 dark:border-yellow-900/20\"\n    },\n    {\n      key: 'materialParsing',\n      icon: <FileText size={24} className=\"text-blue-600 dark:text-blue-400\" />,\n      bg: \"bg-blue-50 dark:bg-blue-900/10 border-blue-100 dark:border-blue-900/20\"\n    },\n    {\n      key: 'vibeEditing',\n      icon: <MessageSquare size={24} className=\"text-green-600 dark:text-green-400\" />,\n      bg: \"bg-green-50 dark:bg-green-900/10 border-green-100 dark:border-green-900/20\"\n    },\n    {\n      key: 'easyExport',\n      icon: <Download size={24} className=\"text-purple-600 dark:text-purple-400\" />,\n      bg: \"bg-purple-50 dark:bg-purple-900/10 border-purple-100 dark:border-purple-900/20\"\n    }\n  ];\n\n  return (\n    <div className=\"min-h-screen bg-white dark:bg-background-primary relative overflow-hidden flex flex-col font-sans\">\n      {/* 动态背景 */}\n      <div className=\"absolute inset-0 overflow-hidden pointer-events-none\">\n        <div className=\"absolute top-0 right-0 w-[800px] h-[800px] bg-gradient-to-bl from-banana-100/40 to-transparent dark:from-banana-900/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/4 animate-float-slow\"></div>\n        <div className=\"absolute bottom-0 left-0 w-[600px] h-[600px] bg-gradient-to-tr from-orange-100/40 to-transparent dark:from-orange-900/5 rounded-full blur-3xl translate-y-1/3 -translate-x-1/4 animate-float-delayed\"></div>\n      </div>\n\n      {/* 导航栏 */}\n      <nav className=\"relative z-50 w-full px-6 py-6 flex items-center justify-between max-w-7xl mx-auto\">\n        <div className=\"flex items-center gap-2\">\n          <img src=\"/logo.png\" alt=\"Logo\" className=\"w-8 h-8 md:w-10 md:h-10 object-contain rounded-lg shadow-sm\" />\n          <span className=\"text-xl md:text-2xl font-bold bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 bg-clip-text text-transparent\">\n            Banana Slides\n          </span>\n        </div>\n        <div className=\"flex items-center gap-4\">\n          <button\n            onClick={() => i18n.changeLanguage(i18n.language?.startsWith('zh') ? 'en' : 'zh')}\n            className=\"text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-black dark:hover:text-white transition-colors px-3 py-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-white/10\"\n          >\n            {i18n.language?.startsWith('zh') ? 'EN' : '中'}\n          </button>\n          <Button \n            variant=\"primary\" \n            size=\"sm\"\n            onClick={() => navigate('/')}\n            className=\"shadow-lg shadow-banana-500/20 hover:shadow-banana-500/30 transition-all\"\n          >\n            {t('landing.nav.enter')}\n          </Button>\n        </div>\n      </nav>\n\n      {/* Hero 区域 */}\n      <main className=\"flex-1 relative z-10 flex flex-col justify-center max-w-7xl mx-auto px-6 py-12 lg:py-20 text-center\">\n        <div className=\"space-y-8 animate-fade-in-up\">\n          <div className=\"inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/80 dark:bg-white/5 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-300 text-sm font-medium mx-auto backdrop-blur-sm shadow-sm\">\n            <span className=\"relative flex h-2 w-2\">\n              <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-banana-400 opacity-75\"></span>\n              <span className=\"relative inline-flex rounded-full h-2 w-2 bg-banana-500\"></span>\n            </span>\n            {t('landing.hero.badge')}\n          </div>\n\n          <h1 className=\"text-5xl md:text-7xl lg:text-8xl font-black tracking-tight text-gray-900 dark:text-white leading-[1.1]\">\n            {t('landing.hero.title_start')}\n            <span className=\"text-transparent bg-clip-text bg-gradient-to-r from-banana-500 via-orange-500 to-pink-500 px-2 relative inline-block\">\n              {t('landing.hero.title_highlight')}\n              <svg className=\"absolute w-full h-3 -bottom-1 left-0 text-banana-200 dark:text-banana-900/30 -z-10\" viewBox=\"0 0 100 10\" preserveAspectRatio=\"none\">\n                <path d=\"M0 5 Q 50 10 100 5\" stroke=\"currentColor\" strokeWidth=\"8\" fill=\"none\" />\n              </svg>\n            </span>\n            <br className=\"hidden md:block\" />\n            {t('landing.hero.title_end')}\n          </h1>\n\n          <p className=\"text-lg md:text-xl text-gray-500 dark:text-gray-400 max-w-2xl mx-auto leading-relaxed font-light\">\n            {t('landing.hero.subtitle')}\n          </p>\n\n          <div className=\"flex flex-col sm:flex-row items-center justify-center gap-4 pt-4\">\n            <Button\n              size=\"lg\"\n              className=\"w-full sm:w-auto text-base px-8 py-6 rounded-full shadow-xl shadow-banana-500/20 hover:shadow-banana-500/30 hover:-translate-y-1 transition-all duration-300 font-bold\"\n              onClick={() => navigate('/')}\n              icon={<ChevronRight size={20} />}\n            >\n              {t('landing.hero.cta_primary')}\n            </Button>\n            <a\n              href=\"https://github.com/Anionex/banana-slides\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"w-full sm:w-auto inline-flex items-center justify-center gap-2 px-8 py-4 rounded-full bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 text-gray-700 dark:text-gray-200 font-medium hover:bg-gray-50 dark:hover:bg-white/10 transition-all duration-200 hover:shadow-md\"\n            >\n              <Github size={20} />\n              GitHub\n            </a>\n          </div>\n\n          {/* 案例展示区域 (Carousel) */}\n          <div className=\"relative mt-20 mx-auto max-w-5xl rounded-2xl overflow-hidden shadow-2xl border border-gray-200/50 dark:border-white/10 bg-white dark:bg-gray-900 animate-float-slow group\">\n            {/* 顶部标题栏模拟 */}\n            <div className=\"h-8 bg-gray-50 dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700 flex items-center px-4 gap-2\">\n              <div className=\"flex gap-1.5\">\n                <div className=\"w-3 h-3 rounded-full bg-red-400\"></div>\n                <div className=\"w-3 h-3 rounded-full bg-yellow-400\"></div>\n                <div className=\"w-3 h-3 rounded-full bg-green-400\"></div>\n              </div>\n              <div className=\"flex-1 text-center text-xs text-gray-400 dark:text-gray-500 font-mono\">\n                {t(`help.showcaseTitles.${showcaseKeys[currentShowcase].titleKey}`)}\n              </div>\n            </div>\n\n            <div className=\"relative aspect-video bg-gray-100 dark:bg-gray-800 overflow-hidden\">\n               {showcaseKeys.map((showcase, idx) => (\n                <img \n                  key={idx}\n                  src={showcase.image}\n                  alt={t(`help.showcaseTitles.${showcase.titleKey}`)}\n                  className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-700 ease-in-out ${\n                    idx === currentShowcase ? 'opacity-100' : 'opacity-0'\n                  }`}\n                />\n              ))}\n              \n              {/* 左右切换按钮 */}\n              <button \n                className=\"absolute left-4 top-1/2 -translate-y-1/2 p-2 rounded-full bg-white/80 dark:bg-black/50 text-gray-800 dark:text-white opacity-0 group-hover:opacity-100 transition-opacity hover:scale-110 shadow-lg backdrop-blur-sm\"\n                onClick={() => setCurrentShowcase((prev) => (prev === 0 ? showcaseKeys.length - 1 : prev - 1))}\n              >\n                <ChevronLeft size={24} />\n              </button>\n              <button \n                className=\"absolute right-4 top-1/2 -translate-y-1/2 p-2 rounded-full bg-white/80 dark:bg-black/50 text-gray-800 dark:text-white opacity-0 group-hover:opacity-100 transition-opacity hover:scale-110 shadow-lg backdrop-blur-sm\"\n                onClick={() => setCurrentShowcase((prev) => (prev + 1) % showcaseKeys.length)}\n              >\n                <ChevronRight size={24} />\n              </button>\n\n              {/* 底部指示器 */}\n              <div className=\"absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2\">\n                {showcaseKeys.map((_, idx) => (\n                  <button\n                    key={idx}\n                    onClick={() => setCurrentShowcase(idx)}\n                    className={`h-1.5 rounded-full transition-all duration-300 ${\n                      idx === currentShowcase \n                        ? 'w-6 bg-white shadow-sm' \n                        : 'w-1.5 bg-white/50 hover:bg-white/70'\n                    }`}\n                  />\n                ))}\n              </div>\n            </div>\n          </div>\n        </div>\n      </main>\n\n      {/* 特性区域 */}\n      <div className=\"relative z-10 bg-white dark:bg-black/20\">\n        {features.map((feature, idx) => (\n          <section \n            key={idx} \n            className={`min-h-[80vh] flex items-center py-24 ${\n              idx % 2 === 0 ? 'bg-gray-50/50 dark:bg-white/5' : 'bg-white dark:bg-transparent'\n            }`}\n          >\n            <div className=\"max-w-7xl mx-auto px-6 w-full\">\n              <div className={`flex flex-col lg:flex-row items-center gap-12 lg:gap-24 ${\n                idx % 2 === 1 ? 'lg:flex-row-reverse' : ''\n              }`}>\n                {/* 文本区域 */}\n                <div className=\"flex-1 space-y-6\">\n                  <div className={`inline-flex p-3 rounded-2xl ${feature.bg} shadow-sm`}>\n                    {feature.icon}\n                  </div>\n                  <h2 className=\"text-3xl md:text-4xl font-bold text-gray-900 dark:text-white\">\n                    {t(`help.features.${feature.key}.title`)}\n                  </h2>\n                  <p className=\"text-lg text-gray-600 dark:text-gray-300 leading-relaxed\">\n                    {t(`help.features.${feature.key}.description`)}\n                  </p>\n                  \n                  {/* 详情列表 */}\n                  <ul className=\"space-y-4 pt-4\">\n                    {(t(`help.features.${feature.key}.details`, { returnObjects: true }) as string[])?.map((detail: string, i: number) => (\n                      <li key={i} className=\"flex items-start gap-3\">\n                        <div className=\"mt-1.5 w-1.5 h-1.5 rounded-full bg-banana-500 shrink-0\" />\n                        <span className=\"text-gray-600 dark:text-gray-400 font-medium\">{detail}</span>\n                      </li>\n                    ))}\n                  </ul>\n                </div>\n\n                {/* 视觉区域 */}\n                <div className=\"flex-1 w-full max-w-lg lg:max-w-none\">\n                  <div className={`aspect-square rounded-3xl overflow-hidden shadow-2xl border border-gray-100 dark:border-white/10 ${feature.bg} bg-opacity-30 dark:bg-opacity-10 backdrop-blur-sm flex items-center justify-center relative group hover:scale-[1.02] transition-transform duration-500`}>\n                    {/* 装饰背景 */}\n                    <div className=\"absolute inset-0 bg-gradient-to-br from-white/40 to-transparent dark:from-white/5 dark:to-transparent\" />\n                    \n                    {/* 中心图标/内容 */}\n                    <div className=\"relative z-10 transform transition-transform duration-500 group-hover:scale-110 group-hover:-rotate-3\">\n                      {React.cloneElement(feature.icon as React.ReactElement, { size: 120, strokeWidth: 1.5 })}\n                    </div>\n\n                    {/* 装饰元素 */}\n                    <div className=\"absolute top-1/4 left-1/4 w-24 h-24 bg-current opacity-10 rounded-full blur-2xl animate-pulse\" style={{ color: feature.icon.props.className.includes('yellow') ? '#EAB308' : feature.icon.props.className.includes('blue') ? '#3B82F6' : feature.icon.props.className.includes('green') ? '#22C55E' : '#A855F7' }} />\n                  </div>\n                </div>\n              </div>\n            </div>\n          </section>\n        ))}\n      </div>\n\n      <Footer />\n    </div>\n  );\n};\n"
  },
  {
    "path": "frontend/src/pages/OutlineEditor.tsx",
    "content": "import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';\nimport { useNavigate, useParams, useLocation } from 'react-router-dom';\nimport { ArrowLeft, Save, ArrowRight, Plus, FileText, Sparkle, Download, Upload, PanelLeftClose, PanelLeftOpen, ChevronDown, Settings2 } from 'lucide-react';\nimport { useT } from '@/hooks/useT';\nimport PresetCapsules from '@/components/shared/PresetCapsules';\n\n// 组件内翻译\nconst outlineI18n = {\n  zh: {\n    home: { title: '蕉幻' },\n    outline: {\n      title: \"编辑大纲\", pageCount: \"共 {{count}} 页\", addPage: \"添加页面\",\n      generateDescriptions: \"生成描述\", generating: \"生成中...\", chapter: \"章节\",\n      page: \"第 {{num}} 页\", titleLabel: \"标题\", keyPoints: \"要点\",\n      keyPointsPlaceholder: \"要点（每行一个）\", addKeyPoint: \"添加要点\",\n      deletePage: \"删除页面\", confirmDeletePage: \"确定要删除这一页吗？\",\n      preview: \"预览\", clickToPreview: \"点击左侧卡片查看详情\",\n      noPages: \"还没有页面\", noPagesHint: \"点击「添加页面」手动创建，或「自动生成大纲」让 AI 帮你完成\",\n      parseOutline: \"解析大纲\", autoGenerate: \"自动生成大纲\",\n      reParseOutline: \"重新解析大纲\", reGenerate: \"重新生成大纲\", export: \"导出大纲\", import: \"导入\", importExport: \"导入/导出\",\n      aiPlaceholder: \"例如：增加一页关于XXX的内容、删除第3页、合并前两页... · Ctrl+Enter提交\",\n      aiPlaceholderShort: \"例如：增加/删除页面... · Ctrl+Enter\",\n      contextLabels: { idea: \"PPT构想\", outline: \"大纲\", description: \"描述\" },\n      inputLabel: { idea: \"PPT 构想\", outline: \"原始大纲\", description: \"页面描述\", ppt_renovation: \"原始 PPT 内容\" },\n      inputPlaceholder: { idea: \"输入你的 PPT 构想...\", outline: \"输入大纲内容...\", description: \"输入页面描述...\", ppt_renovation: \"已从 PDF 中提取内容\" },\n      outlineRequirements: \"大纲生成要求\",\n      outlineRequirementsPlaceholder: \"例如：限制在10页以内、每页要点不超过3条、多使用图表...\",\n      messages: {\n        outlineEmpty: \"大纲不能为空\", generateSuccess: \"描述生成完成\", generateFailed: \"生成描述失败\",\n        generateIncomplete: \"大纲生成可能不完整，请检查后重试\",\n        confirmRegenerate: \"重新生成将更新所有页面标题。已有的描述和图片会按位置保留，但如果新大纲页数减少，多出的页面及其内容将被删除。确定继续吗？\",\n        confirmRegenerateTitle: \"确认重新生成\",\n        lockPageCount: \"锁定页面数量（不允许减少，用空白页填补）\",\n        refineSuccess: \"大纲修改成功\",\n        refineFailed: \"修改失败，请稍后重试\", exportSuccess: \"导出成功\",\n        importSuccess: \"导入成功\", importFailed: \"导入失败，请检查文件格式\", importEmpty: \"文件中未找到有效页面\",\n        loadingProject: \"加载项目中...\", generatingOutline: \"生成大纲中...\",\n        saveFailed: \"保存失败\",\n      }\n    }\n  },\n  en: {\n    home: { title: 'Banana Slides' },\n    outline: {\n      title: \"Edit Outline\", pageCount: \"{{count}} pages\", addPage: \"Add Page\",\n      generateDescriptions: \"Generate Descriptions\", generating: \"Generating...\", chapter: \"Chapter\",\n      page: \"Page {{num}}\", titleLabel: \"Title\", keyPoints: \"Key Points\",\n      keyPointsPlaceholder: \"Key points (one per line)\", addKeyPoint: \"Add Key Point\",\n      deletePage: \"Delete Page\", confirmDeletePage: \"Are you sure you want to delete this page?\",\n      preview: \"Preview\", clickToPreview: \"Click a card on the left to view details\",\n      noPages: \"No pages yet\", noPagesHint: \"Click \\\"Add Page\\\" to create manually, or \\\"Auto Generate\\\" to let AI help you\",\n      parseOutline: \"Parse Outline\", autoGenerate: \"Auto Generate Outline\",\n      reParseOutline: \"Re-parse Outline\", reGenerate: \"Regenerate Outline\", export: \"Export Outline\", import: \"Import\", importExport: \"Import/Export\",\n      aiPlaceholder: \"e.g., Add a page about XXX, delete page 3, merge first two pages... · Ctrl+Enter to submit\",\n      aiPlaceholderShort: \"e.g., Add/delete pages... · Ctrl+Enter\",\n      contextLabels: { idea: \"PPT Idea\", outline: \"Outline\", description: \"Description\" },\n      inputLabel: { idea: \"PPT Idea\", outline: \"Original Outline\", description: \"Page Descriptions\", ppt_renovation: \"Original PPT Content\" },\n      inputPlaceholder: { idea: \"Enter your PPT idea...\", outline: \"Enter outline content...\", description: \"Enter page descriptions...\", ppt_renovation: \"Content extracted from PDF\" },\n      outlineRequirements: \"Generation Requirements\",\n      outlineRequirementsPlaceholder: \"e.g., Limit to 10 pages, max 3 points per page, use more charts...\",\n      messages: {\n        outlineEmpty: \"Outline cannot be empty\", generateSuccess: \"Descriptions generated successfully\", generateFailed: \"Failed to generate descriptions\",\n        generateIncomplete: \"Outline generation may be incomplete, please review and retry\",\n        confirmRegenerate: \"Regenerating will update all page titles. Existing descriptions and images are preserved by position, but if the new outline has fewer pages, extra pages and their content will be removed. Continue?\",\n        confirmRegenerateTitle: \"Confirm Regenerate\",\n        lockPageCount: \"Lock page count (prevent reduction, fill with blank pages)\",\n        refineSuccess: \"Outline modified successfully\",\n        refineFailed: \"Modification failed, please try again\", exportSuccess: \"Export successful\",\n        importSuccess: \"Import successful\", importFailed: \"Import failed, please check file format\", importEmpty: \"No valid pages found in file\",\n        loadingProject: \"Loading project...\", generatingOutline: \"Generating outline...\",\n        saveFailed: \"Save failed\",\n      }\n    }\n  }\n};\nimport {\n  DndContext,\n  closestCenter,\n  KeyboardSensor,\n  PointerSensor,\n  useSensor,\n  useSensors,\n  DragEndEvent,\n} from '@dnd-kit/core';\nimport {\n  arrayMove,\n  SortableContext,\n  sortableKeyboardCoordinates,\n  verticalListSortingStrategy,\n  useSortable,\n} from '@dnd-kit/sortable';\nimport { CSS } from '@dnd-kit/utilities';\nimport { Button, Loading, useConfirm, useToast, AiRefineInput, FilePreviewModal, ReferenceFileList } from '@/components/shared';\nimport { MarkdownTextarea, type MarkdownTextareaRef } from '@/components/shared/MarkdownTextarea';\nimport { OutlineCard } from '@/components/outline/OutlineCard';\nimport { useProjectStore } from '@/store/useProjectStore';\nimport { refineOutline, updateProject, addPage } from '@/api/endpoints';\nimport { useImagePaste } from '@/hooks/useImagePaste';\nimport { exportProjectToMarkdown, parseMarkdownPages } from '@/utils/projectUtils';\nimport type { Page } from '@/types';\n\n// 可排序的卡片包装器\nconst SortableCard: React.FC<{\n  page: Page;\n  index: number;\n  projectId?: string;\n  showToast: (props: { message: string; type: 'success' | 'error' | 'info' | 'warning' }) => void;\n  onUpdate: (data: Partial<Page>) => void;\n  onDelete: () => void;\n  onClick: () => void;\n  isSelected: boolean;\n  isAiRefining?: boolean;\n}> = (props) => {\n  const { attributes, listeners, setNodeRef, transform, transition } = useSortable({\n    id: props.page.id || `page-${props.index}`,\n  });\n\n  const style = {\n    // 只使用位移变换，不使用缩放，避免拖拽时元素被拉伸\n    transform: transform ? CSS.Translate.toString(transform) : undefined,\n    transition,\n  };\n\n  return (\n    <div ref={setNodeRef} style={style} {...attributes}>\n      <OutlineCard {...props} dragHandleProps={listeners} />\n    </div>\n  );\n};\n\nexport const OutlineEditor: React.FC = () => {\n  const navigate = useNavigate();\n  const location = useLocation();\n  const t = useT(outlineI18n);\n  const { projectId } = useParams<{ projectId: string }>();\n  const fromHistory = (location.state as any)?.from === 'history';\n  const {\n    currentProject,\n    syncProject,\n    updatePageLocal,\n    saveAllPages,\n    reorderPages,\n    deletePageById,\n    addNewPage,\n    generateOutlineStream,\n    isGlobalLoading,\n    isOutlineStreaming,\n  } = useProjectStore();\n\n  const [selectedPageId, setSelectedPageId] = useState<string | null>(null);\n  const [isAiRefining, setIsAiRefining] = useState(false);\n  const [previewFileId, setPreviewFileId] = useState<string | null>(null);\n  const [isPanelOpen, setIsPanelOpen] = useState(true);\n\n  // Skeleton fade-out: keep it mounted briefly after streaming ends\n  const [skeletonVisible, setSkeletonVisible] = useState(false);\n  const [skeletonFading, setSkeletonFading] = useState(false);\n  useEffect(() => {\n    if (isOutlineStreaming) {\n      setSkeletonVisible(true);\n      setSkeletonFading(false);\n    } else if (skeletonVisible) {\n      setSkeletonFading(true);\n      const timer = setTimeout(() => {\n        setSkeletonVisible(false);\n        setSkeletonFading(false);\n      }, 1000);\n      return () => clearTimeout(timer);\n    }\n  }, [isOutlineStreaming]);\n  const { confirm, ConfirmDialog } = useConfirm();\n  const { show, ToastContainer } = useToast();\n\n  // 左侧可编辑文本区域 — desktop and mobile use separate refs to avoid\n  // the shared-ref bug where insertAtCursor targets the wrong (hidden) instance.\n  const desktopTextareaRef = useRef<MarkdownTextareaRef>(null);\n  const mobileTextareaRef = useRef<MarkdownTextareaRef>(null);\n  const importFileRef = useRef<HTMLInputElement>(null);\n  const [fileMenuOpen, setFileMenuOpen] = useState(false);\n  const fileMenuRef = useRef<HTMLDivElement>(null);\n  const getInputText = useCallback((project: typeof currentProject) => {\n    if (!project) return '';\n    if (project.creation_type === 'outline' || project.creation_type === 'ppt_renovation') return project.outline_text || project.idea_prompt || '';\n    if (project.creation_type === 'descriptions') return project.description_text || project.idea_prompt || '';\n    return project.idea_prompt || '';\n  }, []);\n\n  const [inputText, setInputText] = useState('');\n  const [isInputDirty, setIsInputDirty] = useState(false);\n  const [outlineRequirements, setOutlineRequirements] = useState('');\n  const [isRequirementsDirty, setIsRequirementsDirty] = useState(false);\n  const reqTextareaRef = useRef<MarkdownTextareaRef>(null);\n  const [isRequirementsOpen, setIsRequirementsOpen] = useState(\n    () => localStorage.getItem('outlineReqOpen') !== 'false'\n  );\n\n  // 点击外部关闭下拉\n  useEffect(() => {\n    if (!fileMenuOpen) return;\n    const handleClickOutside = (e: MouseEvent) => {\n      if (fileMenuRef.current && !fileMenuRef.current.contains(e.target as Node)) {\n        setFileMenuOpen(false);\n      }\n    };\n    document.addEventListener('mousedown', handleClickOutside);\n    return () => document.removeEventListener('mousedown', handleClickOutside);\n  }, [fileMenuOpen]);\n\n  // 项目切换时：强制加载文本\n  useEffect(() => {\n    if (currentProject) {\n      setInputText(getInputText(currentProject));\n      setIsInputDirty(false);\n      setOutlineRequirements(currentProject.outline_requirements || '');\n      setIsRequirementsDirty(false);\n    }\n  }, [currentProject?.id]);\n\n  const saveInputText = useCallback(async (text: string, creationType: string | undefined) => {\n    if (!projectId || !creationType) return;\n    try {\n      const field = creationType === 'outline'\n        ? 'outline_text'\n        : creationType === 'descriptions'\n          ? 'description_text'\n          : 'idea_prompt';\n      await updateProject(projectId, { [field]: text } as any);\n      await syncProject(projectId);\n      setIsInputDirty(false);\n    } catch (e) {\n      console.error('保存输入文本失败:', e);\n      show({ message: t('outline.messages.saveFailed'), type: 'error' });\n    }\n  }, [projectId, show, syncProject]);\n\n  // Debounced auto-save: save 1s after user stops typing\n  useEffect(() => {\n    if (!isInputDirty) return;\n    const timer = setTimeout(() => {\n      saveInputText(inputText, currentProject?.creation_type);\n    }, 1000);\n    return () => clearTimeout(timer);\n  }, [inputText, isInputDirty, saveInputText, currentProject?.creation_type]);\n\n  // Debounced auto-save for outline requirements\n  useEffect(() => {\n    if (!isRequirementsDirty || !projectId) return;\n    const timer = setTimeout(async () => {\n      try {\n        await updateProject(projectId, { outline_requirements: outlineRequirements });\n        setIsRequirementsDirty(false);\n      } catch (e) {\n        console.error('保存大纲要求失败:', e);\n      }\n    }, 1000);\n    return () => clearTimeout(timer);\n  }, [outlineRequirements, isRequirementsDirty, projectId]);\n\n  const handleSaveInputText = useCallback(() => {\n    if (!isInputDirty) return;\n    saveInputText(inputText, currentProject?.creation_type);\n  }, [inputText, isInputDirty, saveInputText, currentProject?.creation_type]);\n\n  const handleInputChange = useCallback((text: string) => {\n    setInputText(text);\n    setIsInputDirty(true);\n  }, []);\n\n  const insertAtCursor = useCallback((markdown: string) => {\n    // Prefer the desktop ref (visible at md+), fall back to mobile\n    const ref = desktopTextareaRef.current || mobileTextareaRef.current;\n    ref?.insertAtCursor(markdown);\n  }, []);\n\n  const { handlePaste: handleImagePaste, handleFiles: handleImageFiles, isUploading: _isUploadingImage } = useImagePaste({\n    projectId: projectId || null,\n    setContent: setInputText,\n    showToast: show,\n    insertAtCursor,\n  });\n\n  const insertAtReqCursor = useCallback((markdown: string) => {\n    reqTextareaRef.current?.insertAtCursor(markdown);\n  }, []);\n\n  const { handlePaste: handleReqImagePaste, handleFiles: handleReqImageFiles } = useImagePaste({\n    projectId: projectId || null,\n    setContent: (updater) => {\n      setOutlineRequirements(updater);\n      setIsRequirementsDirty(true);\n    },\n    showToast: show,\n    insertAtCursor: insertAtReqCursor,\n  });\n\n  const inputLabel = useMemo(() => {\n    const type = currentProject?.creation_type || 'idea';\n    const key = type === 'descriptions' ? 'description' : type;\n    return t(`outline.inputLabel.${key}` as any) || t('outline.contextLabels.idea');\n  }, [currentProject?.creation_type, t]);\n\n  const inputPlaceholder = useMemo(() => {\n    const type = currentProject?.creation_type || 'idea';\n    const key = type === 'descriptions' ? 'description' : type;\n    return t(`outline.inputPlaceholder.${key}` as any) || '';\n  }, [currentProject?.creation_type, t]);\n\n  // 加载项目数据\n  useEffect(() => {\n    if (projectId && (!currentProject || currentProject.id !== projectId)) {\n      syncProject(projectId);\n    }\n  }, [projectId, currentProject, syncProject]);\n\n  // 拖拽传感器配置\n  const sensors = useSensors(\n    useSensor(PointerSensor),\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 && currentProject) {\n      const oldIndex = currentProject.pages.findIndex((p) => p.id === active.id);\n      const newIndex = currentProject.pages.findIndex((p) => p.id === over.id);\n\n      const reorderedPages = arrayMove(currentProject.pages, oldIndex, newIndex);\n      reorderPages(reorderedPages.map((p) => p.id).filter((id): id is string => id !== undefined));\n    }\n  };\n\n  const handleGenerateOutline = async () => {\n    if (!currentProject) return;\n\n    const doGenerate = async (lockPageCount?: boolean) => {\n      try {\n        const result = await generateOutlineStream(lockPageCount);\n        const { currentProject: updatedProject } = useProjectStore.getState();\n        const pageCount = updatedProject?.pages.length ?? 0;\n        if (result && (!result.complete || pageCount === 0)) {\n          show({ message: t('outline.messages.generateIncomplete'), type: 'warning' });\n        }\n      } catch (error: any) {\n        console.error('生成大纲失败:', error);\n        const message = error.friendlyMessage || error.message || t('outline.messages.generateFailed');\n        show({ message, type: 'error' });\n      }\n    };\n\n    if (currentProject.pages.length > 0) {\n      confirm(\n        t('outline.messages.confirmRegenerate'),\n        doGenerate,\n        {\n          title: t('outline.messages.confirmRegenerateTitle'),\n          variant: 'warning',\n          checkboxLabel: t('outline.messages.lockPageCount'),\n          checkboxDefaultChecked: false\n        }\n      );\n      return;\n    }\n\n    await doGenerate();\n  };\n\n  const handleAiRefineOutline = useCallback(async (requirement: string, previousRequirements: string[]) => {\n    if (!currentProject || !projectId) return;\n\n    try {\n      const response = await refineOutline(projectId, requirement, previousRequirements);\n      await syncProject(projectId);\n      show({\n        message: response.data?.message || t('outline.messages.refineSuccess'),\n        type: 'success'\n      });\n    } catch (error: any) {\n      console.error('修改大纲失败:', error);\n      const errorMessage = error?.response?.data?.error?.message\n        || error?.message\n        || t('outline.messages.refineFailed');\n      show({ message: errorMessage, type: 'error' });\n      throw error;\n    }\n  }, [currentProject, projectId, syncProject, show]);\n\n  // 导出大纲为 Markdown 文件\n  const handleExportOutline = useCallback(() => {\n    if (!currentProject) return;\n    exportProjectToMarkdown(currentProject, { outline: true, description: false });\n    show({ message: t('outline.messages.exportSuccess'), type: 'success' });\n  }, [currentProject, show]);\n\n  // 导入大纲 Markdown 文件（追加新页面）\n  const handleImportOutline = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    if (importFileRef.current) importFileRef.current.value = '';\n    if (!file || !currentProject || !projectId) return;\n    try {\n      const text = await file.text();\n      const parsed = parseMarkdownPages(text);\n      if (parsed.length === 0) {\n        show({ message: t('outline.messages.importEmpty'), type: 'error' });\n        return;\n      }\n      const startIndex = currentProject.pages.reduce((max, p) => Math.max(max, (p.order_index ?? 0) + 1), 0);\n      await Promise.all(parsed.map(({ title, points, text: desc, part, extra_fields }, i) =>\n        addPage(projectId, {\n          outline_content: { title, points },\n          description_content: desc ? { text: desc, ...(extra_fields ? { extra_fields } : {}) } : undefined,\n          part,\n          order_index: startIndex + i,\n        })\n      ));\n      await syncProject(projectId);\n      show({ message: t('outline.messages.importSuccess'), type: 'success' });\n    } catch {\n      show({ message: t('outline.messages.importFailed'), type: 'error' });\n    }\n  }, [currentProject, projectId, syncProject, show, t]);\n\n\n  if (!currentProject) {\n    return <Loading fullscreen message={t('outline.messages.loadingProject')} />;\n  }\n\n  if (isGlobalLoading && !isOutlineStreaming) {\n    return <Loading fullscreen message={t('outline.messages.generatingOutline')} />;\n  }\n\n  return (\n    <div className=\"min-h-screen bg-gray-50 dark:bg-background-primary flex flex-col\">\n      {/* 顶栏 */}\n      <header className=\"bg-white dark:bg-background-secondary shadow-sm dark:shadow-background-primary/30 border-b border-gray-200 dark:border-border-primary px-3 md:px-6 py-2 md:py-3 flex-shrink-0\">\n        <div className=\"flex items-center justify-between gap-2 md:gap-4\">\n          {/* 左侧：Logo 和标题 */}\n          <div className=\"flex items-center gap-2 md:gap-4 flex-shrink-0\">\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              icon={<ArrowLeft size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={() => {\n                if (fromHistory) {\n                  navigate('/history');\n                } else {\n                  navigate('/');\n                }\n              }}\n              className=\"flex-shrink-0\"\n            >\n              <span className=\"hidden sm:inline\">{t('common.back')}</span>\n            </Button>\n            <div className=\"flex items-center gap-1.5 md:gap-2\">\n              <span className=\"text-xl md:text-2xl\">🍌</span>\n              <span className=\"text-base md:text-xl font-bold\">{t('home.title')}</span>\n            </div>\n            <span className=\"text-gray-400 hidden lg:inline\">|</span>\n            <span className=\"text-sm md:text-lg font-semibold hidden lg:inline\">{t('outline.title')}</span>\n          </div>\n\n          {/* 中间：AI 修改输入框 */}\n          <div className=\"flex-1 max-w-xl mx-auto hidden md:block md:-translate-x-2 pr-10\">\n            <AiRefineInput\n              title=\"\"\n              placeholder={t('outline.aiPlaceholder')}\n              onSubmit={handleAiRefineOutline}\n              disabled={false}\n              className=\"!p-0 !bg-transparent !border-0\"\n              onStatusChange={setIsAiRefining}\n            />\n          </div>\n\n          {/* 右侧：操作按钮 */}\n          <div className=\"flex items-center gap-1.5 md:gap-2 flex-shrink-0\">\n            <Button\n              variant=\"primary\"\n              size=\"sm\"\n              icon={<ArrowRight size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={async () => {\n                if (isInputDirty && projectId && currentProject) {\n                  const field = currentProject.creation_type === 'outline'\n                    ? 'outline_text'\n                    : currentProject.creation_type === 'descriptions'\n                      ? 'description_text'\n                      : 'idea_prompt';\n                  try {\n                    await updateProject(projectId, { [field]: inputText } as any);\n                  } catch (e) {\n                    console.error('自动保存失败:', e);\n                  }\n                }\n                navigate(`/project/${projectId}/detail`);\n              }}\n              className=\"text-xs md:text-sm\"\n            >\n              <span className=\"hidden sm:inline\">{t('common.next')}</span>\n            </Button>\n          </div>\n        </div>\n\n        {/* 移动端：AI 输入框 */}\n        <div className=\"mt-2 md:hidden\">\n            <AiRefineInput\n            title=\"\"\n            placeholder={t('outline.aiPlaceholderShort')}\n            onSubmit={handleAiRefineOutline}\n            disabled={false}\n            className=\"!p-0 !bg-transparent !border-0\"\n            onStatusChange={setIsAiRefining}\n          />\n        </div>\n      </header>\n\n      {/* 操作栏 - 与 DetailEditor 风格一致 */}\n      <div className=\"bg-white dark:bg-background-secondary border-b border-gray-200 dark:border-border-primary px-3 md:px-6 py-3 md:py-4 flex-shrink-0\">\n        <div className=\"flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 sm:gap-3\">\n          <div className=\"flex items-center gap-2 sm:gap-3 flex-1\">\n            <Button\n              variant=\"primary\"\n              icon={<Plus size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={addNewPage}\n              className=\"flex-1 sm:flex-initial text-sm md:text-base\"\n            >\n              {t('outline.addPage')}\n            </Button>\n            {currentProject.pages.length === 0 && !isOutlineStreaming ? (\n              <Button\n                variant=\"secondary\"\n                onClick={handleGenerateOutline}\n                disabled={isOutlineStreaming}\n                className=\"flex-1 sm:flex-initial text-sm md:text-base\"\n              >\n                {currentProject.creation_type === 'outline' ? t('outline.parseOutline') : t('outline.autoGenerate')}\n              </Button>\n            ) : (\n              <Button\n                variant=\"secondary\"\n                onClick={handleGenerateOutline}\n                disabled={isOutlineStreaming}\n                className=\"flex-1 sm:flex-initial text-sm md:text-base\"\n              >\n                {isOutlineStreaming\n                  ? t('outline.generating')\n                  : currentProject.creation_type === 'outline' ? t('outline.reParseOutline') : t('outline.reGenerate')}\n              </Button>\n            )}\n            {/* 导入导出下拉菜单 */}\n            <div className=\"relative\" ref={fileMenuRef}>\n              <Button\n                variant=\"secondary\"\n                onClick={() => setFileMenuOpen(!fileMenuOpen)}\n                icon={<FileText size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n                className=\"flex-1 sm:flex-initial text-sm md:text-base\"\n              >\n                {t('outline.importExport')}\n                <ChevronDown size={14} className={`ml-1 transition-transform duration-200 ${fileMenuOpen ? 'rotate-180' : ''}`} />\n              </Button>\n              {fileMenuOpen && (\n                <div className=\"absolute top-full left-0 mt-1 z-50 w-full rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary shadow-lg dark:shadow-none overflow-hidden\">\n                  <button\n                    type=\"button\"\n                    onClick={() => { handleExportOutline(); setFileMenuOpen(false); }}\n                    disabled={currentProject.pages.length === 0}\n                    className=\"w-full flex items-center gap-2 px-3 py-2 text-xs font-medium text-gray-600 dark:text-foreground-tertiary hover:bg-gray-50 dark:hover:bg-background-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors duration-150\"\n                  >\n                    <Download size={14} />\n                    {t('outline.export')}\n                  </button>\n                  <div className=\"border-t border-gray-100 dark:border-border-primary\" />\n                  <button\n                    type=\"button\"\n                    onClick={() => { importFileRef.current?.click(); setFileMenuOpen(false); }}\n                    className=\"w-full flex items-center gap-2 px-3 py-2 text-xs font-medium text-gray-600 dark:text-foreground-tertiary hover:bg-gray-50 dark:hover:bg-background-hover transition-colors duration-150\"\n                  >\n                    <Upload size={14} />\n                    {t('outline.import')}\n                  </button>\n                </div>\n              )}\n            </div>\n            <input ref={importFileRef} type=\"file\" accept=\".md,.txt\" className=\"hidden\" onChange={handleImportOutline} />\n            {/* 手机端：保存按钮 */}\n            <Button\n              variant=\"secondary\"\n              icon={<Save size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={async () => await saveAllPages()}\n              className=\"md:hidden flex-1 sm:flex-initial text-sm md:text-base\"\n            >\n              {t('common.save')}\n            </Button>\n            <span className=\"text-xs md:text-sm text-gray-500 dark:text-foreground-tertiary whitespace-nowrap\">\n              {t('outline.pageCount', { count: String(currentProject.pages.length) })}\n            </span>\n          </div>\n        </div>\n      </div>\n\n      {/* 大纲生成要求 - 可折叠 */}\n      <div className=\"bg-white dark:bg-background-secondary border-b border-gray-200 dark:border-border-primary flex-shrink-0\">\n        <button\n          type=\"button\"\n          data-testid=\"outline-requirements-toggle\"\n          onClick={() => { const next = !isRequirementsOpen; setIsRequirementsOpen(next); localStorage.setItem('outlineReqOpen', String(next)); }}\n          className=\"w-full px-3 md:px-6 py-2 flex items-center gap-2 text-xs text-gray-500 dark:text-foreground-tertiary hover:text-gray-700 dark:hover:text-foreground-secondary hover:bg-gray-50 dark:hover:bg-background-hover transition-colors\"\n        >\n          <Settings2 size={12} className=\"flex-shrink-0\" />\n          <span className=\"font-medium\">{t('outline.outlineRequirements')}</span>\n          {outlineRequirements && !isRequirementsOpen && (\n            <span className=\"w-1.5 h-1.5 rounded-full bg-banana-400 flex-shrink-0\" />\n          )}\n          <ChevronDown\n            size={12}\n            className={`ml-auto transition-transform duration-200 ${isRequirementsOpen ? 'rotate-180' : ''}`}\n          />\n        </button>\n        <div\n          className=\"overflow-hidden transition-all duration-200 ease-in-out\"\n          style={{ maxHeight: isRequirementsOpen ? '600px' : '0px' }}\n        >\n          <div className=\"px-3 md:px-6 pb-3\">\n            <div data-testid=\"outline-requirements-textarea\">\n              <MarkdownTextarea\n                ref={reqTextareaRef}\n                value={outlineRequirements}\n                onChange={(val) => { setOutlineRequirements(val); setIsRequirementsDirty(true); }}\n                onPaste={handleReqImagePaste}\n                onFiles={handleReqImageFiles}\n                placeholder={t('outline.outlineRequirementsPlaceholder')}\n                className=\"ring-inset\"\n                rows={2}\n                showImagePreview={false}\n              />\n            </div>\n            <PresetCapsules\n              type=\"outline\"\n              onAppend={(text) => {\n                setOutlineRequirements((prev) => prev ? `${prev}\\n${text}` : text);\n                setIsRequirementsDirty(true);\n              }}\n            />\n          </div>\n        </div>\n      </div>\n\n      {/* 主内容区 */}\n      <main className=\"flex-1 flex flex-col md:flex-row gap-3 md:gap-6 p-3 md:p-6 overflow-y-auto min-h-0 relative\">\n        {/* 左侧：可编辑文本区域（可收起） */}\n        <div\n          className=\"flex-shrink-0 transition-[width] duration-300 ease-in-out hidden md:block\"\n          style={{ width: isPanelOpen ? undefined : 0 }}\n        >\n          <div\n            className=\"w-[320px] lg:w-[360px] xl:w-[400px] transition-[opacity,transform] duration-300 ease-in-out md:sticky md:top-0\"\n            style={{\n              opacity: isPanelOpen ? 1 : 0,\n              transform: isPanelOpen ? 'translateX(0)' : 'translateX(-16px)',\n              pointerEvents: isPanelOpen ? 'auto' : 'none',\n            }}\n          >\n            <div className=\"bg-white dark:bg-background-secondary rounded-card shadow-md border border-gray-100 dark:border-border-primary overflow-hidden\">\n              <div className=\"px-4 py-2.5 flex items-center gap-2 border-b border-gray-100 dark:border-border-secondary\">\n                {currentProject.creation_type === 'idea'\n                  ? <Sparkle size={14} className=\"text-banana-500 flex-shrink-0\" />\n                  : <FileText size={14} className=\"text-banana-500 flex-shrink-0\" />}\n                <span className=\"text-xs font-medium text-gray-500 dark:text-foreground-tertiary\">{inputLabel}</span>\n                <div className=\"ml-auto flex items-center gap-1\">\n                  <button\n                    type=\"button\"\n                    onClick={() => setIsPanelOpen(false)}\n                    className=\"p-1 text-gray-400 hover:text-gray-600 dark:hover:text-foreground-secondary rounded hover:bg-gray-100 dark:hover:bg-background-hover transition-colors\"\n                  >\n                    <PanelLeftClose size={14} />\n                  </button>\n                </div>\n              </div>\n              <MarkdownTextarea\n                ref={desktopTextareaRef}\n                value={inputText}\n                onChange={handleInputChange}\n                onBlur={handleSaveInputText}\n                onPaste={handleImagePaste}\n                onFiles={handleImageFiles}\n                placeholder={inputPlaceholder}\n                rows={12}\n                className=\"border-0 rounded-none shadow-none\"\n              />\n            </div>\n            <ReferenceFileList\n              projectId={projectId}\n              onFileClick={setPreviewFileId}\n              className=\"mt-3\"\n              showToast={show}\n            />\n          </div>\n        </div>\n\n        {/* 收起时的把手 - 绝对定位贴左边缘 */}\n        {!isPanelOpen && (\n          <button\n            type=\"button\"\n            onClick={() => setIsPanelOpen(true)}\n            className=\"hidden md:flex absolute left-0 top-6 z-10 items-center justify-center w-6 h-14 bg-white dark:bg-background-secondary border border-l-0 border-gray-200 dark:border-border-primary rounded-r-lg shadow-md text-gray-400 hover:text-banana-500 hover:border-banana-300 dark:hover:border-banana-500/40 hover:shadow-lg transition-all\"\n          >\n            <PanelLeftOpen size={14} />\n          </button>\n        )}\n\n        {/* 移动端：始终显示卡片 */}\n        <div className=\"md:hidden w-full flex-shrink-0\">\n          <div className=\"bg-white dark:bg-background-secondary rounded-card shadow-md border border-gray-100 dark:border-border-primary overflow-hidden\">\n            <div className=\"px-4 py-2.5 flex items-center gap-2 border-b border-gray-100 dark:border-border-secondary\">\n              {currentProject.creation_type === 'idea'\n                ? <Sparkle size={14} className=\"text-banana-500 flex-shrink-0\" />\n                : <FileText size={14} className=\"text-banana-500 flex-shrink-0\" />}\n              <span className=\"text-xs font-medium text-gray-500 dark:text-foreground-tertiary\">{inputLabel}</span>\n            </div>\n            <MarkdownTextarea\n              ref={mobileTextareaRef}\n              value={inputText}\n              onChange={handleInputChange}\n              onBlur={handleSaveInputText}\n              onPaste={handleImagePaste}\n              onFiles={handleImageFiles}\n              placeholder={inputPlaceholder}\n              rows={6}\n              className=\"border-0 rounded-none shadow-none\"\n            />\n          </div>\n          <ReferenceFileList\n            projectId={projectId}\n            onFileClick={setPreviewFileId}\n            className=\"mt-3\"\n            showToast={show}\n          />\n        </div>\n\n        {/* 右侧：大纲列表 */}\n        <div className=\"flex-1 min-w-0\">\n          {currentProject.pages.length === 0 && !isOutlineStreaming ? (\n            <div className=\"text-center py-12 md:py-20\">\n              <div className=\"flex justify-center mb-4\">\n                <FileText size={48} className=\"text-gray-300\" />\n              </div>\n              <h3 className=\"text-lg font-semibold text-gray-800 dark:text-foreground-primary mb-2\">\n                {t('outline.noPages')}\n              </h3>\n              <p className=\"text-gray-500 dark:text-foreground-tertiary mb-6\">\n                {t('outline.noPagesHint')}\n              </p>\n            </div>\n          ) : (\n            <DndContext\n              sensors={sensors}\n              collisionDetection={closestCenter}\n              onDragEnd={handleDragEnd}\n            >\n              <SortableContext\n                items={currentProject.pages.map((p, idx) => p.id || `page-${idx}`)}\n                strategy={verticalListSortingStrategy}\n              >\n                <div className=\"space-y-3 md:space-y-4\">\n                  {currentProject.pages.map((page, index) => (\n                    <div\n                      key={page.id || `page-${index}`}\n                      className={isOutlineStreaming ? 'animate-slide-in-up' : ''}\n                      style={isOutlineStreaming ? { animationDelay: `${index * 60}ms` } : undefined}\n                    >\n                      <SortableCard\n                        page={page}\n                        index={index}\n                        projectId={projectId}\n                        showToast={show}\n                        onUpdate={(data) => page.id && updatePageLocal(page.id, data)}\n                        onDelete={() => page.id && deletePageById(page.id)}\n                        onClick={() => setSelectedPageId(page.id || null)}\n                        isSelected={selectedPageId === page.id}\n                        isAiRefining={isAiRefining}\n                      />\n                    </div>\n                  ))}\n                  {skeletonVisible && (\n                    <div\n                      className=\"transition-opacity duration-1000\"\n                      style={{ opacity: skeletonFading ? 0 : 1 }}\n                    >\n                      <div className=\"animate-pulse\">\n                        <div className=\"bg-white dark:bg-background-secondary rounded-xl shadow-sm border border-gray-100 dark:border-border-primary p-4\">\n                        <div className=\"flex items-start gap-3\">\n                          <div className=\"w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded mt-1\" />\n                          <div className=\"flex-1 space-y-3\">\n                            <div className=\"flex items-center gap-2\">\n                              <div className=\"h-4 w-12 bg-gray-200 dark:bg-gray-700 rounded\" />\n                              <div className=\"h-4 w-16 bg-banana-100 dark:bg-banana-900/30 rounded\" />\n                            </div>\n                            <div className=\"h-5 w-2/3 bg-gray-200 dark:bg-gray-700 rounded\" />\n                            <div className=\"space-y-2\">\n                              <div className=\"h-3.5 w-full bg-gray-100 dark:bg-gray-800 rounded\" />\n                              <div className=\"h-3.5 w-4/5 bg-gray-100 dark:bg-gray-800 rounded\" />\n                              <div className=\"h-3.5 w-3/5 bg-gray-100 dark:bg-gray-800 rounded\" />\n                            </div>\n                          </div>\n                        </div>\n                        </div>\n                      </div>\n                    </div>\n                  )}\n                </div>\n              </SortableContext>\n            </DndContext>\n          )}\n        </div>\n      </main>\n      {ConfirmDialog}\n      <ToastContainer />\n      <FilePreviewModal fileId={previewFileId} onClose={() => setPreviewFileId(null)} />\n    </div>\n  );\n};\n"
  },
  {
    "path": "frontend/src/pages/Settings.tsx",
    "content": "import React, { useState, useEffect } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { Home, Key, Image, Zap, Save, RotateCcw, Globe, FileText, Brain, ArrowUp, HelpCircle } from 'lucide-react';\nimport { useT } from '@/hooks/useT';\n\n// 组件内翻译\nconst settingsI18n = {\n  zh: {\n    nav: { backToHome: '返回首页' },\n    settings: {\n      title: \"系统设置\",\n      subtitle: \"配置应用的各项参数\",\n      sections: {\n        appearance: \"外观设置\", language: \"界面语言\", apiConfig: \"默认 API 配置\",\n        apiConfigDesc: \"下方模型未单独指定提供商时，将使用此处的配置\",\n        modelConfig: \"模型配置\", mineruConfig: \"MinerU 配置\", imageConfig: \"图像生成配置\",\n        performanceConfig: \"性能配置\", outputLanguage: \"输出语言设置\",\n        textReasoning: \"文本推理模式\", imageReasoning: \"图像推理模式\",\n        baiduOcr: \"百度配置\", serviceTest: \"服务测试\", lazyllmConfig: \"LazyLLM 厂商配置\",\n        vendorApiKeys: \"厂商 API Key 配置\"\n      },\n      theme: { label: \"主题模式\", light: \"浅色\", dark: \"深色\", system: \"跟随系统\" },\n      language: { label: \"界面语言\", zh: \"中文\", en: \"English\" },\n      fields: {\n        aiProviderFormat: \"AI 提供商格式\",\n        aiProviderFormatDesc: \"选择 API 请求格式，影响后端如何构造和发送请求。保存设置后生效。\",\n        openaiFormat: \"OpenAI 格式\", geminiFormat: \"Gemini 格式\", lazyllmFormat: \"LazyLLM 格式\",\n        apiBaseUrl: \"API Base URL\", apiBaseUrlPlaceholder: \"https://api.example.com\",\n        apiBaseUrlDesc: \"设置大模型提供商 API 的基础 URL\",\n        apiKey: \"API Key\", apiKeyPlaceholder: \"输入新的 API Key\",\n        apiKeyDesc: \"留空则保持当前设置不变，输入新值则更新\",\n        apiKeySet: \"已设置（长度: {{length}}）\",\n        textModel: \"文本大模型\", textModelPlaceholder: \"留空使用环境变量配置 (如: gemini-3-flash-preview)\",\n        textModelDesc: \"用于生成大纲、描述等文本内容的模型名称\",\n        imageModel: \"图像生成模型\", imageModelPlaceholder: \"留空使用环境变量配置 (如: imagen-3.0-generate-001)\",\n        imageModelDesc: \"用于生成页面图片的模型名称\",\n        imageCaptionModel: \"图片识别模型\", imageCaptionModelPlaceholder: \"留空使用环境变量配置 (如: gemini-3-flash-preview)\",\n        imageCaptionModelDesc: \"用于识别参考文件中的图片并生成描述\",\n        mineruApiBase: \"MinerU API Base\", mineruApiBasePlaceholder: \"留空使用环境变量配置 (如: https://mineru.net)\",\n        mineruApiBaseDesc: \"MinerU 服务地址，用于解析参考文件\",\n        mineruToken: \"MinerU Token\", mineruTokenPlaceholder: \"输入新的 MinerU Token\",\n        mineruTokenDesc: \"留空则保持当前设置不变，输入新值则更新\",\n        imageResolution: \"图像清晰度（某些OpenAI格式中转调整该值无效）\",\n        imageResolutionDesc: \"更高的清晰度会生成更详细的图像，但需要更长时间\",\n        descriptionGenerationMode: \"描述生成模式\", descriptionGenerationModeDesc: \"流式模式通过一次 AI 调用逐页生成，体验更流畅；并行模式为每页独立调用 AI，速度更快\",\n        descriptionGenerationModeStreaming: \"流式\", descriptionGenerationModeParallel: \"并行\",\n        maxDescriptionWorkers: \"描述生成最大并发数\", maxDescriptionWorkersDesc: \"并行模式下同时生成描述的最大工作线程数 (1-20)，越大速度越快\",\n        maxImageWorkers: \"图像生成最大并发数\", maxImageWorkersDesc: \"同时生成图像的最大工作线程数 (1-20)，越大速度越快\",\n        defaultOutputLanguage: \"默认输出语言\", defaultOutputLanguageDesc: \"AI 生成内容时使用的默认语言\",\n        enableTextReasoning: \"启用文本推理\", enableTextReasoningDesc: \"开启后，文本生成（大纲、描述等）会使用 extended thinking 进行深度推理\",\n        textThinkingBudget: \"文本思考负载\", textThinkingBudgetDesc: \"文本推理的思考 token 预算 (1-8192)，数值越大推理越深入\",\n        enableImageReasoning: \"启用图像推理\", enableImageReasoningDesc: \"开启后，图像生成会使用思考链模式，可能获得更好的构图效果\",\n        imageThinkingBudget: \"图像思考负载\", imageThinkingBudgetDesc: \"图像推理的思考 token 预算 (1-8192)，数值越大推理越深入\",\n        baiduOcrApiKey: \"百度 API Key\", baiduOcrApiKeyPlaceholder: \"输入百度 API Key\",\n        baiduOcrApiKeyDesc: \"用于可编辑 PPTX 导出时的文字识别功能，留空则保持当前设置不变\",\n        applyLink: \"，请点击此处申请\",\n        textModelSource: \"文本模型提供商格式\", textModelSourceDesc: \"选择文本生成使用的提供商格式\", textModelSourcePlaceholder: \"-- 请选择 --\",\n        imageModelSource: \"图片模型提供商格式\", imageModelSourceDesc: \"选择图片生成使用的提供商格式\", imageModelSourcePlaceholder: \"-- 请选择 --\",\n        imageCaptionModelSource: \"图片识别模型提供商格式\", imageCaptionModelSourceDesc: \"选择图片识别使用的提供商格式\", imageCaptionModelSourcePlaceholder: \"-- 请选择 --\",\n        vendorApiKey: \"{{vendor}} API Key\", vendorApiKeyPlaceholder: \"输入 {{vendor}} API Key\",\n        vendorApiKeyDesc: \"留空则保持当前设置不变，输入新值则更新\",\n        vendorApiKeySet: \"已设置（长度: {{length}}）\",\n        selectPlaceholder: \"-- 请选择 --\",\n        modelProvider: \"提供商\", modelProviderDesc: \"为此模型选择独立的提供商，不选则使用上方默认配置\",\n        modelProviderPlaceholder: \"-- 使用默认配置 --\",\n        perModelApiBaseUrl: \"API Base URL\", perModelApiBaseUrlPlaceholder: \"留空使用默认 Base URL\",\n        perModelApiKey: \"API Key\", perModelApiKeyPlaceholder: \"输入 API Key\",\n        perModelApiKeyDesc: \"留空则保持当前设置不变\",\n        perModelApiKeySet: \"已设置（长度: {{length}}）\",\n      },\n      apiKeyHelp: {\n        title: \"如何获取 API 密钥\",\n        step1: \"前往 {{link}} 注册账号\",\n        step2: \"点击顶栏「充值」，根据需要充值一定的额度\",\n        step3: \"点击顶栏「密钥」\",\n        step4: \"点击「创建 key」生成新的 API Key\",\n      },\n      apiKeyTip: { before: \"若需快速配置或稳定高并发生图，可选择 \", after: \"\" },\n      serviceTest: {\n        title: \"服务测试\", description: \"提前验证关键服务配置是否可用，避免使用期间异常。\",\n        tip: \"提示：图像生成和 MinerU 测试可能需要 30-60 秒，请耐心等待。\",\n        startTest: \"开始测试\", testing: \"测试中...\", testTimeout: \"测试超时，请重试\", testFailed: \"测试失败\",\n        tests: {\n          baiduOcr: { title: \"Baidu OCR 服务\", description: \"识别测试图片文字，验证 BAIDU_API_KEY 配置\" },\n          textModel: { title: \"文本生成模型\", description: \"发送短提示词，验证文本模型与 API 配置\" },\n          captionModel: { title: \"图片识别模型\", description: \"生成测试图片并请求模型输出描述\" },\n          baiduInpaint: { title: \"Baidu 图像修复\", description: \"使用测试图片执行修复，验证百度 inpaint 服务\" },\n          imageModel: { title: \"图像生成模型\", description: \"基于测试图片生成演示文稿背景图（固定分辨率，可能需要 20-40 秒）\" },\n          mineruPdf: { title: \"MinerU 解析 PDF\", description: \"上传测试 PDF 并等待解析结果返回（可能需要 30-60 秒）\" }\n        },\n        results: {\n          recognizedText: \"识别结果：{{text}}\", modelReply: \"模型回复：{{reply}}\",\n          captionDesc: \"识别描述：{{caption}}\", imageSize: \"输出尺寸：{{width}}x{{height}}\",\n          parsePreview: \"解析预览：{{preview}}\"\n        }\n      },\n      actions: { save: \"保存设置\", saving: \"保存中...\", resetToDefault: \"重置为默认配置\" },\n      messages: {\n        loadFailed: \"加载设置失败\", saveSuccess: \"设置保存成功\", saveFailed: \"保存设置失败\",\n        resetConfirm: \"将把大模型、图像生成和并发等所有配置恢复为环境默认值，已保存的自定义设置将丢失，确定继续吗？\",\n        resetTitle: \"确认重置为默认配置\", resetSuccess: \"设置已重置\", resetFailed: \"重置设置失败\",\n        testServiceTip: \"建议在本页底部进行服务测试，验证关键配置\",\n        resetConfirmBtn: \"确定重置\", resetCancelBtn: \"取消\", unknownError: \"未知错误\",\n        testSuccess: \"测试成功\"\n      }\n    }\n  },\n  en: {\n    nav: { backToHome: 'Back to Home' },\n    settings: {\n      title: \"Settings\",\n      subtitle: \"Configure application parameters\",\n      sections: {\n        appearance: \"Appearance\", language: \"Interface Language\", apiConfig: \"Default API Configuration\",\n        apiConfigDesc: \"Used as fallback when a model below has no provider specified\",\n        modelConfig: \"Model Configuration\", mineruConfig: \"MinerU Configuration\", imageConfig: \"Image Generation Configuration\",\n        performanceConfig: \"Performance Configuration\", outputLanguage: \"Output Language Settings\",\n        textReasoning: \"Text Reasoning Mode\", imageReasoning: \"Image Reasoning Mode\",\n        baiduOcr: \"Baidu Configuration\", serviceTest: \"Service Test\", lazyllmConfig: \"LazyLLM Provider Configuration\",\n        vendorApiKeys: \"Vendor API Key Configuration\"\n      },\n      theme: { label: \"Theme\", light: \"Light\", dark: \"Dark\", system: \"System\" },\n      language: { label: \"Interface Language\", zh: \"中文\", en: \"English\" },\n      fields: {\n        aiProviderFormat: \"AI Provider Format\",\n        aiProviderFormatDesc: \"Select API request format, affects how backend constructs and sends requests. Takes effect after saving.\",\n        openaiFormat: \"OpenAI Format\", geminiFormat: \"Gemini Format\", lazyllmFormat: \"LazyLLM Format\",\n        apiBaseUrl: \"API Base URL\", apiBaseUrlPlaceholder: \"https://api.example.com\",\n        apiBaseUrlDesc: \"Set the base URL for the LLM provider API\",\n        apiKey: \"API Key\", apiKeyPlaceholder: \"Enter new API Key\",\n        apiKeyDesc: \"Leave empty to keep current setting, enter new value to update\",\n        apiKeySet: \"Set (length: {{length}})\",\n        textModel: \"Text Model\", textModelPlaceholder: \"Leave empty to use env config (e.g., gemini-3-flash-preview)\",\n        textModelDesc: \"Model name for generating outlines, descriptions, etc.\",\n        imageModel: \"Image Generation Model\", imageModelPlaceholder: \"Leave empty to use env config (e.g., imagen-3.0-generate-001)\",\n        imageModelDesc: \"Model name for generating page images\",\n        imageCaptionModel: \"Image Caption Model\", imageCaptionModelPlaceholder: \"Leave empty to use env config (e.g., gemini-3-flash-preview)\",\n        imageCaptionModelDesc: \"Model for recognizing images in reference files and generating descriptions\",\n        mineruApiBase: \"MinerU API Base\", mineruApiBasePlaceholder: \"Leave empty to use env config (e.g., https://mineru.net)\",\n        mineruApiBaseDesc: \"MinerU service address for parsing reference files\",\n        mineruToken: \"MinerU Token\", mineruTokenPlaceholder: \"Enter new MinerU Token\",\n        mineruTokenDesc: \"Leave empty to keep current setting, enter new value to update\",\n        imageResolution: \"Image Resolution (may not work with some OpenAI format proxies)\",\n        imageResolutionDesc: \"Higher resolution generates more detailed images but takes longer\",\n        descriptionGenerationMode: \"Description Generation Mode\", descriptionGenerationModeDesc: \"Streaming mode generates all pages in a single AI call for a smoother experience; Parallel mode calls AI independently per page for faster speed\",\n        descriptionGenerationModeStreaming: \"Streaming\", descriptionGenerationModeParallel: \"Parallel\",\n        maxDescriptionWorkers: \"Max Description Workers\", maxDescriptionWorkersDesc: \"Maximum concurrent workers for description generation in parallel mode (1-20), higher is faster\",\n        maxImageWorkers: \"Max Image Workers\", maxImageWorkersDesc: \"Maximum concurrent workers for image generation (1-20), higher is faster\",\n        defaultOutputLanguage: \"Default Output Language\", defaultOutputLanguageDesc: \"Default language for AI-generated content\",\n        enableTextReasoning: \"Enable Text Reasoning\", enableTextReasoningDesc: \"When enabled, text generation uses extended thinking for deeper reasoning\",\n        textThinkingBudget: \"Text Thinking Budget\", textThinkingBudgetDesc: \"Token budget for text reasoning (1-8192), higher values enable deeper reasoning\",\n        enableImageReasoning: \"Enable Image Reasoning\", enableImageReasoningDesc: \"When enabled, image generation uses chain-of-thought mode for better composition\",\n        imageThinkingBudget: \"Image Thinking Budget\", imageThinkingBudgetDesc: \"Token budget for image reasoning (1-8192), higher values enable deeper reasoning\",\n        baiduOcrApiKey: \"Baidu API Key\", baiduOcrApiKeyPlaceholder: \"Enter Baidu API Key\",\n        baiduOcrApiKeyDesc: \"For text recognition in editable PPTX export, leave empty to keep current setting\",\n        applyLink: \", click here to apply\",\n        textModelSource: \"Text Model Provider Format\", textModelSourceDesc: \"Select the provider format for text generation\", textModelSourcePlaceholder: \"-- Select --\",\n        imageModelSource: \"Image Model Provider Format\", imageModelSourceDesc: \"Select the provider format for image generation\", imageModelSourcePlaceholder: \"-- Select --\",\n        imageCaptionModelSource: \"Image Caption Model Provider Format\", imageCaptionModelSourceDesc: \"Select the provider format for image captioning\", imageCaptionModelSourcePlaceholder: \"-- Select --\",\n        vendorApiKey: \"{{vendor}} API Key\", vendorApiKeyPlaceholder: \"Enter {{vendor}} API Key\",\n        vendorApiKeyDesc: \"Leave empty to keep current setting, enter new value to update\",\n        vendorApiKeySet: \"Set (length: {{length}})\",\n        selectPlaceholder: \"-- Select --\",\n        modelProvider: \"Provider\", modelProviderDesc: \"Select an independent provider for this model, leave empty to use default config\",\n        modelProviderPlaceholder: \"-- Use default config --\",\n        perModelApiBaseUrl: \"API Base URL\", perModelApiBaseUrlPlaceholder: \"Leave empty to use default Base URL\",\n        perModelApiKey: \"API Key\", perModelApiKeyPlaceholder: \"Enter API Key\",\n        perModelApiKeyDesc: \"Leave empty to keep current setting\",\n        perModelApiKeySet: \"Set (length: {{length}})\",\n      },\n      apiKeyHelp: {\n        title: \"How to get an API key\",\n        step1: \"Register at {{link}}\",\n        step2: \"Click \\\"Recharge\\\" in the top navigation bar and add credits as needed\",\n        step3: \"Click \\\"Keys\\\" in the top navigation bar\",\n        step4: \"Click \\\"Create Key\\\" to generate a new API Key\",\n      },\n      apiKeyTip: { before: \"For quick setup or stable high-concurrency image generation, get an API key from \", after: \"\" },\n      serviceTest: {\n        title: \"Service Test\", description: \"Verify key service configurations before use to avoid issues.\",\n        tip: \"Tip: Image generation and MinerU tests may take 30-60 seconds, please be patient.\",\n        startTest: \"Start Test\", testing: \"Testing...\", testTimeout: \"Test timeout, please retry\", testFailed: \"Test failed\",\n        tests: {\n          baiduOcr: { title: \"Baidu OCR Service\", description: \"Recognize text in test image, verify BAIDU_API_KEY configuration\" },\n          textModel: { title: \"Text Generation Model\", description: \"Send short prompt to verify text model and API configuration\" },\n          captionModel: { title: \"Image Caption Model\", description: \"Generate test image and request model to output description\" },\n          baiduInpaint: { title: \"Baidu Image Inpainting\", description: \"Use test image for inpainting, verify Baidu inpaint service\" },\n          imageModel: { title: \"Image Generation Model\", description: \"Generate presentation background from test image (fixed resolution, may take 20-40 seconds)\" },\n          mineruPdf: { title: \"MinerU PDF Parsing\", description: \"Upload test PDF and wait for parsing result (may take 30-60 seconds)\" }\n        },\n        results: {\n          recognizedText: \"Recognized: {{text}}\", modelReply: \"Model reply: {{reply}}\",\n          captionDesc: \"Caption: {{caption}}\", imageSize: \"Output size: {{width}}x{{height}}\",\n          parsePreview: \"Parse preview: {{preview}}\"\n        }\n      },\n      actions: { save: \"Save Settings\", saving: \"Saving...\", resetToDefault: \"Reset to Default\" },\n      messages: {\n        loadFailed: \"Failed to load settings\", saveSuccess: \"Settings saved successfully\", saveFailed: \"Failed to save settings\",\n        resetConfirm: \"This will reset all configurations (LLM, image generation, concurrency, etc.) to environment defaults. Custom settings will be lost. Continue?\",\n        resetTitle: \"Confirm Reset to Default\", resetSuccess: \"Settings reset successfully\", resetFailed: \"Failed to reset settings\",\n        testServiceTip: \"It's recommended to test services at the bottom of this page to verify configurations\",\n        resetConfirmBtn: \"Confirm Reset\", resetCancelBtn: \"Cancel\", unknownError: \"Unknown error\",\n        testSuccess: \"Test passed\"\n      }\n    }\n  }\n};\nimport { Button, Input, Card, Loading, useToast, useConfirm } from '@/components/shared';\nimport * as api from '@/api/endpoints';\nimport type { OutputLanguage } from '@/api/endpoints';\nimport { OUTPUT_LANGUAGE_OPTIONS } from '@/api/endpoints';\nimport type { Settings as SettingsType } from '@/types';\n\n// 配置项类型定义\ntype FieldType = 'text' | 'password' | 'number' | 'select' | 'buttons' | 'switch';\n\ninterface FieldConfig {\n  key: keyof typeof initialFormData;\n  label: string;\n  type: FieldType;\n  placeholder?: string;\n  description?: string;\n  sensitiveField?: boolean;  // 是否为敏感字段（如 API Key）\n  lengthKey?: keyof SettingsType;  // 用于显示已有长度的 key（如 api_key_length）\n  options?: { value: string; label: string }[];  // select 类型的选项\n  min?: number;\n  max?: number;\n  link?: string;  // 申请链接 URL\n}\n\ninterface SectionConfig {\n  title: string;\n  icon: React.ReactNode;\n  fields: FieldConfig[];\n}\n\ntype TestStatus = 'idle' | 'loading' | 'success' | 'error';\n\ninterface ServiceTestState {\n  status: TestStatus;\n  message?: string;\n  detail?: string;\n}\n\n// LazyLLM 支持的厂商列表\nconst LAZYLLM_SOURCES = [\n  { value: 'qwen', label: 'Qwen (通义千问)' },\n  { value: 'doubao', label: 'Doubao (豆包)' },\n  { value: 'deepseek', label: 'DeepSeek' },\n  { value: 'glm', label: 'GLM (智谱)' },\n  { value: 'siliconflow', label: 'SiliconFlow' },\n  { value: 'sensenova', label: 'SenseNova (商汤)' },\n  { value: 'minimax', label: 'MiniMax' },\n  { value: 'openai', label: 'OpenAI' },\n  { value: 'kimi', label: 'Kimi' },\n];\n\n// 所有可用的提供商选项（Gemini/OpenAI + LazyLLM 厂商）\nconst ALL_PROVIDER_SOURCES = [\n  { value: 'gemini', label: 'Gemini' },\n  { value: 'openai', label: 'OpenAI' },\n  ...LAZYLLM_SOURCES.filter(s => s.value !== 'openai'), // avoid duplicate 'openai'\n];\n\n// 需要 API Key + Base URL 的提供商（非 LazyLLM 厂商）\nconst API_KEY_PROVIDERS = new Set(['gemini', 'openai']);\n\n// LazyLLM 厂商名集合\nconst LAZYLLM_VENDOR_SET = new Set(LAZYLLM_SOURCES.map(s => s.value));\n\n// 初始表单数据\nconst initialFormData = {\n  ai_provider_format: 'gemini' as string,\n  api_base_url: '',\n  api_key: '',\n  text_model: '',\n  image_model: '',\n  image_caption_model: '',\n  mineru_api_base: '',\n  mineru_token: '',\n  image_resolution: '2K',\n  max_description_workers: 5,\n  max_image_workers: 8,\n  output_language: 'zh' as OutputLanguage,\n  // 推理模式配置（分别控制文本和图像）\n  enable_text_reasoning: false,\n  text_thinking_budget: 1024,\n  enable_image_reasoning: false,\n  image_thinking_budget: 1024,\n  baidu_api_key: '',\n  // LazyLLM 配置\n  text_model_source: '',\n  image_model_source: '',\n  image_caption_model_source: '',\n  lazyllm_api_keys: {} as Record<string, string>,\n  // Per-model API credentials (for gemini/openai per-model overrides)\n  text_api_key: '',\n  text_api_base_url: '',\n  image_api_key: '',\n  image_api_base_url: '',\n  image_caption_api_key: '',\n  image_caption_api_base_url: '',\n};\n\nconst isLazyllmVendor = (vendor: string) =>\n  LAZYLLM_VENDOR_SET.has(vendor) && vendor !== 'openai';\n\n// When backend returns \"lazyllm\", infer specific vendor from configured keys\nconst resolveLazyllmVendor = (format: string, keysInfo?: Record<string, number>): string => {\n  if (format !== 'lazyllm') return format;\n  if (keysInfo) {\n    const vendor = LAZYLLM_SOURCES.find(s => isLazyllmVendor(s.value) && keysInfo[s.value]);\n    if (vendor) return vendor.value;\n  }\n  return LAZYLLM_SOURCES.find(s => isLazyllmVendor(s.value))?.value || 'deepseek';\n};\n\nconst GlobalVendorKeyInput: React.FC<{\n  vendor: string; formData: typeof initialFormData;\n  setFormData: React.Dispatch<React.SetStateAction<typeof initialFormData>>;\n  settings: SettingsType | null; t: ReturnType<typeof useT>;\n}> = ({ vendor, formData, setFormData, settings, t }) => {\n  const vendorLabel = LAZYLLM_SOURCES.find(s => s.value === vendor)?.label || vendor.toUpperCase();\n  const keyLength = settings?.lazyllm_api_keys_info?.[vendor] || 0;\n  const placeholder = keyLength > 0\n    ? t('settings.fields.vendorApiKeySet', { length: keyLength })\n    : t('settings.fields.vendorApiKeyPlaceholder', { vendor: vendorLabel });\n  return (\n    <div className=\"pl-3 border-l-2 border-amber-300 dark:border-amber-600\">\n      <Input\n        label={t('settings.fields.vendorApiKey', { vendor: vendorLabel })}\n        type=\"password\"\n        placeholder={placeholder}\n        value={formData.lazyllm_api_keys[vendor] || ''}\n        onChange={(e) => {\n          setFormData(prev => ({\n            ...prev,\n            lazyllm_api_keys: { ...prev.lazyllm_api_keys, [vendor]: e.target.value }\n          }));\n        }}\n      />\n      <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">{t('settings.fields.vendorApiKeyDesc')}</p>\n    </div>\n  );\n};\n\nconst formDataFromSettings = (data: SettingsType): typeof initialFormData => ({\n  ai_provider_format: resolveLazyllmVendor(data.ai_provider_format || 'gemini', data.lazyllm_api_keys_info),\n  api_base_url: data.api_base_url || '',\n  api_key: '',\n  image_resolution: data.image_resolution || '2K',\n  max_description_workers: data.max_description_workers || 5,\n  max_image_workers: data.max_image_workers || 8,\n  text_model: data.text_model || '',\n  image_model: data.image_model || '',\n  mineru_api_base: data.mineru_api_base || '',\n  mineru_token: '',\n  image_caption_model: data.image_caption_model || '',\n  output_language: data.output_language || 'zh',\n  enable_text_reasoning: data.enable_text_reasoning || false,\n  text_thinking_budget: data.text_thinking_budget || 1024,\n  enable_image_reasoning: data.enable_image_reasoning || false,\n  image_thinking_budget: data.image_thinking_budget || 1024,\n  baidu_api_key: '',\n  text_model_source: data.text_model_source || '',\n  image_model_source: data.image_model_source || '',\n  image_caption_model_source: data.image_caption_model_source || '',\n  lazyllm_api_keys: {},\n  text_api_key: '',\n  text_api_base_url: data.text_api_base_url || '',\n  image_api_key: '',\n  image_api_base_url: data.image_api_base_url || '',\n  image_caption_api_key: '',\n  image_caption_api_base_url: data.image_caption_api_base_url || '',\n});\n\n// Settings 组件 - 纯嵌入模式（可复用）\nexport const Settings: React.FC = () => {\n  const t = useT(settingsI18n);\n  const { show, ToastContainer } = useToast();\n  const { confirm, ConfirmDialog } = useConfirm();\n\n  const copyToClipboard = (text: string) => {\n    if (navigator.clipboard) {\n      navigator.clipboard.writeText(text);\n    } else {\n      const textarea = document.createElement('textarea');\n      textarea.value = text;\n      textarea.style.position = 'fixed';\n      textarea.style.opacity = '0';\n      document.body.appendChild(textarea);\n      textarea.select();\n      document.execCommand('copy');\n      document.body.removeChild(textarea);\n    }\n    show({ message: '链接已复制到剪贴板', type: 'success' });\n  };\n\n  const [settings, setSettings] = useState<SettingsType | null>(null);\n  const [isLoading, setIsLoading] = useState(true);\n  const [isSaving, setIsSaving] = useState(false);\n  const [formData, setFormData] = useState(initialFormData);\n  const [serviceTestStates, setServiceTestStates] = useState<Record<string, ServiceTestState>>({});\n\n  // 配置驱动的表单区块定义（使用翻译）\n  const settingsSections: SectionConfig[] = [\n    // Global API config & Model config are rendered separately above\n    {\n      title: t('settings.sections.mineruConfig'),\n      icon: <FileText size={20} />,\n      fields: [\n        {\n          key: 'mineru_api_base',\n          label: t('settings.fields.mineruApiBase'),\n          type: 'text',\n          placeholder: t('settings.fields.mineruApiBasePlaceholder'),\n          description: t('settings.fields.mineruApiBaseDesc'),\n        },\n        {\n          key: 'mineru_token',\n          label: t('settings.fields.mineruToken'),\n          type: 'password',\n          placeholder: t('settings.fields.mineruTokenPlaceholder'),\n          sensitiveField: true,\n          lengthKey: 'mineru_token_length',\n          description: t('settings.fields.mineruTokenDesc'),\n          link: 'https://mineru.net/apiManage/token',\n        },\n      ],\n    },\n    {\n      title: t('settings.sections.imageConfig'),\n      icon: <Image size={20} />,\n      fields: [\n        {\n          key: 'image_resolution',\n          label: t('settings.fields.imageResolution'),\n          type: 'select',\n          description: t('settings.fields.imageResolutionDesc'),\n          options: [\n            { value: '1K', label: '1K (1024px)' },\n            { value: '2K', label: '2K (2048px)' },\n            { value: '4K', label: '4K (4096px)' },\n          ],\n        },\n      ],\n    },\n    {\n      title: t('settings.sections.performanceConfig'),\n      icon: <Zap size={20} />,\n      fields: [\n        {\n          key: 'max_description_workers',\n          label: t('settings.fields.maxDescriptionWorkers'),\n          type: 'number',\n          min: 1,\n          max: 20,\n          description: t('settings.fields.maxDescriptionWorkersDesc'),\n        },\n        {\n          key: 'max_image_workers',\n          label: t('settings.fields.maxImageWorkers'),\n          type: 'number',\n          min: 1,\n          max: 20,\n          description: t('settings.fields.maxImageWorkersDesc'),\n        },\n      ],\n    },\n    {\n      title: t('settings.sections.outputLanguage'),\n      icon: <Globe size={20} />,\n      fields: [\n        {\n          key: 'output_language',\n          label: t('settings.fields.defaultOutputLanguage'),\n          type: 'buttons',\n          description: t('settings.fields.defaultOutputLanguageDesc'),\n          options: OUTPUT_LANGUAGE_OPTIONS,\n        },\n      ],\n    },\n    {\n      title: t('settings.sections.textReasoning'),\n      icon: <Brain size={20} />,\n      fields: [\n        {\n          key: 'enable_text_reasoning',\n          label: t('settings.fields.enableTextReasoning'),\n          type: 'switch',\n          description: t('settings.fields.enableTextReasoningDesc'),\n        },\n        {\n          key: 'text_thinking_budget',\n          label: t('settings.fields.textThinkingBudget'),\n          type: 'number',\n          min: 1,\n          max: 8192,\n          description: t('settings.fields.textThinkingBudgetDesc'),\n        },\n      ],\n    },\n    {\n      title: t('settings.sections.imageReasoning'),\n      icon: <Brain size={20} />,\n      fields: [\n        {\n          key: 'enable_image_reasoning',\n          label: t('settings.fields.enableImageReasoning'),\n          type: 'switch',\n          description: t('settings.fields.enableImageReasoningDesc'),\n        },\n        {\n          key: 'image_thinking_budget',\n          label: t('settings.fields.imageThinkingBudget'),\n          type: 'number',\n          min: 1,\n          max: 8192,\n          description: t('settings.fields.imageThinkingBudgetDesc'),\n        },\n      ],\n    },\n    {\n      title: t('settings.sections.baiduOcr'),\n      icon: <FileText size={20} />,\n      fields: [\n        {\n          key: 'baidu_api_key',\n          label: t('settings.fields.baiduOcrApiKey'),\n          type: 'password',\n          placeholder: t('settings.fields.baiduOcrApiKeyPlaceholder'),\n          sensitiveField: true,\n          lengthKey: 'baidu_api_key_length',\n          description: t('settings.fields.baiduOcrApiKeyDesc'),\n          link: 'https://console.bce.baidu.com/iam/#/iam/apikey/list',\n        },\n      ],\n    },\n  ];\n\n  useEffect(() => {\n    loadSettings();\n  }, []);\n\n  const loadSettings = async () => {\n    setIsLoading(true);\n    try {\n      const response = await api.getSettings();\n      if (response.data) {\n        setSettings(response.data);\n        setFormData(formDataFromSettings(response.data));\n        sessionStorage.setItem('banana-settings', JSON.stringify(response.data));\n      }\n    } catch (error: any) {\n      console.error('加载设置失败:', error);\n      show({\n        message: t('settings.messages.loadFailed') + ': ' + (error?.message || t('settings.messages.unknownError')),\n        type: 'error'\n      });\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  const handleSave = async () => {\n    setIsSaving(true);\n    try {\n      const {\n        api_key, mineru_token, baidu_api_key, lazyllm_api_keys,\n        text_api_key, image_api_key, image_caption_api_key,\n        ...otherData\n      } = formData;\n      const payload: Parameters<typeof api.updateSettings>[0] = {\n        ...otherData,\n        ai_provider_format: otherData.ai_provider_format,\n      };\n\n      // Only send sensitive fields if user entered a new value\n      if (api_key) payload.api_key = api_key;\n      if (mineru_token) payload.mineru_token = mineru_token;\n      if (baidu_api_key) payload.baidu_api_key = baidu_api_key;\n      if (text_api_key) payload.text_api_key = text_api_key;\n      if (image_api_key) payload.image_api_key = image_api_key;\n      if (image_caption_api_key) payload.image_caption_api_key = image_caption_api_key;\n\n      // Send lazyllm API keys (only non-empty values)\n      const nonEmptyKeys = Object.fromEntries(\n        Object.entries(lazyllm_api_keys).filter(([, v]) => v)\n      );\n      if (Object.keys(nonEmptyKeys).length > 0) {\n        payload.lazyllm_api_keys = nonEmptyKeys;\n      }\n\n      const response = await api.updateSettings(payload);\n      if (response.data) {\n        setSettings(response.data);\n        sessionStorage.setItem('banana-settings', JSON.stringify(response.data));\n        show({ message: t('settings.messages.saveSuccess'), type: 'success' });\n        show({ message: t('settings.messages.testServiceTip'), type: 'info' });\n        // Clear all sensitive fields after save\n        setFormData(prev => ({\n          ...prev,\n          api_key: '', mineru_token: '', baidu_api_key: '',\n          lazyllm_api_keys: {},\n          text_api_key: '', image_api_key: '', image_caption_api_key: '',\n        }));\n      }\n    } catch (error: any) {\n      console.error('保存设置失败:', error);\n      show({\n        message: t('settings.messages.saveFailed') + ': ' + (error?.response?.data?.error?.message || error?.message || t('settings.messages.unknownError')),\n        type: 'error'\n      });\n    } finally {\n      setIsSaving(false);\n    }\n  };\n\n  const handleReset = () => {\n    confirm(\n      t('settings.messages.resetConfirm'),\n      async () => {\n        setIsSaving(true);\n        try {\n          const response = await api.resetSettings();\n          if (response.data) {\n            setSettings(response.data);\n            setFormData(formDataFromSettings(response.data));\n            show({ message: t('settings.messages.resetSuccess'), type: 'success' });\n          }\n        } catch (error: any) {\n          console.error('重置设置失败:', error);\n          show({\n            message: t('settings.messages.resetFailed') + ': ' + (error?.message || t('settings.messages.unknownError')),\n            type: 'error'\n          });\n        } finally {\n          setIsSaving(false);\n        }\n      },\n      {\n        title: t('settings.messages.resetTitle'),\n        confirmText: t('settings.messages.resetConfirmBtn'),\n        cancelText: t('settings.messages.resetCancelBtn'),\n        variant: 'warning',\n      }\n    );\n  };\n\n  const handleFieldChange = (key: string, value: any) => {\n    setFormData(prev => ({ ...prev, [key]: value }));\n  };\n\n  const updateServiceTest = (key: string, nextState: ServiceTestState) => {\n    setServiceTestStates(prev => ({ ...prev, [key]: nextState }));\n  };\n\n  const handleServiceTest = async (\n    key: string,\n    action: (settings?: any) => Promise<any>,\n    formatDetail: (data: any) => string\n  ) => {\n    updateServiceTest(key, { status: 'loading' });\n    try {\n      // 准备测试时要使用的设置（包括未保存的修改）\n      const testSettings: any = {};\n\n      // 只传递用户已填写的非空值\n      if (formData.api_key) testSettings.api_key = formData.api_key;\n      if (formData.api_base_url) testSettings.api_base_url = formData.api_base_url;\n      if (formData.ai_provider_format) {\n        testSettings.ai_provider_format = formData.ai_provider_format;\n      }\n      if (formData.text_model) testSettings.text_model = formData.text_model;\n      if (formData.image_model) testSettings.image_model = formData.image_model;\n      if (formData.image_caption_model) testSettings.image_caption_model = formData.image_caption_model;\n      if (formData.mineru_api_base) testSettings.mineru_api_base = formData.mineru_api_base;\n      if (formData.mineru_token) testSettings.mineru_token = formData.mineru_token;\n      if (formData.baidu_api_key) testSettings.baidu_api_key = formData.baidu_api_key;\n      if (formData.image_resolution) testSettings.image_resolution = formData.image_resolution;\n\n      // Per-model provider source overrides (always send, even empty, to clear saved values)\n      testSettings.text_model_source = formData.text_model_source || '';\n      testSettings.image_model_source = formData.image_model_source || '';\n      testSettings.image_caption_model_source = formData.image_caption_model_source || '';\n\n      // Per-model API credentials\n      if (formData.text_api_key) testSettings.text_api_key = formData.text_api_key;\n      if (formData.text_api_base_url) testSettings.text_api_base_url = formData.text_api_base_url;\n      if (formData.image_api_key) testSettings.image_api_key = formData.image_api_key;\n      if (formData.image_api_base_url) testSettings.image_api_base_url = formData.image_api_base_url;\n      if (formData.image_caption_api_key) testSettings.image_caption_api_key = formData.image_caption_api_key;\n      if (formData.image_caption_api_base_url) testSettings.image_caption_api_base_url = formData.image_caption_api_base_url;\n\n      // 推理模式设置\n      if (formData.enable_text_reasoning !== undefined) {\n        testSettings.enable_text_reasoning = formData.enable_text_reasoning;\n      }\n      if (formData.text_thinking_budget !== undefined) {\n        testSettings.text_thinking_budget = formData.text_thinking_budget;\n      }\n      if (formData.enable_image_reasoning !== undefined) {\n        testSettings.enable_image_reasoning = formData.enable_image_reasoning;\n      }\n      if (formData.image_thinking_budget !== undefined) {\n        testSettings.image_thinking_budget = formData.image_thinking_budget;\n      }\n\n      // 启动异步测试，获取任务ID\n      const response = await action(testSettings);\n      const taskId = response.data.task_id;\n\n      // 开始轮询任务状态\n      const pollInterval = setInterval(async () => {\n        try {\n          const statusResponse = await api.getTestStatus(taskId);\n          const taskStatus = statusResponse.data.status;\n\n          if (taskStatus === 'COMPLETED') {\n            clearInterval(pollInterval);\n            const detail = formatDetail(statusResponse.data.result || {});\n            const message = statusResponse.data.message || t('settings.messages.testSuccess');\n            updateServiceTest(key, { status: 'success', message, detail });\n            show({ message, type: 'success' });\n          } else if (taskStatus === 'FAILED') {\n            clearInterval(pollInterval);\n            const errorMessage = statusResponse.data.error || t('settings.serviceTest.testFailed');\n            updateServiceTest(key, { status: 'error', message: errorMessage });\n            show({ message: `${t('settings.serviceTest.testFailed')}: ${errorMessage}`, type: 'error' });\n          }\n          // 如果是 PENDING 或 PROCESSING，继续轮询\n        } catch (pollError: any) {\n          clearInterval(pollInterval);\n          const errorMessage = pollError?.response?.data?.error?.message || pollError?.message || t('settings.serviceTest.testFailed');\n          updateServiceTest(key, { status: 'error', message: errorMessage });\n          show({ message: `${t('settings.serviceTest.testFailed')}: ${errorMessage}`, type: 'error' });\n        }\n      }, 2000); // 每2秒轮询一次\n\n      // 设置最大轮询时间（2分钟）\n      setTimeout(() => {\n        clearInterval(pollInterval);\n        if (serviceTestStates[key]?.status === 'loading') {\n          updateServiceTest(key, { status: 'error', message: t('settings.serviceTest.testTimeout') });\n          show({ message: t('settings.serviceTest.testTimeout'), type: 'error' });\n        }\n      }, 120000);\n\n    } catch (error: any) {\n      const errorMessage = error?.response?.data?.error?.message || error?.message || t('common.unknownError');\n      updateServiceTest(key, { status: 'error', message: errorMessage });\n      show({ message: `${t('settings.serviceTest.testFailed')}: ${errorMessage}`, type: 'error' });\n    }\n  };\n\n  const renderField = (field: FieldConfig) => {\n    const value = formData[field.key] as string | number | boolean;\n\n    if (field.type === 'buttons' && field.options) {\n      return (\n        <div key={field.key}>\n          <label className=\"block text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-2\">\n            {field.label}\n          </label>\n          <div className=\"flex flex-wrap gap-2\">\n            {field.options.map((option) => (\n              <button\n                key={option.value}\n                type=\"button\"\n                onClick={() => handleFieldChange(field.key, option.value)}\n                className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${\n                  value === option.value\n                    ? option.value === 'openai'\n                      ? 'bg-gradient-to-r from-sky-500 to-blue-600 text-white shadow-md'\n                      : option.value === 'lazyllm'\n                        ? 'bg-gradient-to-r from-amber-500 to-orange-600 text-white shadow-md'\n                        : 'bg-gradient-to-r from-emerald-500 to-green-600 text-white shadow-md'\n                    : 'bg-white dark:bg-background-secondary border border-gray-200 dark:border-border-primary text-gray-700 dark:text-foreground-secondary hover:bg-gray-50 dark:hover:bg-background-hover hover:border-gray-300 dark:hover:border-gray-500'\n                }`}\n              >\n                {option.label}\n              </button>\n            ))}\n          </div>\n          {field.description && (\n            <p className=\"mt-1 text-xs text-gray-500 dark:text-foreground-tertiary\">{field.description}</p>\n          )}\n        </div>\n      );\n    }\n\n    if (field.type === 'select' && field.options) {\n      return (\n        <div key={field.key}>\n          <label className=\"block text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-2\">\n            {field.label}\n          </label>\n          <select\n            value={value as string}\n            onChange={(e) => handleFieldChange(field.key, e.target.value)}\n            className=\"w-full h-10 px-4 rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary focus:outline-none focus:ring-2 focus:ring-banana-500 focus:border-transparent\"\n          >\n            {!(value as string) && (\n              <option value=\"\" disabled>\n                {field.placeholder || t('settings.fields.selectPlaceholder')}\n              </option>\n            )}\n            {field.options.map((option) => (\n              <option key={option.value} value={option.value}>\n                {option.label}\n              </option>\n            ))}\n          </select>\n          {field.description && (\n            <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">{field.description}</p>\n          )}\n        </div>\n      );\n    }\n\n    // switch 类型 - 开关切换\n    if (field.type === 'switch') {\n      const isEnabled = Boolean(value);\n      return (\n        <div key={field.key}>\n          <div className=\"flex items-center justify-between\">\n            <label className=\"block text-sm font-medium text-gray-700 dark:text-foreground-secondary\">\n              {field.label}\n            </label>\n            <button\n              type=\"button\"\n              onClick={() => handleFieldChange(field.key, !isEnabled)}\n              className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-banana-500 focus:ring-offset-2 ${\n                isEnabled ? 'bg-banana-500' : 'bg-gray-200'\n              }`}\n            >\n              <span\n                className={`inline-block h-4 w-4 transform rounded-full bg-white dark:bg-background-secondary transition-transform ${\n                  isEnabled ? 'translate-x-6' : 'translate-x-1'\n                }`}\n              />\n            </button>\n          </div>\n          {field.description && (\n            <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">{field.description}</p>\n          )}\n        </div>\n      );\n    }\n\n    // text, password, number 类型\n    const placeholder = field.sensitiveField && settings && field.lengthKey && (settings[field.lengthKey] as number) > 0\n      ? t('settings.fields.apiKeySet', { length: settings[field.lengthKey] as string | number })\n      : field.placeholder || '';\n\n    // 判断是否禁用（思考负载字段在对应开关关闭时禁用）\n    let isDisabled = false;\n    if (field.key === 'text_thinking_budget') {\n      isDisabled = !formData.enable_text_reasoning;\n    } else if (field.key === 'image_thinking_budget') {\n      isDisabled = !formData.enable_image_reasoning;\n    }\n\n    return (\n      <div key={field.key} className={isDisabled ? 'opacity-50' : ''}>\n        <Input\n          label={field.label}\n          type={field.type === 'number' ? 'number' : field.type}\n          placeholder={placeholder}\n          value={value as string | number}\n          onChange={(e) => {\n            const newValue = field.type === 'number' \n              ? parseInt(e.target.value) || (field.min ?? 0)\n              : e.target.value;\n            handleFieldChange(field.key, newValue);\n          }}\n          min={field.min}\n          max={field.max}\n          disabled={isDisabled}\n        />\n        {(field.description || field.link) && (\n          <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">\n            {field.description}\n            {field.link && (\n              <a href={field.link} target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-banana-500 hover:underline\">{t('settings.fields.applyLink')}</a>\n            )}\n          </p>\n        )}\n      </div>\n    );\n  };\n\n  // 模型配置项定义：每种模型类型的 key、source key、api key/base key、标签等\n  const modelConfigItems = [\n    {\n      modelKey: 'text_model' as keyof typeof initialFormData,\n      sourceKey: 'text_model_source' as keyof typeof initialFormData,\n      apiKeyKey: 'text_api_key' as keyof typeof initialFormData,\n      apiBaseKey: 'text_api_base_url' as keyof typeof initialFormData,\n      apiKeyLengthKey: 'text_api_key_length' as keyof SettingsType,\n      label: t('settings.fields.textModel'),\n      placeholder: t('settings.fields.textModelPlaceholder'),\n      description: t('settings.fields.textModelDesc'),\n      sourceLabel: t('settings.fields.textModelSource'),\n    },\n    {\n      modelKey: 'image_model' as keyof typeof initialFormData,\n      sourceKey: 'image_model_source' as keyof typeof initialFormData,\n      apiKeyKey: 'image_api_key' as keyof typeof initialFormData,\n      apiBaseKey: 'image_api_base_url' as keyof typeof initialFormData,\n      apiKeyLengthKey: 'image_api_key_length' as keyof SettingsType,\n      label: t('settings.fields.imageModel'),\n      placeholder: t('settings.fields.imageModelPlaceholder'),\n      description: t('settings.fields.imageModelDesc'),\n      sourceLabel: t('settings.fields.imageModelSource'),\n    },\n    {\n      modelKey: 'image_caption_model' as keyof typeof initialFormData,\n      sourceKey: 'image_caption_model_source' as keyof typeof initialFormData,\n      apiKeyKey: 'image_caption_api_key' as keyof typeof initialFormData,\n      apiBaseKey: 'image_caption_api_base_url' as keyof typeof initialFormData,\n      apiKeyLengthKey: 'image_caption_api_key_length' as keyof SettingsType,\n      label: t('settings.fields.imageCaptionModel'),\n      placeholder: t('settings.fields.imageCaptionModelPlaceholder'),\n      description: t('settings.fields.imageCaptionModelDesc'),\n      sourceLabel: t('settings.fields.imageCaptionModelSource'),\n    },\n  ];\n\n  // 渲染单个模型配置组（模型名 + 提供商选择 + 条件凭证）\n  const renderModelConfigGroup = (item: typeof modelConfigItems[0]) => {\n    const sourceValue = formData[item.sourceKey] as string;\n    const isApiKeyProvider = API_KEY_PROVIDERS.has(sourceValue);\n    const isLazyllm = sourceValue && isLazyllmVendor(sourceValue);\n    // 'openai' in source dropdown means OpenAI format (API key provider), not lazyllm openai vendor\n    // lazyllm openai vendor is handled separately\n\n    return (\n      <div key={item.modelKey} className=\"p-4 bg-gray-50 dark:bg-background-primary border border-gray-200 dark:border-border-primary rounded-lg space-y-3\">\n        {/* 模型名称 */}\n        <Input\n          label={item.label}\n          type=\"text\"\n          placeholder={item.placeholder}\n          value={formData[item.modelKey] as string}\n          onChange={(e) => handleFieldChange(item.modelKey, e.target.value)}\n        />\n        {item.description && (\n          <p className=\"-mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">{item.description}</p>\n        )}\n\n        {/* 提供商选择 */}\n        <div>\n          <label className=\"block text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-2\">\n            {item.sourceLabel}\n          </label>\n          <select\n            value={sourceValue}\n            onChange={(e) => handleFieldChange(item.sourceKey, e.target.value)}\n            className=\"w-full h-10 px-4 rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary focus:outline-none focus:ring-2 focus:ring-banana-500 focus:border-transparent\"\n          >\n            <option value=\"\">{t('settings.fields.modelProviderPlaceholder')}</option>\n            {ALL_PROVIDER_SOURCES.map((option) => (\n              <option key={option.value} value={option.value}>\n                {option.label}\n              </option>\n            ))}\n          </select>\n          <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">\n            {t('settings.fields.modelProviderDesc')}\n          </p>\n        </div>\n\n        {/* Gemini/OpenAI 提供商：显示 API Base URL + API Key */}\n        {isApiKeyProvider && (\n          <div className=\"space-y-3 pl-3 border-l-2 border-banana-300 dark:border-banana-600\">\n            <Input\n              label={t('settings.fields.perModelApiBaseUrl')}\n              type=\"text\"\n              placeholder={t('settings.fields.perModelApiBaseUrlPlaceholder')}\n              value={formData[item.apiBaseKey] as string}\n              onChange={(e) => handleFieldChange(item.apiBaseKey, e.target.value)}\n            />\n            <div>\n              <Input\n                label={t('settings.fields.perModelApiKey')}\n                type=\"password\"\n                placeholder={\n                  settings && (settings[item.apiKeyLengthKey] as number) > 0\n                    ? t('settings.fields.perModelApiKeySet', { length: settings[item.apiKeyLengthKey] as number })\n                    : t('settings.fields.perModelApiKeyPlaceholder')\n                }\n                value={formData[item.apiKeyKey] as string}\n                onChange={(e) => handleFieldChange(item.apiKeyKey, e.target.value)}\n              />\n              <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">\n                {t('settings.fields.perModelApiKeyDesc')}\n              </p>\n            </div>\n          </div>\n        )}\n\n        {/* LazyLLM 厂商：显示厂商 API Key */}\n        {isLazyllm && (() => {\n          const vendorLabel = LAZYLLM_SOURCES.find(s => s.value === sourceValue)?.label || sourceValue.toUpperCase();\n          const keyLength = settings?.lazyllm_api_keys_info?.[sourceValue] || 0;\n          const placeholder = keyLength > 0\n            ? t('settings.fields.vendorApiKeySet', { length: keyLength })\n            : t('settings.fields.vendorApiKeyPlaceholder', { vendor: vendorLabel });\n          return (\n            <div className=\"pl-3 border-l-2 border-amber-300 dark:border-amber-600\">\n              <Input\n                label={t('settings.fields.vendorApiKey', { vendor: vendorLabel })}\n                type=\"password\"\n                placeholder={placeholder}\n                value={formData.lazyllm_api_keys[sourceValue] || ''}\n                onChange={(e) => {\n                  setFormData(prev => ({\n                    ...prev,\n                    lazyllm_api_keys: { ...prev.lazyllm_api_keys, [sourceValue]: e.target.value }\n                  }));\n                }}\n              />\n              <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">\n                {t('settings.fields.vendorApiKeyDesc')}\n              </p>\n            </div>\n          );\n        })()}\n      </div>\n    );\n  };\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center py-12\">\n        <Loading message={t('common.loading')} />\n      </div>\n    );\n  }\n\n  return (\n    <>\n      <ToastContainer />\n      {ConfirmDialog}\n      <div className=\"space-y-8\">\n        {/* 默认 API 配置区块 */}\n        <div data-testid=\"global-api-config-section\">\n          <h2 className=\"text-xl font-semibold text-gray-900 dark:text-foreground-primary mb-1 flex items-center\">\n            <Key size={20} />\n            <span className=\"ml-2\">{t('settings.sections.apiConfig')}</span>\n          </h2>\n          <p className=\"text-sm text-gray-500 dark:text-foreground-tertiary mb-4\">{t('settings.sections.apiConfigDesc')}</p>\n          <div className=\"p-4 bg-gray-50 dark:bg-background-primary border border-gray-200 dark:border-border-primary rounded-lg space-y-3\">\n            {/* 提供商下拉 */}\n            <div>\n              <label className=\"block text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-2\">\n                {t('settings.fields.aiProviderFormat')}\n              </label>\n              <select\n                value={formData.ai_provider_format}\n                onChange={(e) => handleFieldChange('ai_provider_format', e.target.value)}\n                className=\"w-full h-10 px-4 rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary focus:outline-none focus:ring-2 focus:ring-banana-500 focus:border-transparent\"\n              >\n                {ALL_PROVIDER_SOURCES.map((option) => (\n                  <option key={option.value} value={option.value}>{option.label}</option>\n                ))}\n              </select>\n              <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">{t('settings.fields.aiProviderFormatDesc')}</p>\n            </div>\n\n            {/* Gemini/OpenAI: API Base URL + API Key */}\n            {API_KEY_PROVIDERS.has(formData.ai_provider_format) && (\n              <div className=\"space-y-3 pl-3 border-l-2 border-banana-300 dark:border-banana-600\">\n                <Input\n                  label={t('settings.fields.apiBaseUrl')}\n                  type=\"text\"\n                  placeholder={t('settings.fields.apiBaseUrlPlaceholder')}\n                  value={formData.api_base_url}\n                  onChange={(e) => handleFieldChange('api_base_url', e.target.value)}\n                />\n                <p className=\"-mt-2 text-sm text-gray-500 dark:text-foreground-tertiary\">{t('settings.fields.apiBaseUrlDesc')}</p>\n                <div>\n                  <Input\n                    label={t('settings.fields.apiKey')}\n                    type=\"password\"\n                    placeholder={\n                      settings && (settings.api_key_length as number) > 0\n                        ? t('settings.fields.apiKeySet', { length: settings.api_key_length })\n                        : t('settings.fields.apiKeyPlaceholder')\n                    }\n                    value={formData.api_key}\n                    onChange={(e) => handleFieldChange('api_key', e.target.value)}\n                  />\n                  <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">{t('settings.fields.apiKeyDesc')}</p>\n                </div>\n              </div>\n            )}\n\n            {/* LazyLLM 厂商: 厂商 API Key */}\n            {isLazyllmVendor(formData.ai_provider_format) && (\n              <GlobalVendorKeyInput vendor={formData.ai_provider_format} formData={formData} setFormData={setFormData} settings={settings} t={t} />\n            )}\n          </div>\n\n          {/* AIHubmix 提示 */}\n          <div className=\"mt-3 p-3 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg\">\n            <p className=\"text-sm text-gray-700 dark:text-foreground-secondary\">\n              {t('settings.apiKeyTip.before')}\n              <a href={['https://', 'aihubmix', '.com/?', 'aff=17EC'].join('')} target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-blue-600 hover:text-blue-800 underline font-medium\">AIHubmix 申请 API key</a>\n            </p>\n          </div>\n\n          {/* API Key 获取指南 */}\n          <div className=\"mt-2 p-3 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg\">\n            <p className=\"text-sm font-medium text-gray-800 dark:text-foreground-primary flex items-center gap-1.5 mb-2\">\n              <HelpCircle size={15} className=\"text-blue-500\" />\n              {t('settings.apiKeyHelp.title')}\n            </p>\n            <ol className=\"text-sm text-gray-700 dark:text-foreground-secondary space-y-1 list-decimal list-inside ml-1\">\n              <li>\n                {t('settings.apiKeyHelp.step1', { link: '{{link}}' }).split('{{link}}')[0]}\n                <span className=\"inline-flex items-center gap-2\">\n                  <a\n                    href={['https://', 'aihubmix', '.com/?', 'aff=17EC'].join('')}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className=\"text-blue-600 hover:text-blue-800 underline font-medium\"\n                  >\n                    点击此处访问 AIHubmix →\n                  </a>\n                  <button\n                    onClick={() => copyToClipboard('https://aihubmix.com/?aff=17EC')}\n                    className=\"text-xs px-2 py-0.5 bg-blue-100 hover:bg-blue-200 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded transition-colors\"\n                  >\n                    复制链接\n                  </button>\n                </span>\n                {t('settings.apiKeyHelp.step1', { link: '{{link}}' }).split('{{link}}')[1]}\n              </li>\n              <li>{t('settings.apiKeyHelp.step2')}</li>\n              <li>{t('settings.apiKeyHelp.step3')}</li>\n              <li>{t('settings.apiKeyHelp.step4')}</li>\n            </ol>\n          </div>\n        </div>\n\n        {/* 模型配置区块 */}\n        <div>\n          <h2 className=\"text-xl font-semibold text-gray-900 dark:text-foreground-primary mb-4 flex items-center\">\n            <FileText size={20} />\n            <span className=\"ml-2\">{t('settings.sections.modelConfig')}</span>\n          </h2>\n          <div className=\"space-y-4\">\n            {modelConfigItems.map(renderModelConfigGroup)}\n          </div>\n        </div>\n\n        {/* 其余配置区块（配置驱动） */}\n        <div className=\"space-y-8\">\n          {settingsSections.map((section) => (\n            <div key={section.title}>\n              <h2 className=\"text-xl font-semibold text-gray-900 dark:text-foreground-primary mb-4 flex items-center\">\n                {section.icon}\n                <span className=\"ml-2\">{section.title}</span>\n              </h2>\n              <div className=\"space-y-4\">\n                {section.fields.map((field) => renderField(field))}\n              </div>\n            </div>\n          ))}\n        </div>\n\n        {/* 服务测试区 */}\n        <div className=\"space-y-4\">\n          <h2 className=\"text-xl font-semibold text-gray-900 dark:text-foreground-primary mb-2 flex items-center\">\n            <FileText size={20} />\n            <span className=\"ml-2\">{t('settings.serviceTest.title')}</span>\n          </h2>\n          <p className=\"text-sm text-gray-500 dark:text-foreground-tertiary\">\n            {t('settings.serviceTest.description')}\n          </p>\n          <div className=\"p-3 bg-yellow-50 dark:bg-background-primary border border-yellow-200 dark:border-yellow-700 rounded-lg\">\n            <p className=\"text-sm text-gray-700 dark:text-foreground-secondary\">\n              💡 {t('settings.serviceTest.tip')}\n            </p>\n          </div>\n          <div className=\"space-y-4\">\n            {[\n              {\n                key: 'baidu-ocr',\n                titleKey: 'settings.serviceTest.tests.baiduOcr.title',\n                descriptionKey: 'settings.serviceTest.tests.baiduOcr.description',\n                resultKey: 'settings.serviceTest.results.recognizedText',\n                action: api.testBaiduOcr,\n                formatDetail: (data: any) => (data?.recognized_text ? t('settings.serviceTest.results.recognizedText', { text: data.recognized_text }) : ''),\n              },\n              {\n                key: 'text-model',\n                titleKey: 'settings.serviceTest.tests.textModel.title',\n                descriptionKey: 'settings.serviceTest.tests.textModel.description',\n                resultKey: 'settings.serviceTest.results.modelReply',\n                action: api.testTextModel,\n                formatDetail: (data: any) => (data?.reply ? t('settings.serviceTest.results.modelReply', { reply: data.reply }) : ''),\n              },\n              {\n                key: 'caption-model',\n                titleKey: 'settings.serviceTest.tests.captionModel.title',\n                descriptionKey: 'settings.serviceTest.tests.captionModel.description',\n                resultKey: 'settings.serviceTest.results.captionDesc',\n                action: api.testCaptionModel,\n                formatDetail: (data: any) => (data?.caption ? t('settings.serviceTest.results.captionDesc', { caption: data.caption }) : ''),\n              },\n              {\n                key: 'baidu-inpaint',\n                titleKey: 'settings.serviceTest.tests.baiduInpaint.title',\n                descriptionKey: 'settings.serviceTest.tests.baiduInpaint.description',\n                resultKey: 'settings.serviceTest.results.imageSize',\n                action: api.testBaiduInpaint,\n                formatDetail: (data: any) => (data?.image_size ? t('settings.serviceTest.results.imageSize', { width: data.image_size[0], height: data.image_size[1] }) : ''),\n              },\n              {\n                key: 'image-model',\n                titleKey: 'settings.serviceTest.tests.imageModel.title',\n                descriptionKey: 'settings.serviceTest.tests.imageModel.description',\n                resultKey: 'settings.serviceTest.results.imageSize',\n                action: api.testImageModel,\n                formatDetail: (data: any) => (data?.image_size ? t('settings.serviceTest.results.imageSize', { width: data.image_size[0], height: data.image_size[1] }) : ''),\n              },\n              {\n                key: 'mineru-pdf',\n                titleKey: 'settings.serviceTest.tests.mineruPdf.title',\n                descriptionKey: 'settings.serviceTest.tests.mineruPdf.description',\n                resultKey: 'settings.serviceTest.results.parsePreview',\n                action: api.testMineruPdf,\n                formatDetail: (data: any) => (data?.content_preview ? t('settings.serviceTest.results.parsePreview', { preview: data.content_preview }) : data?.message || ''),\n              },\n            ].map((item) => {\n              const testState = serviceTestStates[item.key] || { status: 'idle' as TestStatus };\n              const isLoadingTest = testState.status === 'loading';\n              return (\n                <div\n                  key={item.key}\n                  className=\"p-4 bg-gray-50 dark:bg-background-primary border border-gray-200 dark:border-border-primary rounded-lg space-y-2\"\n                >\n                  <div className=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n                    <div>\n                      <div className=\"text-base font-semibold text-gray-800 dark:text-foreground-primary\">{t(item.titleKey)}</div>\n                      <div className=\"text-sm text-gray-500 dark:text-foreground-tertiary\">{t(item.descriptionKey)}</div>\n                    </div>\n                    <Button\n                      variant=\"secondary\"\n                      size=\"sm\"\n                      loading={isLoadingTest}\n                      onClick={() => handleServiceTest(item.key, item.action, item.formatDetail)}\n                    >\n                      {isLoadingTest ? t('settings.serviceTest.testing') : t('settings.serviceTest.startTest')}\n                    </Button>\n                  </div>\n                  {testState.status === 'success' && (\n                    <p className=\"text-sm text-green-600\">\n                      {testState.message}{testState.detail ? `｜${testState.detail}` : ''}\n                    </p>\n                  )}\n                  {testState.status === 'error' && (\n                    <p className=\"text-sm text-red-600\">\n                      {testState.message}\n                    </p>\n                  )}\n                </div>\n              );\n            })}\n          </div>\n        </div>\n\n        {/* 操作按钮 */}\n        <div className=\"flex items-center justify-between pt-4 border-t border-gray-200 dark:border-border-primary\">\n          <Button\n            variant=\"secondary\"\n            icon={<RotateCcw size={18} />}\n            onClick={handleReset}\n            disabled={isSaving}\n          >\n            {t('settings.actions.resetToDefault')}\n          </Button>\n          <Button\n            variant=\"primary\"\n            icon={<Save size={18} />}\n            onClick={handleSave}\n            loading={isSaving}\n          >\n            {isSaving ? t('settings.actions.saving') : t('settings.actions.save')}\n          </Button>\n        </div>\n      </div>\n    </>\n  );\n};\n\n// SettingsPage 组件 - 完整页面包装\nconst SCROLL_SHOW_THRESHOLD = 300;\n\nexport const SettingsPage: React.FC = () => {\n  const navigate = useNavigate();\n  const t = useT(settingsI18n);\n  const [showTop, setShowTop] = useState(false);\n\n  useEffect(() => {\n    const onScroll = () => setShowTop(window.scrollY > SCROLL_SHOW_THRESHOLD);\n    window.addEventListener('scroll', onScroll, { passive: true });\n    return () => window.removeEventListener('scroll', onScroll);\n  }, []);\n\n  return (\n    <div className=\"min-h-screen bg-gradient-to-br from-banana-50 dark:from-background-primary to-yellow-50 dark:to-background-primary\">\n      <div className=\"container mx-auto px-4 py-8 max-w-4xl\">\n        <Card className=\"p-6 md:p-8\">\n          <div className=\"space-y-8\">\n            {/* 顶部标题 */}\n            <div className=\"flex items-center justify-between pb-6 border-b border-gray-200 dark:border-border-primary\">\n              <div className=\"flex items-center\">\n                <Button\n                  variant=\"secondary\"\n                  icon={<Home size={18} />}\n                  onClick={() => navigate('/')}\n                  className=\"mr-4\"\n                >\n                  {t('nav.backToHome')}\n                </Button>\n                <div>\n                  <h1 className=\"text-2xl font-bold text-gray-900 dark:text-foreground-primary\">{t('settings.title')}</h1>\n                  <p className=\"text-sm text-gray-500 dark:text-foreground-tertiary mt-1\">\n                    {t('settings.subtitle')}\n                  </p>\n                </div>\n              </div>\n            </div>\n\n            <Settings />\n          </div>\n        </Card>\n      </div>\n\n      {showTop && (\n        <button\n          data-testid=\"back-to-top-button\"\n          aria-label=\"Back to top\"\n          title=\"Back to top\"\n          onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}\n          className=\"fixed bottom-6 right-6 p-3 rounded-full bg-banana-500 text-white shadow-lg hover:bg-banana-600 transition-all z-50\"\n        >\n          <ArrowUp size={20} />\n        </button>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "frontend/src/pages/SlidePreview.tsx",
    "content": "// TODO: split components\nimport React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';\nimport { useNavigate, useParams, useLocation } from 'react-router-dom';\nimport { useT } from '@/hooks/useT';\nimport { devLog } from '@/utils/logger';\n\n// 组件内翻译\nconst previewI18n = {\n  zh: {\n    home: { title: '蕉幻' },\n    nav: { home: '主页', materialGenerate: '素材生成' },\n    slidePreview: {\n      pageGenerating: \"该页面正在生成中，请稍候...\", generationStarted: \"已开始生成图片，请稍候...\",\n      versionSwitched: \"已切换到该版本\", outlineSaved: \"大纲和描述已保存\",\n      materialsAdded: \"已添加 {{count}} 个素材\", exportStarted: \"导出任务已开始，可在导出任务面板查看进度\",\n      cannotRefresh: \"无法刷新：缺少项目ID\", refreshSuccess: \"刷新成功\",\n      extraRequirementsSaved: \"额外要求已保存\", styleDescSaved: \"风格描述已保存\",\n      exportSettingsSaved: \"导出设置已保存\", aspectRatioSaved: \"画面比例已保存\", loadTemplateFailed: \"加载模板失败\", templateChanged: \"模板更换成功\",\n      saveFailed: \"保存失败: {{error}}\", refreshFailed: \"刷新失败，请稍后重试\",\n      loadMaterialFailed: \"加载素材失败: {{error}}\", templateChangeFailed: \"更换模板失败: {{error}}\",\n      versionSwitchFailed: \"切换失败: {{error}}\", unknownError: \"未知错误\",\n      regionCropSuccess: \"已将选中区域添加为参考图片，可在下方\\\"上传图片\\\"中查看与删除\",\n      regionCropFailed: \"无法从当前图片裁剪区域（浏览器安全限制）。可以尝试手动上传参考图片。\"\n    },\n    preview: {\n      title: \"预览\", pageCount: \"共 {{count}} 页\", export: \"导出\",\n      exportPptx: \"导出为 PPTX\", exportPdf: \"导出为 PDF\",\n      exportEditablePptx: \"导出可编辑 PPTX（Beta）\", exportImages: \"导出为图片\",\n      exportSelectedPages: \"将导出选中的 {{count}} 页\",\n      regenerate: \"重新生成\", regenerating: \"生成中...\",\n      editMode: \"编辑模式\", viewMode: \"查看模式\", page: \"第 {{num}} 页\",\n      projectSettings: \"项目设置\", changeTemplate: \"更换模板\", refresh: \"刷新\",\n      batchGenerate: \"批量生成图片 ({{count}})\", generateSelected: \"生成选中页面 ({{count}})\",\n      multiSelect: \"多选\", cancelMultiSelect: \"取消多选\", pagesUnit: \"页\",\n      noPages: \"还没有页面\", noPagesHint: \"请先返回编辑页面添加内容\", backToEdit: \"返回编辑\",\n      generating: \"正在生成中...\", queued: \"排队等待生成...\", notGenerated: \"尚未生成图片\", generateThisPage: \"生成此页\",\n      prevPage: \"上一页\", nextPage: \"下一页\", historyVersions: \"历史版本\",\n      versions: \"版本\", version: \"版本\", current: \"当前\", editPage: \"编辑页面\",\n      regionSelect: \"区域选图\", endRegionSelect: \"结束区域选图\",\n      pageOutline: \"页面大纲（可编辑）\", pageDescription: \"页面描述（可编辑）\",\n      enterTitle: \"输入页面标题\", pointsPerLine: \"要点（每行一个）\",\n      enterPointsPerLine: \"每行输入一个要点\", enterDescription: \"输入页面的详细描述内容\",\n      selectContextImages: \"选择上下文图片（可选）\", useTemplateImage: \"使用模板图片\",\n      imagesInDescription: \"描述中的图片\", uploadImages: \"上传图片\",\n      selectFromMaterials: \"从素材库选择\", upload: \"上传\",\n      editPromptLabel: \"输入修改指令(将自动添加页面描述)\",\n      editPromptPlaceholder: \"例如：将框选区域内的素材移除、把背景改成蓝色、增大标题字号、更改文本框样式为虚线...\",\n      saveOutlineOnly: \"仅保存大纲/描述\", generateImage: \"生成图片\",\n      templateModalDesc: \"选择一个新的模板将应用到后续PPT页面生成（不影响已经生成的页面）。你可以选择预设模板、已有模板或上传新模板。\",\n      useTextStyle: \"使用文字描述风格\",\n      applyStyle: \"应用风格\",\n      styleSaved: \"风格描述已保存\",\n      uploadingTemplate: \"正在上传模板...\",\n      resolution1KWarning: \"1K分辨率警告\",\n      resolution1KWarningText: \"当前使用 1K 分辨率 生成图片，可能导致渲染的文字乱码或模糊。\",\n      resolution1KWarningHint: \"建议在「项目设置 → 全局设置」中切换到 2K 或 4K 分辨率以获得更清晰的效果。\",\n      dontShowAgain: \"不再提示\", generateAnyway: \"仍然生成\",\n      confirmRegenerateSelected: \"将重新生成选中的 {{count}} 页（历史记录将会保存），确定继续吗？\",\n      confirmRegenerateAll: \"将重新生成所有页面（历史记录将会保存），确定继续吗？\",\n      confirmRegenerateTitle: \"确认重新生成\",\n      generationFailed: \"生成失败\",\n      disabledExportTip: \"还有 {{count}} 页未生成图片，请先生成所有页面图片\",\n      disabledEditTip: \"请先生成该页图片\",\n      messages: {\n        exportSuccess: \"导出成功\", exportFailed: \"导出失败\",\n        regenerateSuccess: \"重新生成完成\", regenerateFailed: \"重新生成失败\",\n        loadingProject: \"加载项目中...\", processing: \"处理中...\",\n        generatingBackgrounds: \"正在生成干净背景...\", creatingPdf: \"正在创建PDF...\",\n        parsingContent: \"正在解析内容...\", creatingPptx: \"正在创建可编辑PPTX...\", complete: \"完成！\"\n      }\n    },\n    outline: {\n      titleLabel: \"标题\",\n      keyPoints: \"要点\"\n    }\n  },\n  en: {\n    home: { title: 'Banana Slides' },\n    nav: { home: 'Home', materialGenerate: 'Generate Material' },\n    slidePreview: {\n      pageGenerating: \"This page is generating, please wait...\", generationStarted: \"Image generation started, please wait...\",\n      versionSwitched: \"Switched to this version\", outlineSaved: \"Outline and description saved\",\n      materialsAdded: \"Added {{count}} material(s)\", exportStarted: \"Export task started, check progress in export tasks panel\",\n      cannotRefresh: \"Cannot refresh: Missing project ID\", refreshSuccess: \"Refresh successful\",\n      extraRequirementsSaved: \"Extra requirements saved\", styleDescSaved: \"Style description saved\",\n      exportSettingsSaved: \"Export settings saved\", aspectRatioSaved: \"Aspect ratio saved\", loadTemplateFailed: \"Failed to load template\", templateChanged: \"Template changed successfully\",\n      saveFailed: \"Save failed: {{error}}\", refreshFailed: \"Refresh failed, please try again later\",\n      loadMaterialFailed: \"Failed to load material: {{error}}\", templateChangeFailed: \"Failed to change template: {{error}}\",\n      versionSwitchFailed: \"Switch failed: {{error}}\", unknownError: \"Unknown error\",\n      regionCropSuccess: \"Selected region added as reference image. You can view and delete it in \\\"Upload Images\\\" below.\",\n      regionCropFailed: \"Cannot crop from current image (browser security restriction). Try uploading a reference image manually.\"\n    },\n    preview: {\n      title: \"Preview\", pageCount: \"{{count}} pages\", export: \"Export\",\n      exportPptx: \"Export as PPTX\", exportPdf: \"Export as PDF\",\n      exportEditablePptx: \"Export Editable PPTX (Beta)\", exportImages: \"Export as Images\",\n      exportSelectedPages: \"Will export {{count}} selected page(s)\",\n      regenerate: \"Regenerate\", regenerating: \"Generating...\",\n      editMode: \"Edit Mode\", viewMode: \"View Mode\", page: \"Page {{num}}\",\n      projectSettings: \"Project Settings\", changeTemplate: \"Change Template\", refresh: \"Refresh\",\n      batchGenerate: \"Batch Generate Images ({{count}})\", generateSelected: \"Generate Selected ({{count}})\",\n      multiSelect: \"Multi-select\", cancelMultiSelect: \"Cancel Multi-select\", pagesUnit: \" pages\",\n      noPages: \"No pages yet\", noPagesHint: \"Please go back to editor to add content first\", backToEdit: \"Back to Editor\",\n      generating: \"Generating...\", queued: \"Queued for generation...\", notGenerated: \"Image not generated yet\", generateThisPage: \"Generate This Page\",\n      prevPage: \"Previous\", nextPage: \"Next\", historyVersions: \"History Versions\",\n      versions: \"Versions\", version: \"Version\", current: \"Current\", editPage: \"Edit Page\",\n      regionSelect: \"Region Select\", endRegionSelect: \"End Region Select\",\n      pageOutline: \"Page Outline (Editable)\", pageDescription: \"Page Description (Editable)\",\n      enterTitle: \"Enter page title\", pointsPerLine: \"Key Points (one per line)\",\n      enterPointsPerLine: \"Enter one key point per line\", enterDescription: \"Enter detailed page description\",\n      selectContextImages: \"Select Context Images (Optional)\", useTemplateImage: \"Use Template Image\",\n      imagesInDescription: \"Images in Description\", uploadImages: \"Upload Images\",\n      selectFromMaterials: \"Select from Materials\", upload: \"Upload\",\n      editPromptLabel: \"Enter edit instructions (page description will be auto-added)\",\n      editPromptPlaceholder: \"e.g., Remove elements in selected area, change background to blue, increase title font size, change text box style to dashed...\",\n      saveOutlineOnly: \"Save Outline/Description Only\", generateImage: \"Generate Image\",\n      templateModalDesc: \"Selecting a new template will apply to future PPT page generation (won't affect already generated pages). You can choose preset templates, existing templates, or upload a new one.\",\n      useTextStyle: \"Use text description for style\",\n      applyStyle: \"Apply Style\",\n      styleSaved: \"Style description saved\",\n      uploadingTemplate: \"Uploading template...\",\n      resolution1KWarning: \"1K Resolution Warning\",\n      resolution1KWarningText: \"Currently using 1K resolution for image generation, which may cause garbled or blurry text.\",\n      resolution1KWarningHint: \"It's recommended to switch to 2K or 4K resolution in \\\"Project Settings → Global Settings\\\" for clearer results.\",\n      dontShowAgain: \"Don't show again\", generateAnyway: \"Generate Anyway\",\n      confirmRegenerateSelected: \"Will regenerate {{count}} selected page(s) (history will be saved). Continue?\",\n      confirmRegenerateAll: \"Will regenerate all pages (history will be saved). Continue?\",\n      confirmRegenerateTitle: \"Confirm Regenerate\",\n      generationFailed: \"Generation failed\",\n      disabledExportTip: \"{{count}} page(s) have no images yet. Please generate all page images first\",\n      disabledEditTip: \"Please generate this page's image first\",\n      messages: {\n        exportSuccess: \"Export successful\", exportFailed: \"Export failed\",\n        regenerateSuccess: \"Regeneration complete\", regenerateFailed: \"Failed to regenerate\",\n        loadingProject: \"Loading project...\", processing: \"Processing...\",\n        generatingBackgrounds: \"Generating clean backgrounds...\", creatingPdf: \"Creating PDF...\",\n        parsingContent: \"Parsing content...\", creatingPptx: \"Creating editable PPTX...\", complete: \"Complete!\"\n      }\n    },\n    outline: {\n      titleLabel: \"Title\",\n      keyPoints: \"Key Points\"\n    }\n  }\n};\nimport {\n  Home,\n  ArrowLeft,\n  Download,\n  RefreshCw,\n  ChevronLeft,\n  ChevronRight,\n  Sparkles,\n  ChevronDown,\n  ChevronUp,\n  X,\n  Upload,\n  Image as ImageIcon,\n  ImagePlus,\n  Settings,\n  CheckSquare,\n  Square,\n  Check,\n  FileText,\n  Loader2,\n} from 'lucide-react';\nimport { Button, Loading, Modal, Textarea, useToast, useConfirm, MaterialSelector, ProjectSettingsModal, ExportTasksPanel, TextStyleSelector } from '@/components/shared';\nimport { MaterialGeneratorModal } from '@/components/shared/MaterialGeneratorModal';\nimport { TemplateSelector, getTemplateFile } from '@/components/shared/TemplateSelector';\nimport { listUserTemplates, type UserTemplate } from '@/api/endpoints';\nimport { materialUrlToFile } from '@/components/shared/MaterialSelector';\nimport type { Material } from '@/api/endpoints';\nimport { SlideCard } from '@/components/preview/SlideCard';\nimport { useProjectStore } from '@/store/useProjectStore';\nimport { useExportTasksStore, type ExportTaskType } from '@/store/useExportTasksStore';\nimport { getImageUrl } from '@/api/client';\nimport { getPageImageVersions, setCurrentImageVersion, updateProject, uploadTemplate, exportPPTX as apiExportPPTX, exportPDF as apiExportPDF, exportImages as apiExportImages, exportEditablePPTX as apiExportEditablePPTX, getSettings } from '@/api/endpoints';\nimport type { ImageVersion, DescriptionContent, ExportExtractorMethod, ExportInpaintMethod, Page } from '@/types';\nimport { normalizeErrorMessage } from '@/utils';\n\nexport const SlidePreview: React.FC = () => {\n  const navigate = useNavigate();\n  const location = useLocation();\n  const t = useT(previewI18n);\n  const { projectId } = useParams<{ projectId: string }>();\n  const fromHistory = (location.state as any)?.from === 'history';\n  const {\n    currentProject,\n    syncProject,\n    generateImages,\n    editPageImage,\n    deletePageById,\n    updatePageLocal,\n    isGlobalLoading,\n    taskProgress,\n    pageGeneratingTasks,\n    warningMessage,\n  } = useProjectStore();\n  \n  const { addTask, pollTask: pollExportTask, tasks: exportTasks, restoreActiveTasks } = useExportTasksStore();\n  const notifiedFailedExportTaskIds = useRef<Set<string>>(new Set());\n\n  // 页面挂载时恢复正在进行的导出任务（页面刷新后）\n  useEffect(() => {\n    restoreActiveTasks();\n  }, [restoreActiveTasks]);\n\n  const [selectedIndex, setSelectedIndex] = useState(0);\n  const [isEditModalOpen, setIsEditModalOpen] = useState(false);\n  const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);\n  const [useTextStyleMode, setUseTextStyleMode] = useState(false);\n  const [draftTemplateStyle, setDraftTemplateStyle] = useState('');\n  const [editPrompt, setEditPrompt] = useState('');\n  // 大纲和描述编辑状态\n  const [editOutlineTitle, setEditOutlineTitle] = useState('');\n  const [editOutlinePoints, setEditOutlinePoints] = useState('');\n  const [editDescription, setEditDescription] = useState('');\n  const [showExportMenu, setShowExportMenu] = useState(false);\n  const [showExportTasksPanel, setShowExportTasksPanel] = useState(false);\n  // 多选导出相关状态\n  const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);\n  const [selectedPageIds, setSelectedPageIds] = useState<Set<string>>(new Set());\n  const [isOutlineExpanded, setIsOutlineExpanded] = useState(false);\n  const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);\n  const [isRefreshing, setIsRefreshing] = useState(false);\n  const [imageVersions, setImageVersions] = useState<ImageVersion[]>([]);\n  const [showVersionMenu, setShowVersionMenu] = useState(false);\n  const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);\n  const [selectedPresetTemplateId, setSelectedPresetTemplateId] = useState<string | null>(null);\n  const [isUploadingTemplate, setIsUploadingTemplate] = useState(false);\n  const [selectedContextImages, setSelectedContextImages] = useState<{\n    useTemplate: boolean;\n    descImageUrls: string[];\n    uploadedFiles: File[];\n  }>({\n    useTemplate: false,\n    descImageUrls: [],\n    uploadedFiles: [],\n  });\n  const [extraRequirements, setExtraRequirements] = useState<string>('');\n  const [isSavingRequirements, setIsSavingRequirements] = useState(false);\n  const isEditingRequirements = useRef(false); // 跟踪用户是否正在编辑额外要求\n  const [templateStyle, setTemplateStyle] = useState<string>('');\n  const [isSavingTemplateStyle, setIsSavingTemplateStyle] = useState(false);\n  const isEditingTemplateStyle = useRef(false); // 跟踪用户是否正在编辑风格描述\n  const lastProjectId = useRef<string | null>(null); // 跟踪上一次的项目ID\n  const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);\n  // 素材生成模态开关（模块本身可复用，这里只是示例入口）\n  const [isMaterialModalOpen, setIsMaterialModalOpen] = useState(false);\n  // 素材选择器模态开关\n  const [userTemplates, setUserTemplates] = useState<UserTemplate[]>([]);\n  const [isMaterialSelectorOpen, setIsMaterialSelectorOpen] = useState(false);\n  // 导出设置\n  const [exportExtractorMethod, setExportExtractorMethod] = useState<ExportExtractorMethod>(\n    (currentProject?.export_extractor_method as ExportExtractorMethod) || 'hybrid'\n  );\n  const [exportInpaintMethod, setExportInpaintMethod] = useState<ExportInpaintMethod>(\n    (currentProject?.export_inpaint_method as ExportInpaintMethod) || 'hybrid'\n  );\n  const [exportAllowPartial, setExportAllowPartial] = useState<boolean>(\n    currentProject?.export_allow_partial || false\n  );\n  const [isSavingExportSettings, setIsSavingExportSettings] = useState(false);\n  // 画面比例\n  const [aspectRatio, setAspectRatio] = useState<string>(\n    currentProject?.image_aspect_ratio || '16:9'\n  );\n  const [isSavingAspectRatio, setIsSavingAspectRatio] = useState(false);\n  // 根据画面比例计算 CSS aspect-ratio\n  const aspectRatioStyle = useMemo(() => {\n    const parts = aspectRatio.split(':');\n    if (parts.length === 2) {\n      const w = parseInt(parts[0], 10);\n      const h = parseInt(parts[1], 10);\n      if (w > 0 && h > 0) return `${w}/${h}`;\n    }\n    return '16/9';\n  }, [aspectRatio]);\n  // 1K分辨率警告对话框状态\n  const [show1KWarningDialog, setShow1KWarningDialog] = useState(false);\n  const [skip1KWarningChecked, setSkip1KWarningChecked] = useState(false);\n  const [pending1KAction, setPending1KAction] = useState<(() => Promise<void>) | null>(null);\n  // 每页编辑参数缓存（前端会话内缓存，便于重复执行）\n  const [editContextByPage, setEditContextByPage] = useState<Record<string, {\n    prompt: string;\n    contextImages: {\n      useTemplate: boolean;\n      descImageUrls: string[];\n      uploadedFiles: File[];\n    };\n  }>>({});\n\n  // 预览图矩形选择状态（编辑弹窗内）\n  const imageRef = useRef<HTMLImageElement | null>(null);\n  const [isRegionSelectionMode, setIsRegionSelectionMode] = useState(false);\n  const [isSelectingRegion, setIsSelectingRegion] = useState(false);\n  const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);\n  const [selectionRect, setSelectionRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null);\n  const { show, ToastContainer } = useToast();\n  const { confirm, ConfirmDialog } = useConfirm();\n\n  useEffect(() => {\n    exportTasks\n      .filter(task => task.projectId === projectId && task.status === 'FAILED' && task.taskId)\n      .forEach(task => {\n        if (notifiedFailedExportTaskIds.current.has(task.id)) {\n          return;\n        }\n        notifiedFailedExportTaskIds.current.add(task.id);\n        show({\n          message: normalizeErrorMessage(task.errorMessage || t('preview.messages.exportFailed')),\n          type: 'error',\n          duration: 5000,\n        });\n      });\n  }, [exportTasks, projectId, show, t]);\n\n  // Memoize pages with generated images to avoid re-computing in multiple places\n  const pagesWithImages = useMemo(() => {\n    return currentProject?.pages.filter(p => p.id && p.generated_image_path) || [];\n  }, [currentProject?.pages]);\n\n  const hasImages = useMemo(\n    () => currentProject?.pages?.some(p => p.generated_image_path) ?? false,\n    [currentProject?.pages]\n  );\n\n  // 加载项目数据 & 用户模板\n  useEffect(() => {\n    if (projectId && (!currentProject || currentProject.id !== projectId)) {\n      // 直接使用 projectId 同步项目数据\n      syncProject(projectId);\n    }\n    \n    // 加载用户模板列表（用于按需获取File）\n    const loadTemplates = async () => {\n      try {\n        const response = await listUserTemplates();\n        if (response.data?.templates) {\n          setUserTemplates(response.data.templates);\n        }\n      } catch (error) {\n        console.error('Failed to load user templates:', error);\n      }\n    };\n    loadTemplates();\n  }, [projectId, currentProject, syncProject]);\n\n  // 监听警告消息\n  const lastWarningRef = React.useRef<string | null>(null);\n  useEffect(() => {\n    if (warningMessage) {\n      if (warningMessage !== lastWarningRef.current) {\n        lastWarningRef.current = warningMessage;\n        show({ message: warningMessage, type: 'warning', duration: 6000 });\n      }\n    } else {\n      // warningMessage 被清空时重置 ref，以便下次能再次显示\n      lastWarningRef.current = null;\n    }\n  }, [warningMessage, show]);\n\n  // 当项目加载后，初始化额外要求和风格描述\n  // 只在项目首次加载或项目ID变化时初始化，避免覆盖用户正在输入的内容\n  useEffect(() => {\n    if (currentProject) {\n      // 检查是否是新项目\n      const isNewProject = lastProjectId.current !== currentProject.id;\n      \n      if (isNewProject) {\n        // 新项目，初始化额外要求和风格描述\n        setExtraRequirements(currentProject.extra_requirements || '');\n        setTemplateStyle(currentProject.template_style || '');\n        // 初始化导出设置\n        setExportExtractorMethod((currentProject.export_extractor_method as ExportExtractorMethod) || 'hybrid');\n        setExportInpaintMethod((currentProject.export_inpaint_method as ExportInpaintMethod) || 'hybrid');\n        setExportAllowPartial(currentProject.export_allow_partial || false);\n        setAspectRatio(currentProject.image_aspect_ratio || '16:9');\n        lastProjectId.current = currentProject.id || null;\n        isEditingRequirements.current = false;\n        isEditingTemplateStyle.current = false;\n      } else {\n        // 同一项目且用户未在编辑，可以更新（比如从服务器保存后同步回来）\n        if (!isEditingRequirements.current) {\n          setExtraRequirements(currentProject.extra_requirements || '');\n        }\n        if (!isEditingTemplateStyle.current) {\n          setTemplateStyle(currentProject.template_style || '');\n        }\n        // 非文本输入的设置项，始终从服务器同步\n        setAspectRatio(currentProject.image_aspect_ratio || '16:9');\n        setExportExtractorMethod((currentProject.export_extractor_method as ExportExtractorMethod) || 'hybrid');\n        setExportInpaintMethod((currentProject.export_inpaint_method as ExportInpaintMethod) || 'hybrid');\n        setExportAllowPartial(currentProject.export_allow_partial || false);\n      }\n      // 如果用户正在编辑，则不更新本地状态\n    }\n  }, [currentProject?.id, currentProject?.extra_requirements, currentProject?.template_style, currentProject?.image_aspect_ratio, currentProject?.export_extractor_method, currentProject?.export_inpaint_method, currentProject?.export_allow_partial]);\n\n  // 加载当前页面的历史版本\n  useEffect(() => {\n    const loadVersions = async () => {\n      if (!currentProject || !projectId || selectedIndex < 0 || selectedIndex >= currentProject.pages.length) {\n        setImageVersions([]);\n        setShowVersionMenu(false);\n        return;\n      }\n\n      const page = currentProject.pages[selectedIndex];\n      if (!page?.id) {\n        setImageVersions([]);\n        setShowVersionMenu(false);\n        return;\n      }\n\n      try {\n        const response = await getPageImageVersions(projectId, page.id);\n        if (response.data?.versions) {\n          setImageVersions(response.data.versions);\n        }\n      } catch (error) {\n        console.error('Failed to load image versions:', error);\n        setImageVersions([]);\n      }\n    };\n\n    loadVersions();\n  }, [currentProject, selectedIndex, projectId]);\n\n  // 检查是否需要显示1K分辨率警告\n  const checkResolutionAndExecute = useCallback(async (action: () => Promise<void>) => {\n    // 检查 localStorage 中是否已跳过警告\n    const skipWarning = localStorage.getItem('skip1KResolutionWarning') === 'true';\n    if (skipWarning) {\n      await action();\n      return;\n    }\n\n    try {\n      const response = await getSettings();\n      const resolution = response.data?.image_resolution;\n\n      // 如果是1K分辨率，显示警告对话框\n      if (resolution === '1K') {\n        setPending1KAction(() => action);\n        setSkip1KWarningChecked(false);\n        setShow1KWarningDialog(true);\n      } else {\n        // 不是1K分辨率，直接执行\n        await action();\n      }\n    } catch (error) {\n      console.error('获取设置失败:', error);\n      // 获取设置失败时，直接执行（不阻塞用户）\n      await action();\n    }\n  }, []);\n\n  // 确认1K分辨率警告后执行\n  const handleConfirm1KWarning = useCallback(async () => {\n    // 如果勾选了\"不再提示\"，保存到 localStorage\n    if (skip1KWarningChecked) {\n      localStorage.setItem('skip1KResolutionWarning', 'true');\n    }\n\n    setShow1KWarningDialog(false);\n\n    // 执行待处理的操作\n    if (pending1KAction) {\n      await pending1KAction();\n      setPending1KAction(null);\n    }\n  }, [skip1KWarningChecked, pending1KAction]);\n\n  // 取消1K分辨率警告\n  const handleCancel1KWarning = useCallback(() => {\n    setShow1KWarningDialog(false);\n    setPending1KAction(null);\n  }, []);\n\n  const handleGenerateAll = async () => {\n    // 先检查分辨率，如果是1K则显示警告\n    await checkResolutionAndExecute(async () => {\n      const pageIds = getSelectedPageIdsForExport();\n      const isPartialGenerate = isMultiSelectMode && selectedPageIds.size > 0;\n\n      // 检查要生成的页面中是否有已有图片的\n      const pagesToGenerate = isPartialGenerate\n        ? currentProject?.pages.filter(p => p.id && selectedPageIds.has(p.id))\n        : currentProject?.pages;\n      const hasImages = pagesToGenerate?.some((p) => p.generated_image_path);\n\n      const executeGenerate = async () => {\n        try {\n          await generateImages(pageIds);\n        } catch (error: any) {\n          console.error('批量生成错误:', error);\n          console.error('错误响应:', error?.response?.data);\n\n          // 提取后端返回的更具体错误信息\n          let errorMessage = t('preview.generationFailed');\n          const respData = error?.response?.data;\n\n          if (respData) {\n            if (respData.error?.message) {\n              errorMessage = respData.error.message;\n            } else if (respData.message) {\n              errorMessage = respData.message;\n            } else if (respData.error) {\n              errorMessage =\n                typeof respData.error === 'string'\n                  ? respData.error\n                  : respData.error.message || errorMessage;\n            }\n          } else if (error.message) {\n            errorMessage = error.message;\n          }\n\n          devLog('提取的错误消息:', errorMessage);\n\n          // 使用统一的错误消息规范化函数\n          errorMessage = normalizeErrorMessage(errorMessage);\n\n          devLog('规范化后的错误消息:', errorMessage);\n\n          show({\n            message: errorMessage,\n            type: 'error',\n          });\n        }\n      };\n\n      if (hasImages) {\n        const message = isPartialGenerate\n          ? t('preview.confirmRegenerateSelected', { count: selectedPageIds.size })\n          : t('preview.confirmRegenerateAll');\n        confirm(\n          message,\n          executeGenerate,\n          { title: t('preview.confirmRegenerateTitle'), variant: 'warning' }\n        );\n      } else {\n        await executeGenerate();\n      }\n    });\n  };\n\n  const handleRegeneratePage = useCallback(async () => {\n    if (!currentProject) return;\n    const page = currentProject.pages[selectedIndex];\n    if (!page.id) return;\n\n    // 如果该页面正在生成，不重复提交\n    if (pageGeneratingTasks[page.id]) {\n      show({ message: t('slidePreview.pageGenerating'), type: 'info' });\n      return;\n    }\n\n    // 先检查分辨率，如果是1K则显示警告\n    await checkResolutionAndExecute(async () => {\n      try {\n        // 使用统一的 generateImages，传入单个页面 ID\n        await generateImages([page.id!]);\n        show({ message: t('slidePreview.generationStarted'), type: 'success' });\n      } catch (error: any) {\n        // 提取后端返回的更具体错误信息\n        let errorMessage = '生成失败';\n        const respData = error?.response?.data;\n\n        if (respData) {\n          if (respData.error?.message) {\n            errorMessage = respData.error.message;\n          } else if (respData.message) {\n            errorMessage = respData.message;\n          } else if (respData.error) {\n            errorMessage =\n              typeof respData.error === 'string'\n                ? respData.error\n                : respData.error.message || errorMessage;\n          }\n        } else if (error.message) {\n          errorMessage = error.message;\n        }\n\n        // 使用统一的错误消息规范化函数\n        errorMessage = normalizeErrorMessage(errorMessage);\n\n        show({\n          message: errorMessage,\n          type: 'error',\n        });\n      }\n    });\n  }, [currentProject, selectedIndex, pageGeneratingTasks, generateImages, show, checkResolutionAndExecute]);\n\n  const handleSwitchVersion = async (versionId: string) => {\n    if (!currentProject || !selectedPage?.id || !projectId) return;\n    \n    try {\n      await setCurrentImageVersion(projectId, selectedPage.id, versionId);\n      await syncProject(projectId);\n      setShowVersionMenu(false);\n      show({ message: t('slidePreview.versionSwitched'), type: 'success' });\n    } catch (error: any) {\n      show({ \n        message: t('slidePreview.versionSwitchFailed', { error: error.message || t('slidePreview.unknownError') }),\n        type: 'error' \n      });\n    }\n  };\n\n  // 从描述内容中提取图片URL\n  const extractImageUrlsFromDescription = (descriptionContent: DescriptionContent | undefined): string[] => {\n    if (!descriptionContent) return [];\n    \n    // 处理两种格式\n    let text: string = '';\n    if ('text' in descriptionContent) {\n      text = descriptionContent.text as string;\n    } else if ('text_content' in descriptionContent && Array.isArray(descriptionContent.text_content)) {\n      text = descriptionContent.text_content.join('\\n');\n    }\n    \n    if (!text) return [];\n    \n    // 匹配 markdown 图片语法: ![](url) 或 ![alt](url)\n    const pattern = /!\\[.*?\\]\\((.*?)\\)/g;\n    const matches: string[] = [];\n    let match: RegExpExecArray | null;\n    \n    while ((match = pattern.exec(text)) !== null) {\n      const url = match[1]?.trim();\n      // 只保留有效的HTTP/HTTPS URL\n      if (url && (url.startsWith('http://') || url.startsWith('https://'))) {\n        matches.push(url);\n      }\n    }\n    \n    return matches;\n  };\n\n  const handleEditPage = () => {\n    if (!currentProject) return;\n    const page = currentProject.pages[selectedIndex];\n    const pageId = page?.id;\n\n    setIsOutlineExpanded(false);\n    setIsDescriptionExpanded(false);\n\n    // 初始化大纲和描述编辑状态\n    setEditOutlineTitle(page?.outline_content?.title || '');\n    setEditOutlinePoints(page?.outline_content?.points?.join('\\n') || '');\n    // 提取描述文本\n    const descContent = page?.description_content;\n    let descText = '';\n    if (descContent) {\n      if ('text' in descContent) {\n        descText = descContent.text as string;\n      } else if ('text_content' in descContent && Array.isArray(descContent.text_content)) {\n        descText = descContent.text_content.join('\\n');\n      }\n    }\n    setEditDescription(descText);\n\n    if (pageId && editContextByPage[pageId]) {\n      // 恢复该页上次编辑的内容和图片选择\n      const cached = editContextByPage[pageId];\n      setEditPrompt(cached.prompt);\n      setSelectedContextImages({\n        useTemplate: cached.contextImages.useTemplate,\n        descImageUrls: [...cached.contextImages.descImageUrls],\n        uploadedFiles: [...cached.contextImages.uploadedFiles],\n      });\n    } else {\n      // 首次编辑该页，使用默认值\n      setEditPrompt('');\n      setSelectedContextImages({\n        useTemplate: false,\n        descImageUrls: [],\n        uploadedFiles: [],\n      });\n    }\n\n    // 打开编辑弹窗时，清空上一次的选区和模式\n    setIsRegionSelectionMode(false);\n    setSelectionStart(null);\n    setSelectionRect(null);\n    setIsSelectingRegion(false);\n\n    setIsEditModalOpen(true);\n  };\n\n  // 保存大纲和描述修改\n  const handleSaveOutlineAndDescription = useCallback(() => {\n    if (!currentProject) return;\n    const page = currentProject.pages[selectedIndex];\n    if (!page?.id) return;\n\n    const updates: Partial<Page> = {};\n    \n    // 检查大纲是否有变化\n    const originalTitle = page.outline_content?.title || '';\n    const originalPoints = page.outline_content?.points?.join('\\n') || '';\n    if (editOutlineTitle !== originalTitle || editOutlinePoints !== originalPoints) {\n      updates.outline_content = {\n        title: editOutlineTitle,\n        points: editOutlinePoints.split('\\n').filter((p) => p.trim()),\n      };\n    }\n    \n    // 检查描述是否有变化\n    const descContent = page.description_content;\n    let originalDesc = '';\n    if (descContent) {\n      if ('text' in descContent) {\n        originalDesc = descContent.text as string;\n      } else if ('text_content' in descContent && Array.isArray(descContent.text_content)) {\n        originalDesc = descContent.text_content.join('\\n');\n      }\n    }\n    if (editDescription !== originalDesc) {\n      updates.description_content = {\n        text: editDescription,\n      } as DescriptionContent;\n    }\n    \n    // 如果有修改，保存更新\n    if (Object.keys(updates).length > 0) {\n      updatePageLocal(page.id, updates);\n      show({ message: t('slidePreview.outlineSaved'), type: 'success' });\n    }\n  }, [currentProject, selectedIndex, editOutlineTitle, editOutlinePoints, editDescription, updatePageLocal, show]);\n\n  const handleSubmitEdit = useCallback(async () => {\n    if (!currentProject || !editPrompt.trim()) return;\n    \n    const page = currentProject.pages[selectedIndex];\n    if (!page.id) return;\n\n    // 先保存大纲和描述的修改\n    handleSaveOutlineAndDescription();\n\n    // 调用后端编辑接口\n    await editPageImage(\n      page.id,\n      editPrompt,\n      {\n        useTemplate: selectedContextImages.useTemplate,\n        descImageUrls: selectedContextImages.descImageUrls,\n        uploadedFiles: selectedContextImages.uploadedFiles.length > 0 \n          ? selectedContextImages.uploadedFiles \n          : undefined,\n      }\n    );\n\n    // 缓存当前页的编辑上下文，便于后续快速重复执行\n    setEditContextByPage((prev) => ({\n      ...prev,\n      [page.id!]: {\n        prompt: editPrompt,\n        contextImages: {\n          useTemplate: selectedContextImages.useTemplate,\n          descImageUrls: [...selectedContextImages.descImageUrls],\n          uploadedFiles: [...selectedContextImages.uploadedFiles],\n        },\n      },\n    }));\n\n    setIsEditModalOpen(false);\n  }, [currentProject, selectedIndex, editPrompt, selectedContextImages, editPageImage, handleSaveOutlineAndDescription]);\n\n  const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const files = Array.from(e.target.files || []);\n    setSelectedContextImages((prev) => ({\n      ...prev,\n      uploadedFiles: [...prev.uploadedFiles, ...files],\n    }));\n  };\n\n  const removeUploadedFile = (index: number) => {\n    setSelectedContextImages((prev) => ({\n      ...prev,\n      uploadedFiles: prev.uploadedFiles.filter((_, i) => i !== index),\n    }));\n  };\n\n  // Manage object URLs for uploaded files to prevent memory leaks\n  const uploadedFileUrls = useRef<string[]>([]);\n  useEffect(() => {\n    uploadedFileUrls.current.forEach(url => URL.revokeObjectURL(url));\n    uploadedFileUrls.current = selectedContextImages.uploadedFiles.map(file => URL.createObjectURL(file));\n  }, [selectedContextImages.uploadedFiles]);\n  useEffect(() => {\n    return () => {\n      uploadedFileUrls.current.forEach(url => URL.revokeObjectURL(url));\n    };\n  }, []);\n\n  const handleSelectMaterials = async (materials: Material[]) => {\n    try {\n      // 将选中的素材转换为File对象并添加到上传列表\n      const files = await Promise.all(\n        materials.map((material) => materialUrlToFile(material))\n      );\n      setSelectedContextImages((prev) => ({\n        ...prev,\n        uploadedFiles: [...prev.uploadedFiles, ...files],\n      }));\n      show({ message: t('slidePreview.materialsAdded', { count: materials.length }), type: 'success' });\n    } catch (error: any) {\n      console.error('加载素材失败:', error);\n      show({\n        message: t('slidePreview.loadMaterialFailed', { error: error.message || t('slidePreview.unknownError') }),\n        type: 'error',\n      });\n    }\n  };\n\n  // 编辑弹窗打开时，实时把输入与图片选择写入缓存（前端会话内）\n  useEffect(() => {\n    if (!isEditModalOpen || !currentProject) return;\n    const page = currentProject.pages[selectedIndex];\n    const pageId = page?.id;\n    if (!pageId) return;\n\n    setEditContextByPage((prev) => ({\n      ...prev,\n      [pageId]: {\n        prompt: editPrompt,\n        contextImages: {\n          useTemplate: selectedContextImages.useTemplate,\n          descImageUrls: [...selectedContextImages.descImageUrls],\n          uploadedFiles: [...selectedContextImages.uploadedFiles],\n        },\n      },\n    }));\n  }, [isEditModalOpen, currentProject, selectedIndex, editPrompt, selectedContextImages]);\n\n  // ========== 预览图矩形选择相关逻辑（编辑弹窗内） ==========\n  const handleSelectionMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {\n    if (!isRegionSelectionMode || !imageRef.current) return;\n    const rect = imageRef.current.getBoundingClientRect();\n    const x = e.clientX - rect.left;\n    const y = e.clientY - rect.top;\n    if (x < 0 || y < 0 || x > rect.width || y > rect.height) return;\n    setIsSelectingRegion(true);\n    setSelectionStart({ x, y });\n    setSelectionRect(null);\n  };\n\n  const handleSelectionMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {\n    if (!isRegionSelectionMode || !isSelectingRegion || !selectionStart || !imageRef.current) return;\n    const rect = imageRef.current.getBoundingClientRect();\n    const x = e.clientX - rect.left;\n    const y = e.clientY - rect.top;\n\n    const clampedX = Math.max(0, Math.min(x, rect.width));\n    const clampedY = Math.max(0, Math.min(y, rect.height));\n\n    const left = Math.min(selectionStart.x, clampedX);\n    const top = Math.min(selectionStart.y, clampedY);\n    const width = Math.abs(clampedX - selectionStart.x);\n    const height = Math.abs(clampedY - selectionStart.y);\n\n    setSelectionRect({ left, top, width, height });\n  };\n\n  const handleSelectionMouseUp = async () => {\n    if (!isRegionSelectionMode || !isSelectingRegion || !selectionRect || !imageRef.current) {\n      setIsSelectingRegion(false);\n      setSelectionStart(null);\n      return;\n    }\n\n    // 结束拖拽，但保留选中的矩形，直到用户手动退出区域选图模式\n    setIsSelectingRegion(false);\n    setSelectionStart(null);\n\n    try {\n      const img = imageRef.current;\n      const { left, top, width, height } = selectionRect;\n      if (width < 10 || height < 10) {\n        // 选区太小，忽略\n        return;\n      }\n\n      // 将选区从展示尺寸映射到原始图片尺寸\n      const naturalWidth = img.naturalWidth;\n      const naturalHeight = img.naturalHeight;\n      const displayWidth = img.clientWidth;\n      const displayHeight = img.clientHeight;\n\n      if (!naturalWidth || !naturalHeight || !displayWidth || !displayHeight) return;\n\n      const scaleX = naturalWidth / displayWidth;\n      const scaleY = naturalHeight / displayHeight;\n\n      const sx = left * scaleX;\n      const sy = top * scaleY;\n      const sWidth = width * scaleX;\n      const sHeight = height * scaleY;\n\n      const canvas = document.createElement('canvas');\n      canvas.width = Math.max(1, Math.round(sWidth));\n      canvas.height = Math.max(1, Math.round(sHeight));\n      const ctx = canvas.getContext('2d');\n      if (!ctx) return;\n\n      try {\n        ctx.drawImage(\n          img,\n          sx,\n          sy,\n          sWidth,\n          sHeight,\n          0,\n          0,\n          canvas.width,\n          canvas.height\n        );\n\n        canvas.toBlob((blob) => {\n          if (!blob) return;\n          const file = new File([blob], `crop-${Date.now()}.png`, { type: 'image/png' });\n          // 把选中区域作为额外参考图片加入上传列表\n          setSelectedContextImages((prev) => ({\n            ...prev,\n            uploadedFiles: [...prev.uploadedFiles, file],\n          }));\n          // 给用户一个明显反馈：选区已作为图片加入下方“上传图片”\n          show({\n            message: t('slidePreview.regionCropSuccess'),\n            type: 'success',\n          });\n        }, 'image/png');\n      } catch (e: any) {\n        console.error('裁剪选中区域失败（可能是跨域图片导致 canvas 被污染）:', e);\n        show({\n          message: t('slidePreview.regionCropFailed'),\n          type: 'error',\n        });\n      }\n    } finally {\n      // 不清理 selectionRect，让选区在界面上持续显示\n    }\n  };\n\n  // 多选相关函数\n  const togglePageSelection = (pageId: string) => {\n    setSelectedPageIds(prev => {\n      const next = new Set(prev);\n      if (next.has(pageId)) {\n        next.delete(pageId);\n      } else {\n        next.add(pageId);\n      }\n      return next;\n    });\n  };\n\n  const selectAllPages = () => {\n    const allPageIds = pagesWithImages.map(p => p.id!);\n    setSelectedPageIds(new Set(allPageIds));\n  };\n\n  const deselectAllPages = () => {\n    setSelectedPageIds(new Set());\n  };\n\n  const toggleMultiSelectMode = () => {\n    setIsMultiSelectMode(prev => {\n      if (prev) {\n        // 退出多选模式时清空选择\n        setSelectedPageIds(new Set());\n      }\n      return !prev;\n    });\n  };\n\n  // 获取有图片的选中页面ID列表\n  const getSelectedPageIdsForExport = (): string[] | undefined => {\n    if (!isMultiSelectMode || selectedPageIds.size === 0) {\n      return undefined; // 导出全部\n    }\n    return Array.from(selectedPageIds);\n  };\n\n  const handleExport = async (type: 'pptx' | 'pdf' | 'editable-pptx' | 'images') => {\n    setShowExportMenu(false);\n    if (!projectId) return;\n\n    const pageIds = getSelectedPageIdsForExport();\n    const exportTaskId = `export-${Date.now()}`;\n\n    try {\n      if (type === 'pptx' || type === 'pdf' || type === 'images') {\n        // Synchronous export - direct download, create completed task directly\n        const exportApi = { pptx: apiExportPPTX, pdf: apiExportPDF, images: apiExportImages };\n        const response = await exportApi[type](projectId, pageIds);\n        const downloadUrl = response.data?.download_url || response.data?.download_url_absolute;\n        if (downloadUrl) {\n          addTask({\n            id: exportTaskId,\n            taskId: '',\n            projectId,\n            type: type as ExportTaskType,\n            status: 'COMPLETED',\n            downloadUrl,\n            pageIds: pageIds,\n          });\n          window.open(downloadUrl, '_blank');\n        }\n      } else if (type === 'editable-pptx') {\n        // Async export - create processing task and start polling\n        addTask({\n          id: exportTaskId,\n          taskId: '', // Will be updated below\n          projectId,\n          type: 'editable-pptx',\n          status: 'PROCESSING',\n          pageIds: pageIds,\n        });\n        \n        show({ message: t('slidePreview.exportStarted'), type: 'success' });\n        \n        const response = await apiExportEditablePPTX(projectId, undefined, pageIds);\n        const taskId = response.data?.task_id;\n        \n        if (taskId) {\n          // Update task with real taskId\n          addTask({\n            id: exportTaskId,\n            taskId,\n            projectId,\n            type: 'editable-pptx',\n            status: 'PROCESSING',\n            pageIds: pageIds,\n          });\n          \n          // Start polling in background (non-blocking)\n          pollExportTask(exportTaskId, projectId, taskId);\n        }\n      }\n    } catch (error: any) {\n      // Update task as failed\n      addTask({\n        id: exportTaskId,\n        taskId: '',\n        projectId,\n        type: type as ExportTaskType,\n        status: 'FAILED',\n        errorMessage: normalizeErrorMessage(error.message || t('preview.messages.exportFailed')),\n        pageIds: pageIds,\n      });\n      show({ message: normalizeErrorMessage(error.message || t('preview.messages.exportFailed')), type: 'error' });\n    }\n  };\n\n  const handleRefresh = useCallback(async () => {\n    const targetProjectId = projectId || currentProject?.id;\n    if (!targetProjectId) {\n      show({ message: t('slidePreview.cannotRefresh'), type: 'error' });\n      return;\n    }\n\n    setIsRefreshing(true);\n    try {\n      await syncProject(targetProjectId);\n      show({ message: t('slidePreview.refreshSuccess'), type: 'success' });\n    } catch (error: any) {\n      show({ \n        message: error.message || t('slidePreview.refreshFailed'),\n        type: 'error' \n      });\n    } finally {\n      setIsRefreshing(false);\n    }\n  }, [projectId, currentProject?.id, syncProject, show]);\n\n  const handleSaveExtraRequirements = useCallback(async () => {\n    if (!currentProject || !projectId) return;\n    \n    setIsSavingRequirements(true);\n    try {\n      await updateProject(projectId, { extra_requirements: extraRequirements || '' });\n      // 保存成功后，标记为不在编辑状态，允许同步更新\n      isEditingRequirements.current = false;\n      // 更新本地项目状态\n      await syncProject(projectId);\n      show({ message: t('slidePreview.extraRequirementsSaved'), type: 'success' });\n    } catch (error: any) {\n      show({ \n        message: t('slidePreview.saveFailed', { error: error.message || t('slidePreview.unknownError') }),\n        type: 'error' \n      });\n    } finally {\n      setIsSavingRequirements(false);\n    }\n  }, [currentProject, projectId, extraRequirements, syncProject, show]);\n\n  const handleSaveTemplateStyle = useCallback(async () => {\n    if (!currentProject || !projectId) return;\n    \n    setIsSavingTemplateStyle(true);\n    try {\n      await updateProject(projectId, { template_style: templateStyle || '' });\n      // 保存成功后，标记为不在编辑状态，允许同步更新\n      isEditingTemplateStyle.current = false;\n      // 更新本地项目状态\n      await syncProject(projectId);\n      show({ message: t('slidePreview.styleDescSaved'), type: 'success' });\n    } catch (error: any) {\n      show({ \n        message: t('slidePreview.saveFailed', { error: error.message || t('slidePreview.unknownError') }),\n        type: 'error' \n      });\n    } finally {\n      setIsSavingTemplateStyle(false);\n    }\n  }, [currentProject, projectId, templateStyle, syncProject, show]);\n\n  const handleSaveExportSettings = useCallback(async () => {\n    if (!currentProject || !projectId) return;\n\n    setIsSavingExportSettings(true);\n    try {\n      await updateProject(projectId, {\n        export_extractor_method: exportExtractorMethod,\n        export_inpaint_method: exportInpaintMethod,\n        export_allow_partial: exportAllowPartial\n      });\n      // 更新本地项目状态\n      await syncProject(projectId);\n      show({ message: t('slidePreview.exportSettingsSaved'), type: 'success' });\n    } catch (error: any) {\n      show({\n        message: t('slidePreview.saveFailed', { error: error.message || t('slidePreview.unknownError') }),\n        type: 'error'\n      });\n    } finally {\n      setIsSavingExportSettings(false);\n    }\n  }, [currentProject, projectId, exportExtractorMethod, exportInpaintMethod, exportAllowPartial, syncProject, show]);\n\n  const handleSaveAspectRatio = useCallback(async () => {\n    if (!currentProject || !projectId) return;\n\n    setIsSavingAspectRatio(true);\n    try {\n      await updateProject(projectId, { image_aspect_ratio: aspectRatio });\n      await syncProject(projectId);\n      show({ message: t('slidePreview.aspectRatioSaved'), type: 'success' });\n    } catch (error: any) {\n      show({\n        message: t('slidePreview.saveFailed', { error: error.message || t('slidePreview.unknownError') }),\n        type: 'error'\n      });\n    } finally {\n      setIsSavingAspectRatio(false);\n    }\n  }, [currentProject, projectId, aspectRatio, syncProject, show]);\n\n  const handleTemplateSelect = async (templateFile: File | null, templateId?: string) => {\n    if (!projectId) return;\n    \n    // 如果有templateId，按需加载File\n    let file = templateFile;\n    if (templateId && !file) {\n      file = await getTemplateFile(templateId, userTemplates);\n      if (!file) {\n        show({ message: t('slidePreview.loadTemplateFailed'), type: 'error' });\n        return;\n      }\n    }\n    \n    if (!file) {\n      // 如果没有文件也没有 ID，可能是取消选择\n      return;\n    }\n    \n    setIsUploadingTemplate(true);\n    try {\n      await uploadTemplate(projectId, file);\n      await syncProject(projectId);\n      setIsTemplateModalOpen(false);\n      show({ message: t('slidePreview.templateChanged'), type: 'success' });\n      \n      // 更新选择状态\n      if (templateId) {\n        // 判断是用户模板还是预设模板（短ID通常是预设模板）\n        if (templateId.length <= 3 && /^\\d+$/.test(templateId)) {\n          setSelectedPresetTemplateId(templateId);\n          setSelectedTemplateId(null);\n        } else {\n          setSelectedTemplateId(templateId);\n          setSelectedPresetTemplateId(null);\n        }\n      }\n    } catch (error: any) {\n      show({ \n        message: t('slidePreview.templateChangeFailed', { error: error.message || t('slidePreview.unknownError') }),\n        type: 'error' \n      });\n    } finally {\n      setIsUploadingTemplate(false);\n    }\n  };\n\n  if (!currentProject) {\n    return <Loading fullscreen message={t('preview.messages.loadingProject')} />;\n  }\n\n  if (isGlobalLoading) {\n    // 根据任务进度显示不同的消息\n    let loadingMessage = t('preview.messages.processing');\n    if (taskProgress && typeof taskProgress === 'object') {\n      const progressData = taskProgress as any;\n      if (progressData.current_step) {\n        // 使用后端提供的当前步骤信息\n        const stepMap: Record<string, string> = {\n          'Generating clean backgrounds': t('preview.messages.generatingBackgrounds'),\n          'Creating PDF': t('preview.messages.creatingPdf'),\n          'Parsing with MinerU': t('preview.messages.parsingContent'),\n          'Creating editable PPTX': t('preview.messages.creatingPptx'),\n          'Complete': t('preview.messages.complete')\n        };\n        loadingMessage = stepMap[progressData.current_step] || progressData.current_step;\n      }\n      // 不再显示 \"处理中 (X/Y)...\" 格式，百分比已在进度条显示\n    }\n    \n    return (\n      <Loading\n        fullscreen\n        message={loadingMessage}\n        progress={taskProgress || undefined}\n      />\n    );\n  }\n\n  const selectedPage = currentProject.pages[selectedIndex];\n  const imageUrl = selectedPage?.generated_image_path\n    ? getImageUrl(selectedPage.generated_image_path, selectedPage.updated_at)\n    : '';\n\n  const hasAllImages = currentProject.pages.every(\n    (p) => p.generated_image_path\n  );\n  const missingImageCount = currentProject.pages.filter(p => !p.generated_image_path).length;\n\n  return (\n    <div className=\"h-screen bg-gray-50 dark:bg-background-primary flex flex-col overflow-hidden\">\n      {/* 顶栏 */}\n      <header className=\"h-14 md:h-16 bg-white dark:bg-background-secondary shadow-sm dark:shadow-background-primary/30 border-b border-gray-200 dark:border-border-primary flex items-center justify-between px-3 md:px-6 flex-shrink-0\">\n        <div className=\"flex items-center gap-2 md:gap-4 min-w-0 flex-1\">\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            icon={<Home size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n            onClick={() => navigate('/')}\n            className=\"hidden sm:inline-flex flex-shrink-0\"\n            >\n              <span className=\"hidden md:inline\">{t('nav.home')}</span>\n            </Button>\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              icon={<ArrowLeft size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={() => {\n                if (fromHistory) {\n                  navigate('/history');\n                } else {\n                  navigate(`/project/${projectId}/detail`);\n                }\n              }}\n              className=\"flex-shrink-0\"\n            >\n              <span className=\"hidden sm:inline\">{t('common.back')}</span>\n            </Button>\n            <div className=\"flex items-center gap-1.5 md:gap-2 min-w-0\">\n              <span className=\"text-xl md:text-2xl\">🍌</span>\n              <span className=\"text-base md:text-xl font-bold truncate\">{t('home.title')}</span>\n            </div>\n            <span className=\"text-gray-400 hidden md:inline\">|</span>\n            <span className=\"text-sm md:text-lg font-semibold truncate hidden sm:inline\">{t('preview.title')}</span>\n        </div>\n        <div className=\"flex items-center gap-1 md:gap-3 flex-shrink-0\">\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              icon={<Settings size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={() => setIsProjectSettingsOpen(true)}\n              className=\"hidden lg:inline-flex\"\n            >\n              <span className=\"hidden xl:inline\">{t('preview.projectSettings')}</span>\n            </Button>\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              icon={<Upload size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={() => { setDraftTemplateStyle(templateStyle); setIsTemplateModalOpen(true); }}\n              className=\"hidden lg:inline-flex\"\n            >\n              <span className=\"hidden xl:inline\">{t('preview.changeTemplate')}</span>\n            </Button>\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              icon={<ImagePlus size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={() => setIsMaterialModalOpen(true)}\n              className=\"hidden lg:inline-flex\"\n            >\n              <span className=\"hidden xl:inline\">{t('nav.materialGenerate')}</span>\n            </Button>\n            <Button\n              variant=\"secondary\"\n              size=\"sm\"\n              icon={<ArrowLeft size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={() => navigate(`/project/${projectId}/detail`)}\n              className=\"hidden sm:inline-flex\"\n            >\n              <span className=\"hidden md:inline\">{t('common.previous')}</span>\n            </Button>\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              icon={<RefreshCw size={16} className={`md:w-[18px] md:h-[18px] ${isRefreshing ? 'animate-spin' : ''}`} />}\n              onClick={handleRefresh}\n              disabled={isRefreshing}\n              className=\"hidden md:inline-flex\"\n            >\n              <span className=\"hidden lg:inline\">{t('preview.refresh')}</span>\n            </Button>\n          \n          {/* 导出任务按钮 */}\n          {exportTasks.filter(t => t.projectId === projectId).length > 0 && (\n            <div className=\"relative\">\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={() => {\n                  setShowExportTasksPanel(!showExportTasksPanel);\n                  setShowExportMenu(false);\n                }}\n                className=\"relative\"\n              >\n                {exportTasks.filter(t => t.projectId === projectId && (t.status === 'PROCESSING' || t.status === 'RUNNING' || t.status === 'PENDING')).length > 0 ? (\n                  <Loader2 size={16} className=\"animate-spin text-banana-500\" />\n                ) : (\n                  <FileText size={16} />\n                )}\n                <span className=\"ml-1 text-xs\">\n                  {exportTasks.filter(t => t.projectId === projectId).length}\n                </span>\n              </Button>\n              {showExportTasksPanel && (\n                <div className=\"absolute right-0 mt-2 z-20\">\n                  <ExportTasksPanel \n                    projectId={projectId} \n                    pages={currentProject?.pages || []}\n                    className=\"w-96 max-h-[28rem] shadow-lg\" \n                  />\n                </div>\n              )}\n            </div>\n          )}\n          \n          <div className=\"relative\">\n            <Button\n              variant=\"primary\"\n              size=\"sm\"\n              icon={<Download size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={() => {\n                setShowExportMenu(!showExportMenu);\n                setShowExportTasksPanel(false);\n              }}\n              disabled={isMultiSelectMode ? selectedPageIds.size === 0 : !hasAllImages}\n              title={!isMultiSelectMode && !hasAllImages ? t('preview.disabledExportTip', { count: missingImageCount }) : undefined}\n              className=\"text-xs md:text-sm\"\n            >\n              <span className=\"hidden sm:inline\">\n                {isMultiSelectMode && selectedPageIds.size > 0 \n                  ? `${t('preview.export')} (${selectedPageIds.size})` \n                  : t('preview.export')}\n              </span>\n              <span className=\"sm:hidden\">\n                {isMultiSelectMode && selectedPageIds.size > 0 \n                  ? `(${selectedPageIds.size})` \n                  : t('preview.export')}\n              </span>\n            </Button>\n            {showExportMenu && (\n              <div className=\"absolute right-0 mt-2 w-56 bg-white dark:bg-background-secondary rounded-lg shadow-lg border border-gray-200 dark:border-border-primary py-2 z-10\">\n                {isMultiSelectMode && selectedPageIds.size > 0 && (\n                  <div className=\"px-4 py-2 text-xs text-gray-500 dark:text-foreground-tertiary border-b border-gray-100 dark:border-border-primary\">\n                    {t('preview.exportSelectedPages', { count: selectedPageIds.size })}\n                  </div>\n                )}\n                <button\n                  onClick={() => handleExport('pptx')}\n                  className=\"w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-background-hover transition-colors text-sm\"\n                >\n                  {t('preview.exportPptx')}\n                </button>\n                <button\n                  onClick={() => handleExport('editable-pptx')}\n                  className=\"w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-background-hover transition-colors text-sm\"\n                >\n                  {t('preview.exportEditablePptx')}\n                </button>\n                <button\n                  onClick={() => handleExport('pdf')}\n                  className=\"w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-background-hover transition-colors text-sm\"\n                >\n                  {t('preview.exportPdf')}\n                </button>\n                <button\n                  onClick={() => handleExport('images')}\n                  className=\"w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-background-hover transition-colors text-sm\"\n                >\n                  {t('preview.exportImages')}\n                </button>\n              </div>\n            )}\n          </div>\n        </div>\n      </header>\n\n      {/* 主内容区 */}\n      <div className=\"flex-1 flex flex-col md:flex-row overflow-hidden min-w-0 min-h-0\">\n        {/* 左侧：缩略图列表 */}\n        <aside className=\"w-full md:w-80 bg-white dark:bg-background-secondary border-b md:border-b-0 md:border-r border-gray-200 dark:border-border-primary flex flex-col flex-shrink-0\">\n          <div className=\"p-3 md:p-4 border-b border-gray-200 dark:border-border-primary flex-shrink-0 space-y-2 md:space-y-3\">\n            <Button\n              variant=\"primary\"\n              icon={<Sparkles size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n              onClick={handleGenerateAll}\n              className=\"w-full text-sm md:text-base\"\n              disabled={isMultiSelectMode && selectedPageIds.size === 0}\n            >\n              {isMultiSelectMode && selectedPageIds.size > 0\n                ? t('preview.generateSelected', { count: selectedPageIds.size })\n                : t('preview.batchGenerate', { count: currentProject.pages.length })}\n            </Button>\n          </div>\n          \n          {/* 缩略图列表：桌面端垂直，移动端横向滚动 */}\n          <div className=\"flex-1 overflow-y-auto md:overflow-y-auto overflow-x-auto md:overflow-x-visible p-3 md:p-4 min-h-0\">\n            {/* 多选模式切换 - 紧凑布局 */}\n            <div className=\"flex items-center gap-2 text-xs mb-3\">\n              <button\n                onClick={toggleMultiSelectMode}\n                className={`px-2 py-1 rounded transition-colors flex items-center gap-1 ${\n                  isMultiSelectMode \n                    ? 'bg-banana-100 text-banana-700 hover:bg-banana-200' \n                    : 'text-gray-500 dark:text-foreground-tertiary hover:bg-gray-100 dark:hover:bg-background-hover'\n                }`}\n              >\n                {isMultiSelectMode ? <CheckSquare size={14} /> : <Square size={14} />}\n                <span>{isMultiSelectMode ? t('preview.cancelMultiSelect') : t('preview.multiSelect')}</span>\n              </button>\n              {isMultiSelectMode && (\n                <>\n                  <button\n                    onClick={selectedPageIds.size === pagesWithImages.length ? deselectAllPages : selectAllPages}\n                    className=\"text-gray-500 dark:text-foreground-tertiary hover:text-banana-600 transition-colors\"\n                  >\n                    {selectedPageIds.size === pagesWithImages.length ? t('common.deselectAll') : t('common.selectAll')}\n                  </button>\n                  {selectedPageIds.size > 0 && (\n                    <span className=\"text-banana-600 font-medium\">\n                      ({selectedPageIds.size}{t('preview.pagesUnit')})\n                    </span>\n                  )}\n                </>\n              )}\n            </div>\n            <div className=\"flex md:flex-col gap-2 md:gap-4 min-w-max md:min-w-0\">\n              {currentProject.pages.map((page, index) => (\n                <div key={page.id} className=\"md:w-full flex-shrink-0 relative\">\n                  {/* 移动端：简化缩略图 */}\n                  <div className=\"md:hidden relative\">\n                    <button\n                      onClick={() => {\n                        if (isMultiSelectMode && page.id && page.generated_image_path) {\n                          togglePageSelection(page.id);\n                        } else {\n                          setSelectedIndex(index);\n                        }\n                      }}\n                      className={`w-20 h-14 rounded border-2 transition-all ${\n                        selectedIndex === index\n                          ? 'border-banana-500 shadow-md'\n                          : 'border-gray-200 dark:border-border-primary'\n                      } ${isMultiSelectMode && page.id && selectedPageIds.has(page.id) ? 'ring-2 ring-banana-400' : ''}`}\n                    >\n                      {page.generated_image_path ? (\n                        <img\n                          src={getImageUrl(page.generated_image_path, page.updated_at)}\n                          alt={`Slide ${index + 1}`}\n                          className=\"w-full h-full object-cover rounded\"\n                        />\n                      ) : (\n                        <div className=\"w-full h-full bg-gray-100 dark:bg-background-secondary rounded flex items-center justify-center text-xs text-gray-400\">\n                          {index + 1}\n                        </div>\n                      )}\n                    </button>\n                    {/* 多选复选框（移动端） */}\n                    {isMultiSelectMode && page.id && page.generated_image_path && (\n                      <button\n                        onClick={(e) => {\n                          e.stopPropagation();\n                          togglePageSelection(page.id!);\n                        }}\n                        className={`absolute -top-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center transition-all ${\n                          selectedPageIds.has(page.id)\n                            ? 'bg-banana-500 text-white'\n                            : 'bg-white dark:bg-background-secondary border-2 border-gray-300 dark:border-border-primary'\n                        }`}\n                      >\n                        {selectedPageIds.has(page.id) && <Check size={12} />}\n                      </button>\n                    )}\n                  </div>\n                  {/* 桌面端：完整卡片 */}\n                  <div className=\"hidden md:block relative\">\n                    {/* 多选复选框（桌面端） */}\n                    {isMultiSelectMode && page.id && page.generated_image_path && (\n                      <button\n                        onClick={(e) => {\n                          e.stopPropagation();\n                          togglePageSelection(page.id!);\n                        }}\n                        className={`absolute top-2 left-2 z-10 w-6 h-6 rounded flex items-center justify-center transition-all ${\n                          selectedPageIds.has(page.id)\n                            ? 'bg-banana-500 text-white shadow-md'\n                            : 'bg-white/90 border-2 border-gray-300 dark:border-border-primary hover:border-banana-400'\n                        }`}\n                      >\n                        {selectedPageIds.has(page.id) && <Check size={14} />}\n                      </button>\n                    )}\n                    <SlideCard\n                      page={page}\n                      index={index}\n                      isSelected={selectedIndex === index}\n                      onClick={() => {\n                        if (isMultiSelectMode && page.id && page.generated_image_path) {\n                          togglePageSelection(page.id);\n                        } else {\n                          setSelectedIndex(index);\n                        }\n                      }}\n                      onEdit={() => {\n                        setSelectedIndex(index);\n                        handleEditPage();\n                      }}\n                      onDelete={() => page.id && deletePageById(page.id)}\n                      isGenerating={page.id ? !!pageGeneratingTasks[page.id] : false}\n                      aspectRatio={aspectRatio}\n                    />\n                  </div>\n                </div>\n              ))}\n            </div>\n          </div>\n        </aside>\n\n        {/* 右侧：大图预览 */}\n        <main className=\"flex-1 flex flex-col bg-gradient-to-br from-banana-50 dark:from-background-primary via-white dark:via-background-primary to-gray-50 dark:to-background-primary min-w-0 overflow-hidden\">\n          {currentProject.pages.length === 0 ? (\n            <div className=\"flex-1 flex items-center justify-center overflow-y-auto\">\n              <div className=\"text-center\">\n                <div className=\"text-4xl md:text-6xl mb-4\">📊</div>\n                <h3 className=\"text-lg md:text-xl font-semibold text-gray-700 dark:text-foreground-secondary mb-2\">\n                  {t('preview.noPages')}\n                </h3>\n                <p className=\"text-sm md:text-base text-gray-500 dark:text-foreground-tertiary mb-6\">\n                  {t('preview.noPagesHint')}\n                </p>\n                <Button\n                  variant=\"primary\"\n                  onClick={() => navigate(`/project/${projectId}/outline`)}\n                  className=\"text-sm md:text-base\"\n                >\n                  {t('preview.backToEdit')}\n                </Button>\n              </div>\n            </div>\n          ) : (\n            <>\n              {/* 预览区 */}\n              <div className=\"flex-1 overflow-y-auto min-h-0 flex items-center justify-center p-4 md:p-8\">\n                <div className=\"max-w-5xl w-full\">\n                  <div className=\"relative bg-white dark:bg-background-secondary rounded-lg shadow-xl overflow-hidden touch-manipulation\" style={{ aspectRatio: aspectRatioStyle }}>\n                    {selectedPage?.generated_image_path ? (\n                      <img\n                        src={imageUrl}\n                        alt={`Slide ${selectedIndex + 1}`}\n                        className=\"w-full h-full object-cover select-none\"\n                        draggable={false}\n                      />\n                    ) : (\n                      <div className=\"w-full h-full flex items-center justify-center bg-gray-100 dark:bg-background-secondary\">\n                        <div className=\"text-center\">\n                          <div className=\"text-6xl mb-4\">🍌</div>\n                          <p className=\"text-gray-500 dark:text-foreground-tertiary mb-4\">\n                            {selectedPage?.status === 'QUEUED'\n                              ? t('preview.queued')\n                              : (selectedPage?.id && pageGeneratingTasks[selectedPage.id]) ||\n                                selectedPage?.status === 'GENERATING'\n                              ? t('preview.generating')\n                              : t('preview.notGenerated')}\n                          </p>\n                          {(!selectedPage?.id || !pageGeneratingTasks[selectedPage.id]) &&\n                           selectedPage?.status !== 'QUEUED' &&\n                           selectedPage?.status !== 'GENERATING' && (\n                            <Button\n                              variant=\"primary\"\n                              onClick={handleRegeneratePage}\n                            >\n                              {t('preview.generateThisPage')}\n                            </Button>\n                          )}\n                        </div>\n                      </div>\n                    )}\n                  </div>\n                </div>\n              </div>\n\n              {/* 控制栏 */}\n              <div className=\"bg-white dark:bg-background-secondary border-t border-gray-200 dark:border-border-primary px-3 md:px-6 py-3 md:py-4 flex-shrink-0\">\n                <div className=\"flex flex-col sm:flex-row items-center justify-between gap-3 max-w-5xl mx-auto\">\n                  {/* 导航 */}\n                  <div className=\"flex items-center gap-2 w-full sm:w-auto justify-center\">\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      icon={<ChevronLeft size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n                      onClick={() => setSelectedIndex(Math.max(0, selectedIndex - 1))}\n                      disabled={selectedIndex === 0}\n                      className=\"text-xs md:text-sm\"\n                    >\n                      <span className=\"hidden sm:inline\">{t('preview.prevPage')}</span>\n                      <span className=\"sm:hidden\">{t('preview.prevPage')}</span>\n                    </Button>\n                    <span className=\"px-2 md:px-4 text-xs md:text-sm text-gray-600 dark:text-foreground-tertiary whitespace-nowrap\">\n                      {selectedIndex + 1} / {currentProject.pages.length}\n                    </span>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      icon={<ChevronRight size={16} className=\"md:w-[18px] md:h-[18px]\" />}\n                      onClick={() =>\n                        setSelectedIndex(\n                          Math.min(currentProject.pages.length - 1, selectedIndex + 1)\n                        )\n                      }\n                      disabled={selectedIndex === currentProject.pages.length - 1}\n                      className=\"text-xs md:text-sm\"\n                    >\n                      <span className=\"hidden sm:inline\">{t('preview.nextPage')}</span>\n                      <span className=\"sm:hidden\">{t('preview.nextPage')}</span>\n                    </Button>\n                  </div>\n\n                  {/* 操作 */}\n                  <div className=\"flex items-center gap-1.5 md:gap-2 w-full sm:w-auto justify-center\">\n                    {/* 手机端：模板更换按钮 */}\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      icon={<Upload size={16} />}\n                      onClick={() => { setDraftTemplateStyle(templateStyle); setIsTemplateModalOpen(true); }}\n                      className=\"lg:hidden text-xs\"\n                      title={t('preview.changeTemplate')}\n                    />\n                    {/* 手机端：素材生成按钮 */}\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      icon={<ImagePlus size={16} />}\n                      onClick={() => setIsMaterialModalOpen(true)}\n                      className=\"lg:hidden text-xs\"\n                      title={t('nav.materialGenerate')}\n                    />\n                    {/* 手机端：刷新按钮 */}\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      icon={<RefreshCw size={16} className={isRefreshing ? 'animate-spin' : ''} />}\n                      onClick={handleRefresh}\n                      disabled={isRefreshing}\n                      className=\"md:hidden text-xs\"\n                      title={t('preview.refresh')}\n                    />\n                    {imageVersions.length > 1 && (\n                      <div className=\"relative\">\n                        <Button\n                          variant=\"ghost\"\n                          size=\"sm\"\n                          onClick={() => setShowVersionMenu(!showVersionMenu)}\n                          className=\"text-xs md:text-sm\"\n                        >\n                          <span className=\"hidden md:inline\">{t('preview.historyVersions')} ({imageVersions.length})</span>\n                          <span className=\"md:hidden\">{t('preview.versions')}</span>\n                        </Button>\n                        {showVersionMenu && (\n                          <div className=\"absolute right-0 bottom-full mb-2 w-56 md:w-64 bg-white dark:bg-background-secondary rounded-lg shadow-lg border border-gray-200 dark:border-border-primary py-2 z-20 max-h-96 overflow-y-auto\">\n                            {imageVersions.map((version) => (\n                              <button\n                                key={version.version_id}\n                                onClick={() => handleSwitchVersion(version.version_id)}\n                                className={`w-full px-3 md:px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-background-hover transition-colors flex items-center justify-between text-xs md:text-sm ${\n                                  version.is_current ? 'bg-banana-50 dark:bg-background-secondary' : ''\n                                }`}\n                              >\n                                <div className=\"flex items-center gap-2\">\n                                  <span>\n                                    {t('preview.version')} {version.version_number}\n                                  </span>\n                                  {version.is_current && (\n                                    <span className=\"text-xs text-banana-600 font-medium\">\n                                      ({t('preview.current')})\n                                    </span>\n                                  )}\n                                </div>\n                                <span className=\"text-xs text-gray-400 hidden md:inline\">\n                                  {version.created_at\n                                    ? new Date(version.created_at).toLocaleString('zh-CN', {\n                                        month: 'short',\n                                        day: 'numeric',\n                                        hour: '2-digit',\n                                        minute: '2-digit',\n                                      })\n                                    : ''}\n                                </span>\n                              </button>\n                            ))}\n                          </div>\n                        )}\n                      </div>\n                    )}\n                    <Button\n                      variant=\"secondary\"\n                      size=\"sm\"\n                      onClick={handleEditPage}\n                      disabled={!selectedPage?.generated_image_path}\n                      title={!selectedPage?.generated_image_path ? t('preview.disabledEditTip') : undefined}\n                      className=\"text-xs md:text-sm flex-1 sm:flex-initial\"\n                    >\n                      {t('common.edit')}\n                    </Button>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={handleRegeneratePage}\n                      disabled={selectedPage?.id && pageGeneratingTasks[selectedPage.id] ? true : false}\n                      className=\"text-xs md:text-sm flex-1 sm:flex-initial\"\n                    >\n                      {selectedPage?.id && pageGeneratingTasks[selectedPage.id]\n                        ? t('preview.regenerating')\n                        : t('preview.regenerate')}\n                    </Button>\n                  </div>\n                </div>\n              </div>\n            </>\n          )}\n        </main>\n      </div>\n\n      {/* 编辑对话框 */}\n      <Modal\n        isOpen={isEditModalOpen}\n        onClose={() => setIsEditModalOpen(false)}\n        title={t('preview.editPage')}\n        size=\"lg\"\n      >\n        <div className=\"space-y-4\">\n          {/* 图片（支持矩形区域选择） */}\n          <div\n            className=\"bg-gray-100 dark:bg-background-secondary rounded-lg overflow-hidden relative\"\n            style={{ aspectRatio: aspectRatioStyle }}\n            onMouseDown={handleSelectionMouseDown}\n            onMouseMove={handleSelectionMouseMove}\n            onMouseUp={handleSelectionMouseUp}\n            onMouseLeave={handleSelectionMouseUp}\n          >\n            {imageUrl && (\n              <>\n                {/* 左上角：区域选图模式开关 */}\n                <button\n                  type=\"button\"\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    // 切换矩形选择模式\n                    setIsRegionSelectionMode((prev) => !prev);\n                    // 切模式时清空当前选区\n                    setSelectionStart(null);\n                    setSelectionRect(null);\n                    setIsSelectingRegion(false);\n                  }}\n                  className=\"absolute top-2 left-2 z-10 px-2 py-1 rounded bg-white/80 text-[10px] text-gray-700 dark:text-foreground-secondary hover:bg-banana-50 dark:hover:bg-background-hover shadow-sm dark:shadow-background-primary/30 flex items-center gap-1\"\n                >\n                  <Sparkles size={12} />\n                  <span>{isRegionSelectionMode ? t('preview.endRegionSelect') : t('preview.regionSelect')}</span>\n                </button>\n\n                <img\n                  ref={imageRef}\n                  src={imageUrl}\n                  alt=\"Current slide\"\n                  className=\"w-full h-full object-contain select-none\"\n                  draggable={false}\n                  crossOrigin=\"anonymous\"\n                />\n                {selectionRect && (\n                  <div\n                    className=\"absolute border-2 border-banana-500 bg-banana-400/10 pointer-events-none\"\n                    style={{\n                      left: selectionRect.left,\n                      top: selectionRect.top,\n                      width: selectionRect.width,\n                      height: selectionRect.height,\n                    }}\n                  />\n                )}\n              </>\n            )}\n          </div>\n\n          {/* 大纲内容 - 可编辑 */}\n          <div className=\"bg-gray-50 dark:bg-background-primary rounded-lg border border-gray-200 dark:border-border-primary\">\n            <button\n              onClick={() => setIsOutlineExpanded(!isOutlineExpanded)}\n              className=\"w-full px-4 py-3 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-background-hover transition-colors\"\n            >\n              <h4 className=\"text-sm font-semibold text-gray-700 dark:text-foreground-secondary\">{t('preview.pageOutline')}</h4>\n              {isOutlineExpanded ? (\n                <ChevronUp size={18} className=\"text-gray-500 dark:text-foreground-tertiary\" />\n              ) : (\n                <ChevronDown size={18} className=\"text-gray-500 dark:text-foreground-tertiary\" />\n              )}\n            </button>\n            {isOutlineExpanded && (\n              <div className=\"px-4 pb-4 space-y-3\">\n                <div>\n                  <label className=\"block text-xs font-medium text-gray-600 dark:text-foreground-tertiary mb-1\">{t('outline.titleLabel')}</label>\n                  <input\n                    type=\"text\"\n                    value={editOutlineTitle}\n                    onChange={(e) => setEditOutlineTitle(e.target.value)}\n                    className=\"w-full px-3 py-2 text-sm border border-gray-300 dark:border-border-primary bg-white dark:bg-background-secondary text-gray-900 dark:text-foreground-primary rounded-lg focus:outline-none focus:ring-2 focus:ring-banana-500\"\n                    placeholder={t('preview.enterTitle')}\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-xs font-medium text-gray-600 dark:text-foreground-tertiary mb-1\">{t('preview.pointsPerLine')}</label>\n                  <textarea\n                    value={editOutlinePoints}\n                    onChange={(e) => setEditOutlinePoints(e.target.value)}\n                    rows={4}\n                    className=\"w-full px-3 py-2 text-sm border border-gray-300 dark:border-border-primary bg-white dark:bg-background-secondary text-gray-900 dark:text-foreground-primary rounded-lg focus:outline-none focus:ring-2 focus:ring-banana-500 resize-none\"\n                    placeholder={t('preview.enterPointsPerLine')}\n                  />\n                </div>\n              </div>\n            )}\n          </div>\n\n          {/* 描述内容 - 可编辑 */}\n          <div className=\"bg-blue-50 dark:bg-blue-900/30 rounded-lg border border-blue-200 dark:border-blue-700\">\n            <button\n              onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}\n              className=\"w-full px-4 py-3 flex items-center justify-between hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors\"\n            >\n              <h4 className=\"text-sm font-semibold text-gray-700 dark:text-foreground-secondary\">{t('preview.pageDescription')}</h4>\n              {isDescriptionExpanded ? (\n                <ChevronUp size={18} className=\"text-gray-500 dark:text-foreground-tertiary\" />\n              ) : (\n                <ChevronDown size={18} className=\"text-gray-500 dark:text-foreground-tertiary\" />\n              )}\n            </button>\n            {isDescriptionExpanded && (\n              <div className=\"px-4 pb-4\">\n                <textarea\n                  value={editDescription}\n                  onChange={(e) => setEditDescription(e.target.value)}\n                  rows={8}\n                  className=\"w-full px-3 py-2 text-sm border border-blue-300 dark:border-blue-700 bg-white dark:bg-background-secondary text-gray-900 dark:text-foreground-primary rounded-lg focus:outline-none focus:ring-2 focus:ring-banana-500 resize-none\"\n                  placeholder={t('preview.enterDescription')}\n                />\n              </div>\n            )}\n          </div>\n\n          {/* 上下文图片选择 */}\n          <div className=\"bg-gray-50 dark:bg-background-primary rounded-lg border border-gray-200 dark:border-border-primary p-4 space-y-4\">\n            <h4 className=\"text-sm font-semibold text-gray-700 dark:text-foreground-secondary mb-3\">{t('preview.selectContextImages')}</h4>\n            \n            {/* Template图片选择 */}\n            {currentProject?.template_image_path && (\n              <div className=\"flex items-center gap-3\">\n                <input\n                  type=\"checkbox\"\n                  id=\"use-template\"\n                  checked={selectedContextImages.useTemplate}\n                  onChange={(e) =>\n                    setSelectedContextImages((prev) => ({\n                      ...prev,\n                      useTemplate: e.target.checked,\n                    }))\n                  }\n                  className=\"w-4 h-4 text-banana-600 rounded focus:ring-banana-500\"\n                />\n                <label htmlFor=\"use-template\" className=\"flex items-center gap-2 cursor-pointer\">\n                  <ImageIcon size={16} className=\"text-gray-500 dark:text-foreground-tertiary\" />\n                  <span className=\"text-sm text-gray-700 dark:text-foreground-secondary\">{t('preview.useTemplateImage')}</span>\n                  {currentProject.template_image_path && (\n                    <img\n                      src={getImageUrl(currentProject.template_image_path, currentProject.updated_at)}\n                      alt=\"Template\"\n                      className=\"w-16 h-10 object-cover rounded border border-gray-300 dark:border-border-primary\"\n                    />\n                  )}\n                </label>\n              </div>\n            )}\n\n            {/* Desc中的图片 */}\n            {selectedPage?.description_content && (() => {\n              const descImageUrls = extractImageUrlsFromDescription(selectedPage.description_content);\n              return descImageUrls.length > 0 ? (\n                <div className=\"space-y-2\">\n                  <label className=\"text-sm font-medium text-gray-700 dark:text-foreground-secondary\">{t('preview.imagesInDescription')}:</label>\n                  <div className=\"grid grid-cols-3 gap-2\">\n                    {descImageUrls.map((url, idx) => (\n                      <div key={idx} className=\"relative group\">\n                        <img\n                          src={url}\n                          alt={`Desc image ${idx + 1}`}\n                          className=\"w-full h-20 object-cover rounded border-2 border-gray-300 dark:border-border-primary cursor-pointer transition-all\"\n                          style={{\n                            borderColor: selectedContextImages.descImageUrls.includes(url)\n                              ? 'var(--banana-yellow)'\n                              : 'var(--border-primary)',\n                          }}\n                          onClick={() => {\n                            setSelectedContextImages((prev) => {\n                              const isSelected = prev.descImageUrls.includes(url);\n                              return {\n                                ...prev,\n                                descImageUrls: isSelected\n                                  ? prev.descImageUrls.filter((u) => u !== url)\n                                  : [...prev.descImageUrls, url],\n                              };\n                            });\n                          }}\n                        />\n                        {selectedContextImages.descImageUrls.includes(url) && (\n                          <div className=\"absolute inset-0 bg-banana-500/20 border-2 border-banana-500 rounded flex items-center justify-center\">\n                            <div className=\"w-6 h-6 bg-banana-500 rounded-full flex items-center justify-center\">\n                              <span className=\"text-white text-xs font-bold\">✓</span>\n                            </div>\n                          </div>\n                        )}\n                      </div>\n                    ))}\n                  </div>\n                </div>\n              ) : null;\n            })()}\n\n            {/* 上传图片 */}\n            <div className=\"space-y-2\">\n              <div className=\"flex items-center justify-between\">\n                <label className=\"text-sm font-medium text-gray-700 dark:text-foreground-secondary\">{t('preview.uploadImages')}:</label>\n                {projectId && (\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    icon={<ImagePlus size={16} />}\n                    onClick={() => setIsMaterialSelectorOpen(true)}\n                  >\n                    {t('preview.selectFromMaterials')}\n                  </Button>\n                )}\n              </div>\n              <div className=\"flex flex-wrap gap-2\">\n                {selectedContextImages.uploadedFiles.map((file, idx) => (\n                  <div key={idx} className=\"relative group\">\n                    <img\n                      src={uploadedFileUrls.current[idx] || ''}\n                      alt={`Uploaded ${idx + 1}`}\n                      className=\"w-20 h-20 object-cover rounded border border-gray-300 dark:border-border-primary\"\n                    />\n                    <button\n                      onClick={() => removeUploadedFile(idx)}\n                      className=\"no-min-touch-target absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity\"\n                    >\n                      <X size={12} />\n                    </button>\n                  </div>\n                ))}\n                <label className=\"w-20 h-20 border-2 border-dashed border-gray-300 dark:border-border-primary rounded flex flex-col items-center justify-center cursor-pointer hover:border-banana-500 transition-colors\">\n                  <Upload size={20} className=\"text-gray-400 mb-1\" />\n                  <span className=\"text-xs text-gray-500 dark:text-foreground-tertiary\">{t('preview.upload')}</span>\n                  <input\n                    type=\"file\"\n                    accept=\"image/*\"\n                    multiple\n                    className=\"hidden\"\n                    onChange={handleFileUpload}\n                  />\n                </label>\n              </div>\n            </div>\n          </div>\n\n          {/* 编辑框 */}\n          <Textarea\n            label={t('preview.editPromptLabel')}\n            placeholder={t('preview.editPromptPlaceholder')}\n            value={editPrompt}\n            onChange={(e) => setEditPrompt(e.target.value)}\n            rows={4}\n          />\n          <div className=\"flex justify-between gap-3\">\n            <Button \n              variant=\"secondary\" \n              onClick={() => {\n                handleSaveOutlineAndDescription();\n                setIsEditModalOpen(false);\n              }}\n            >\n              {t('preview.saveOutlineOnly')}\n            </Button>\n            <div className=\"flex gap-3\">\n              <Button variant=\"ghost\" onClick={() => setIsEditModalOpen(false)}>\n                {t('common.cancel')}\n              </Button>\n              <Button\n                variant=\"primary\"\n                onClick={handleSubmitEdit}\n                disabled={!editPrompt.trim()}\n              >\n                {t('preview.generateImage')}\n              </Button>\n            </div>\n          </div>\n        </div>\n      </Modal>\n      <ToastContainer />\n      {ConfirmDialog}\n      \n      {/* 模板选择 Modal */}\n      <Modal\n        isOpen={isTemplateModalOpen}\n        onClose={() => setIsTemplateModalOpen(false)}\n        title={t('preview.changeTemplate')}\n        size=\"lg\"\n      >\n        <div className=\"space-y-4\">\n          <p className=\"text-sm text-gray-600 dark:text-foreground-tertiary mb-4\">\n            {t('preview.templateModalDesc')}\n          </p>\n          {/* 图片模板 / 文字风格 切换 */}\n          <label className=\"flex items-center gap-2 cursor-pointer group\">\n            <span className=\"text-sm text-gray-600 dark:text-foreground-tertiary group-hover:text-gray-900 dark:group-hover:text-white transition-colors\">\n              {t('preview.useTextStyle')}\n            </span>\n            <div className=\"relative\">\n              <input\n                type=\"checkbox\"\n                checked={useTextStyleMode}\n                onChange={(e) => setUseTextStyleMode(e.target.checked)}\n                className=\"sr-only peer\"\n              />\n              <div className=\"w-11 h-6 bg-gray-200 dark:bg-background-hover peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-banana-300 dark:peer-focus:ring-banana/30 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white dark:after:bg-foreground-secondary after:border-gray-300 dark:after:border-border-hover after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-banana\"></div>\n            </div>\n          </label>\n          {useTextStyleMode ? (\n            <TextStyleSelector\n              value={draftTemplateStyle}\n              onChange={setDraftTemplateStyle}\n              onToast={show}\n            />\n          ) : (\n            <>\n              <TemplateSelector\n                onSelect={handleTemplateSelect}\n                selectedTemplateId={selectedTemplateId}\n                selectedPresetTemplateId={selectedPresetTemplateId}\n                showUpload={false}\n                projectId={projectId || null}\n              />\n              {isUploadingTemplate && (\n                <div className=\"text-center py-2 text-sm text-gray-500 dark:text-foreground-tertiary\">\n                  {t('preview.uploadingTemplate')}\n                </div>\n              )}\n            </>\n          )}\n          <div className=\"flex justify-end gap-3 pt-4 border-t\">\n            {useTextStyleMode && (\n              <Button\n                variant=\"primary\"\n                loading={isSavingTemplateStyle}\n                onClick={async () => {\n                  isEditingTemplateStyle.current = true;\n                  setTemplateStyle(draftTemplateStyle);\n                  setIsSavingTemplateStyle(true);\n                  try {\n                    await updateProject(projectId!, { template_style: draftTemplateStyle || '' });\n                    isEditingTemplateStyle.current = false;\n                    await syncProject(projectId!);\n                    show({ message: t('slidePreview.styleDescSaved'), type: 'success' });\n                    setIsTemplateModalOpen(false);\n                  } catch (error: any) {\n                    show({ message: t('slidePreview.saveFailed', { error: error.message || t('slidePreview.unknownError') }), type: 'error' });\n                  } finally {\n                    setIsSavingTemplateStyle(false);\n                  }\n                }}\n              >\n                {t('preview.applyStyle')}\n              </Button>\n            )}\n            <Button\n              variant=\"ghost\"\n              onClick={() => setIsTemplateModalOpen(false)}\n              disabled={isUploadingTemplate || isSavingTemplateStyle}\n            >\n              {t('common.close')}\n            </Button>\n          </div>\n        </div>\n      </Modal>\n      {/* 素材生成模态组件（可复用模块，这里只是示例挂载） */}\n      {projectId && (\n        <>\n          <MaterialGeneratorModal\n            projectId={projectId}\n            isOpen={isMaterialModalOpen}\n            onClose={() => setIsMaterialModalOpen(false)}\n          />\n          {/* 素材选择器 */}\n          <MaterialSelector\n            projectId={projectId}\n            isOpen={isMaterialSelectorOpen}\n            onClose={() => setIsMaterialSelectorOpen(false)}\n            onSelect={handleSelectMaterials}\n            multiple={true}\n          />\n          {/* 项目设置模态框 */}\n          <ProjectSettingsModal\n            isOpen={isProjectSettingsOpen}\n            onClose={() => setIsProjectSettingsOpen(false)}\n            extraRequirements={extraRequirements}\n            templateStyle={templateStyle}\n            onExtraRequirementsChange={(value) => {\n              isEditingRequirements.current = true;\n              setExtraRequirements(value);\n            }}\n            onTemplateStyleChange={(value) => {\n              isEditingTemplateStyle.current = true;\n              setTemplateStyle(value);\n            }}\n            onSaveExtraRequirements={handleSaveExtraRequirements}\n            onSaveTemplateStyle={handleSaveTemplateStyle}\n            isSavingRequirements={isSavingRequirements}\n            isSavingTemplateStyle={isSavingTemplateStyle}\n            // 导出设置\n            exportExtractorMethod={exportExtractorMethod}\n            exportInpaintMethod={exportInpaintMethod}\n            exportAllowPartial={exportAllowPartial}\n            onExportExtractorMethodChange={setExportExtractorMethod}\n            onExportInpaintMethodChange={setExportInpaintMethod}\n            onExportAllowPartialChange={setExportAllowPartial}\n            onSaveExportSettings={handleSaveExportSettings}\n            isSavingExportSettings={isSavingExportSettings}\n            // 画面比例\n            aspectRatio={aspectRatio}\n            onAspectRatioChange={setAspectRatio}\n            onSaveAspectRatio={handleSaveAspectRatio}\n            isSavingAspectRatio={isSavingAspectRatio}\n            hasImages={hasImages}\n          />\n        </>\n      )}\n\n      {/* 1K分辨率警告对话框 */}\n      <Modal\n        isOpen={show1KWarningDialog}\n        onClose={handleCancel1KWarning}\n        title={t('preview.resolution1KWarning')}\n        size=\"sm\"\n      >\n        <div className=\"space-y-4\">\n          <div className=\"flex items-start gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg\">\n            <div className=\"text-2xl\">⚠️</div>\n            <div className=\"flex-1\">\n              <p className=\"text-sm text-amber-800\">\n                {t('preview.resolution1KWarningText')}\n              </p>\n              <p className=\"text-sm text-amber-700 mt-2\">\n                {t('preview.resolution1KWarningHint')}\n              </p>\n            </div>\n          </div>\n\n          <label className=\"flex items-center gap-2 cursor-pointer\">\n            <input\n              type=\"checkbox\"\n              checked={skip1KWarningChecked}\n              onChange={(e) => setSkip1KWarningChecked(e.target.checked)}\n              className=\"w-4 h-4 text-banana-600 rounded focus:ring-banana-500\"\n            />\n            <span className=\"text-sm text-gray-600 dark:text-foreground-tertiary\">{t('preview.dontShowAgain')}</span>\n          </label>\n\n          <div className=\"flex justify-end gap-3 pt-2\">\n            <Button variant=\"ghost\" onClick={handleCancel1KWarning}>\n              {t('common.cancel')}\n            </Button>\n            <Button variant=\"primary\" onClick={handleConfirm1KWarning}>\n              {t('preview.generateAnyway')}\n            </Button>\n          </div>\n        </div>\n      </Modal>\n\n    </div>\n  );\n};\n"
  },
  {
    "path": "frontend/src/store/useExportTasksStore.ts",
    "content": "import { create } from 'zustand';\nimport { persist } from 'zustand/middleware';\nimport * as api from '@/api/endpoints';\nimport { devLog } from '@/utils/logger';\nimport { getT } from '@/utils/i18nHelper';\n\nconst exportI18n = {\n  zh: { exportStore: { exportFailed: '导出失败', pollFailed: '轮询失败' } },\n  en: { exportStore: { exportFailed: 'Export failed', pollFailed: 'Polling failed' } }\n};\nconst t = getT(exportI18n);\n\n// Note: Backend uses 'RUNNING' but we also accept 'PROCESSING' for compatibility\nexport type ExportTaskStatus = 'PENDING' | 'PROCESSING' | 'RUNNING' | 'COMPLETED' | 'FAILED';\nexport type ExportTaskType = 'pptx' | 'pdf' | 'editable-pptx' | 'images';\n\nexport interface ExportTask {\n  id: string;\n  taskId: string;\n  projectId: string;\n  type: ExportTaskType;\n  status: ExportTaskStatus;\n  pageIds?: string[]; // 选中的页面ID列表，undefined表示全部\n  progress?: {\n    total: number;\n    completed: number;\n    percent?: number;\n    current_step?: string;\n    messages?: string[];\n    warnings?: string[];  // 导出警告信息\n    warning_details?: {   // 警告详细信息\n      style_extraction_failed?: Array<{ element_id: string; reason: string }>;\n      text_render_failed?: Array<{ text: string; reason: string }>;\n      image_add_failed?: Array<{ path: string; reason: string }>;\n      json_parse_failed?: Array<{ context: string; reason: string }>;\n      other_warnings?: string[];\n      total_warnings?: number;\n    };\n  };\n  downloadUrl?: string;\n  filename?: string;\n  errorMessage?: string;\n  createdAt: string;\n  completedAt?: string;\n}\n\ninterface ExportTasksState {\n  tasks: ExportTask[];\n  \n  // Actions\n  addTask: (task: Omit<ExportTask, 'createdAt'>) => void;\n  updateTask: (id: string, updates: Partial<ExportTask>) => void;\n  removeTask: (id: string) => void;\n  clearCompleted: () => void;\n  pollTask: (id: string, projectId: string, taskId: string) => Promise<void>;\n  restoreActiveTasks: () => void; // 恢复正在进行的任务并重新开始轮询\n}\n\nexport const useExportTasksStore = create<ExportTasksState>()(\n  persist(\n    (set, get) => ({\n      tasks: [],\n\n      addTask: (task) => {\n        set((state) => {\n          // Check if task with this id already exists\n          const existingIndex = state.tasks.findIndex(t => t.id === task.id);\n          \n          if (existingIndex >= 0) {\n            // Update existing task\n            const updatedTasks = [...state.tasks];\n            updatedTasks[existingIndex] = {\n              ...updatedTasks[existingIndex],\n              ...task,\n              // Update completedAt if status changed to completed/failed\n              completedAt: (task.status === 'COMPLETED' || task.status === 'FAILED')\n                ? new Date().toISOString()\n                : updatedTasks[existingIndex].completedAt,\n            };\n            return { tasks: updatedTasks };\n          } else {\n            // Add new task\n            const newTask: ExportTask = {\n              ...task,\n              createdAt: new Date().toISOString(),\n            };\n            return {\n              tasks: [newTask, ...state.tasks].slice(0, 20), // Keep max 20 tasks\n            };\n          }\n        });\n      },\n\n      updateTask: (id, updates) => {\n        set((state) => ({\n          tasks: state.tasks.map((task) =>\n            task.id === id ? { ...task, ...updates } : task\n          ),\n        }));\n      },\n\n      removeTask: (id) => {\n        set((state) => ({\n          tasks: state.tasks.filter((task) => task.id !== id),\n        }));\n      },\n\n      clearCompleted: () => {\n        set((state) => ({\n          tasks: state.tasks.filter(\n            (task) => task.status !== 'COMPLETED' && task.status !== 'FAILED'\n          ),\n        }));\n      },\n\n      pollTask: async (id, projectId, taskId) => {\n        const poll = async () => {\n          try {\n            const response = await api.getTaskStatus(projectId, taskId);\n            const task = response.data;\n\n            if (!task) {\n              console.warn('[ExportTasksStore] No task data in response');\n              return;\n            }\n\n            const updates: Partial<ExportTask> = {\n              status: task.status as ExportTaskStatus,\n            };\n\n            if (task.progress) {\n              // Parse progress if it's a string (from database JSON field)\n              let progressData = task.progress;\n              if (typeof progressData === 'string') {\n                try {\n                  progressData = JSON.parse(progressData);\n                } catch (e) {\n                  console.warn('[ExportTasksStore] Failed to parse progress:', e);\n                }\n              }\n              \n              updates.progress = progressData;\n              \n              // Extract download URL if available\n              if (progressData.download_url) {\n                updates.downloadUrl = progressData.download_url;\n              }\n              if (progressData.filename) {\n                updates.filename = progressData.filename;\n              }\n            }\n\n            if (task.status === 'COMPLETED') {\n              updates.completedAt = new Date().toISOString();\n              get().updateTask(id, updates);\n            } else if (task.status === 'FAILED') {\n              updates.errorMessage = task.error_message || task.error || t('exportStore.exportFailed');\n              updates.completedAt = new Date().toISOString();\n              get().updateTask(id, updates);\n            } else if (task.status === 'PENDING' || task.status === 'RUNNING' || task.status === 'PROCESSING') {\n              get().updateTask(id, updates);\n              // Continue polling\n              setTimeout(poll, 2000);\n            }\n          } catch (error: any) {\n            console.error('[ExportTasksStore] Poll error:', error);\n            get().updateTask(id, {\n              status: 'FAILED',\n              errorMessage: error.message || t('exportStore.pollFailed'),\n              completedAt: new Date().toISOString(),\n            });\n          }\n        };\n\n        await poll();\n      },\n\n      restoreActiveTasks: () => {\n        // 恢复所有正在进行的任务并重新开始轮询\n        const state = get();\n        const activeTasks = state.tasks.filter(\n          task => task.status === 'PENDING' || task.status === 'PROCESSING' || task.status === 'RUNNING'\n        );\n        \n        if (activeTasks.length > 0) {\n          devLog(`[ExportTasksStore] 恢复 ${activeTasks.length} 个正在进行的任务`);\n          activeTasks.forEach(task => {\n            // 重新开始轮询\n            state.pollTask(task.id, task.projectId, task.taskId).catch(err => {\n              console.error(`[ExportTasksStore] 恢复任务 ${task.id} 失败:`, err);\n            });\n          });\n        }\n      },\n    }),\n    {\n      name: 'export-tasks-storage',\n      partialize: (state) => ({\n        // Persist all tasks (including active ones) so they can be restored after page refresh\n        tasks: state.tasks.slice(0, 20), // Keep max 20 tasks\n      }),\n    }\n  )\n);\n\n"
  },
  {
    "path": "frontend/src/store/useProjectStore.ts",
    "content": "import { create } from 'zustand';\nimport type { Project } from '@/types';\nimport * as api from '@/api/endpoints';\nimport { debounce, normalizeProject, normalizeErrorMessage } from '@/utils';\nimport { devLog } from '@/utils/logger';\nimport { getT } from '@/utils/i18nHelper';\n\nconst storeI18n = {\n  zh: {\n    store: {\n      createFailed: '创建项目失败',\n      createNoId: '项目创建失败：未返回项目ID',\n      syncFailed: '同步项目失败',\n      projectNotFound: '项目不存在，可能已被删除',\n      requestFailed: '请求失败',\n      requestFailedStatus: '请求失败: {{status}}',\n      networkError: '网络错误，请检查后端服务是否启动',\n      updateOrderFailed: '更新顺序失败',\n      newPage: '新页面',\n      addPageFailed: '添加页面失败',\n      deletePageFailed: '删除页面失败',\n      taskStartFailed: '任务启动失败',\n      taskFailed: '任务失败',\n      unknownTaskStatus: '未知任务状态: {{status}}',\n      taskQueryFailed: '任务查询失败',\n      generateOutlineFailed: '生成大纲失败',\n      generateFromDescFailed: '从描述生成失败',\n      projectIdMissing: '项目ID不存在',\n      noTaskId: '未收到任务ID',\n      generateDescFailed: '生成描述失败',\n      generateDescTimeout: '生成描述失败：轮询超时',\n      startGenerationFailed: '启动生成任务失败',\n      regenerateFailed: '重新生成失败',\n      batchGenerateFailed: '批量生成失败',\n      editImageFailed: '编辑图片失败',\n      exportLinkFailed: '导出链接获取失败',\n      exportFailed: '导出失败',\n      exportEditableFailed: '导出可编辑PPTX失败',\n    }\n  },\n  en: {\n    store: {\n      createFailed: 'Failed to create project',\n      createNoId: 'Project creation failed: no project ID returned',\n      syncFailed: 'Failed to sync project',\n      projectNotFound: 'Project not found, it may have been deleted',\n      requestFailed: 'Request failed',\n      requestFailedStatus: 'Request failed: {{status}}',\n      networkError: 'Network error, please check if the backend service is running',\n      updateOrderFailed: 'Failed to update page order',\n      newPage: 'New Page',\n      addPageFailed: 'Failed to add page',\n      deletePageFailed: 'Failed to delete page',\n      taskStartFailed: 'Failed to start task',\n      taskFailed: 'Task failed',\n      unknownTaskStatus: 'Unknown task status: {{status}}',\n      taskQueryFailed: 'Failed to query task',\n      generateOutlineFailed: 'Failed to generate outline',\n      generateFromDescFailed: 'Failed to generate from description',\n      projectIdMissing: 'Project ID not found',\n      noTaskId: 'No task ID received',\n      generateDescFailed: 'Failed to generate description',\n      generateDescTimeout: 'Failed to generate description: polling timeout',\n      startGenerationFailed: 'Failed to start generation task',\n      regenerateFailed: 'Failed to regenerate',\n      batchGenerateFailed: 'Batch generation failed',\n      editImageFailed: 'Failed to edit image',\n      exportLinkFailed: 'Failed to get export link',\n      exportFailed: 'Export failed',\n      exportEditableFailed: 'Failed to export editable PPTX',\n    }\n  }\n};\nconst t = getT(storeI18n);\n\ninterface ProjectState {\n  // 状态\n  currentProject: Project | null;\n  isGlobalLoading: boolean;\n  activeTaskId: string | null;\n  taskProgress: { total: number; completed: number } | null;\n  error: string | null;\n  // 每个页面的生成任务ID映射 (pageId -> taskId)\n  pageGeneratingTasks: Record<string, string>;\n  // 警告消息\n  warningMessage: string | null;\n  // 流式大纲生成中\n  isOutlineStreaming: boolean;\n  // 流式描述生成中\n  isDescriptionStreaming: boolean;\n\n  // Actions\n  setCurrentProject: (project: Project | null) => void;\n  setGlobalLoading: (loading: boolean) => void;\n  setError: (error: string | null) => void;\n  \n  // 项目操作\n  initializeProject: (type: 'idea' | 'outline' | 'description', content: string, templateImage?: File, templateStyle?: string, referenceFileIds?: string[], aspectRatio?: string) => Promise<void>;\n  syncProject: (projectId?: string) => Promise<void>;\n  \n  // 页面操作\n  updatePageLocal: (pageId: string, data: any) => void;\n  saveAllPages: () => Promise<void>;\n  reorderPages: (newOrder: string[]) => Promise<void>;\n  addNewPage: () => Promise<void>;\n  deletePageById: (pageId: string) => Promise<void>;\n  \n  // 异步任务\n  startAsyncTask: (apiCall: () => Promise<any>) => Promise<void>;\n  pollTask: (taskId: string) => Promise<void>;\n  pollImageTask: (taskId: string, pageIds: string[]) => void;\n\n  // 生成操作\n  generateOutline: () => Promise<void>;\n  generateOutlineStream: () => Promise<{ complete: boolean } | undefined>;\n  generateFromDescription: () => Promise<void>;\n  generateDescriptions: (detailLevel?: string) => Promise<void>;\n  generatePageDescription: (pageId: string, detailLevel?: string) => Promise<void>;\n  regenerateRenovationPage: (pageId: string, keepLayout?: boolean) => Promise<void>;\n  generateImages: (pageIds?: string[]) => Promise<void>;\n  editPageImage: (\n    pageId: string,\n    editPrompt: string,\n    contextImages?: {\n      useTemplate?: boolean;\n      descImageUrls?: string[];\n      uploadedFiles?: File[];\n    }\n  ) => Promise<void>;\n  \n  // 导出\n  exportPPTX: (pageIds?: string[]) => Promise<void>;\n  exportPDF: (pageIds?: string[]) => Promise<void>;\n  exportEditablePPTX: (filename?: string, pageIds?: string[]) => Promise<void>;\n}\n\nexport const useProjectStore = create<ProjectState>((set, get) => {\n  // 防抖的API更新函数（在store内部定义，以便访问syncProject）\nconst debouncedUpdatePage = debounce(\n  async (projectId: string, pageId: string, data: any) => {\n      try {\n    const promises: Promise<any>[] = [];\n\n    // 如果更新的是 description_content，使用专门的端点\n    if (data.description_content) {\n      promises.push(api.updatePageDescription(projectId, pageId, data.description_content));\n    }\n\n    // 如果更新的是 outline_content，使用专门的端点\n    if (data.outline_content) {\n      promises.push(api.updatePageOutline(projectId, pageId, data.outline_content));\n    }\n\n    // 如果更新的是 part 字段，使用通用端点\n    if ('part' in data) {\n      promises.push(api.updatePage(projectId, pageId, { part: data.part }));\n    }\n\n    // 如果没有特定的内容更新，使用通用端点\n    if (promises.length === 0) {\n      await api.updatePage(projectId, pageId, data);\n    } else {\n      // 并行执行所有更新请求\n      await Promise.all(promises);\n    }\n        \n        // API调用成功后，同步项目状态以更新updated_at\n        // 图片生成期间 poll 已在 2s 同步，跳过以避免并发竞态\n        const { syncProject, pageGeneratingTasks } = get();\n        if (Object.keys(pageGeneratingTasks).length === 0) {\n          await syncProject(projectId);\n        }\n      } catch (error: any) {\n        console.error('保存页面失败:', error);\n        // 可以在这里添加错误提示，但为了避免频繁提示，暂时只记录日志\n        // 如果需要，可以通过事件系统或toast通知用户\n    }\n  },\n  1000\n);\n\n  return {\n  // 初始状态\n  currentProject: null,\n  isGlobalLoading: false,\n  activeTaskId: null,\n  taskProgress: null,\n  error: null,\n  pageGeneratingTasks: {},\n  warningMessage: null,\n  isOutlineStreaming: false,\n  isDescriptionStreaming: false,\n\n  // Setters\n  setCurrentProject: (project) => set({ currentProject: project }),\n  setGlobalLoading: (loading) => set({ isGlobalLoading: loading }),\n  setError: (error) => set({ error }),\n\n  // 初始化项目\n  initializeProject: async (type, content, templateImage, templateStyle, referenceFileIds, aspectRatio) => {\n    set({ isGlobalLoading: true, error: null });\n    try {\n      const request: any = {};\n\n      if (type === 'idea') {\n        request.idea_prompt = content;\n      } else if (type === 'outline') {\n        request.outline_text = content;\n      } else if (type === 'description') {\n        request.description_text = content;\n      }\n\n      // 添加风格描述（如果有）\n      if (templateStyle && templateStyle.trim()) {\n        request.template_style = templateStyle.trim();\n      }\n\n      // 添加画面比例（如果有）\n      if (aspectRatio) {\n        request.image_aspect_ratio = aspectRatio;\n      }\n\n      // 1. 创建项目\n      const response = await api.createProject(request);\n      const projectId = response.data?.project_id;\n\n      if (!projectId) {\n        throw new Error(t('store.createNoId'));\n      }\n\n      // 2. 关联参考文件到项目（在生成之前，确保 AI 能读取参考文件）\n      if (referenceFileIds && referenceFileIds.length > 0) {\n        try {\n          await Promise.all(\n            referenceFileIds.map(fileId => api.associateFileToProject(fileId, projectId))\n          );\n          devLog(`[初始化项目] 已关联 ${referenceFileIds.length} 个参考文件`);\n        } catch (error) {\n          console.warn('[初始化项目] 关联参考文件失败:', error);\n        }\n      }\n\n      // 3. 如果有模板图片，上传模板\n      if (templateImage) {\n        try {\n          await api.uploadTemplate(projectId, templateImage);\n        } catch (error) {\n          console.warn('模板上传失败:', error);\n          // 模板上传失败不影响项目创建，继续执行\n        }\n      }\n\n      // 4. 根据类型调用 AI 生成，失败时回滚项目\n      const generateWithRollback = async (fn: () => Promise<any>, label: string) => {\n        try {\n          await fn();\n          devLog(`[初始化项目] ${label}完成`);\n        } catch (error: any) {\n          console.error(`[初始化项目] ${label}失败:`, error);\n          try { await api.deleteProject(projectId); } catch (e: any) { console.error(`[初始化项目] 回滚失败，未能删除项目 ${projectId}:`, e); }\n          throw error;\n        }\n      };\n\n      if (type === 'outline') {\n        await generateWithRollback(() => api.generateOutline(projectId), '生成大纲');\n      } else if (type === 'description') {\n        await generateWithRollback(() => api.generateFromDescription(projectId, content), '从描述生成大纲和页面描述');\n      }\n\n      // 5. 获取完整项目信息\n      const projectResponse = await api.getProject(projectId);\n      const project = normalizeProject(projectResponse.data);\n\n      if (project) {\n        set({ currentProject: project });\n        // 保存到 localStorage\n        localStorage.setItem('currentProjectId', project.id!);\n      }\n    } catch (error: any) {\n      set({ error: normalizeErrorMessage(error.message || t('store.createFailed')) });\n      throw error;\n    } finally {\n      set({ isGlobalLoading: false });\n    }\n  },\n\n  // 同步项目数据\n  syncProject: async (projectId?: string) => {\n    const { currentProject } = get();\n\n    // 如果没有提供 projectId，尝试从 currentProject 或 localStorage 获取\n    let targetProjectId = projectId;\n    if (!targetProjectId) {\n      if (currentProject?.id) {\n        targetProjectId = currentProject.id;\n      } else {\n        targetProjectId = localStorage.getItem('currentProjectId') || undefined;\n      }\n    }\n\n    if (!targetProjectId) {\n      console.warn('syncProject: 没有可用的项目ID');\n      return;\n    }\n\n    try {\n      const response = await api.getProject(targetProjectId);\n      if (response.data) {\n        const project = normalizeProject(response.data);\n        devLog('[syncProject] 同步项目数据:', {\n          projectId: project.id,\n          pagesCount: project.pages?.length || 0,\n          status: project.status\n        });\n        set({ currentProject: project });\n        // 确保 localStorage 中保存了项目ID\n        localStorage.setItem('currentProjectId', project.id!);\n      }\n    } catch (error: any) {\n      // 提取更详细的错误信息\n      let errorMessage = t('store.syncFailed');\n      let shouldClearStorage = false;\n      \n      if (error.response) {\n        // 服务器返回了错误响应\n        const errorData = error.response.data;\n        if (error.response.status === 404) {\n          // 404错误：项目不存在，清除localStorage\n          errorMessage = errorData?.error?.message || t('store.projectNotFound');\n          shouldClearStorage = true;\n        } else if (errorData?.error?.message) {\n          // 从后端错误格式中提取消息\n          errorMessage = errorData.error.message;\n        } else if (errorData?.message) {\n          errorMessage = errorData.message;\n        } else if (errorData?.error) {\n          errorMessage = typeof errorData.error === 'string' ? errorData.error : errorData.error.message || t('store.requestFailed');\n        } else {\n          errorMessage = t('store.requestFailedStatus', { status: error.response.status });\n        }\n      } else if (error.request) {\n        // 请求已发送但没有收到响应\n        errorMessage = t('store.networkError');\n      } else if (error.message) {\n        // 其他错误\n        errorMessage = error.message;\n      }\n      \n      // 如果项目不存在，清除localStorage并重置当前项目\n      // 不显示错误toast，因为这通常是自动同步时发现的过期项目ID\n      if (shouldClearStorage) {\n        console.warn('[syncProject] 项目不存在，清除localStorage');\n        localStorage.removeItem('currentProjectId');\n        set({ currentProject: null });\n      } else {\n        set({ error: normalizeErrorMessage(errorMessage) });\n      }\n    }\n  },\n\n  // 本地更新页面（乐观更新）\n  updatePageLocal: (pageId, data) => {\n    const { currentProject } = get();\n    if (!currentProject) return;\n\n    const updatedPages = currentProject.pages.map((page) =>\n      page.id === pageId ? { ...page, ...data } : page\n    );\n\n    set({\n      currentProject: {\n        ...currentProject,\n        pages: updatedPages,\n      },\n    });\n\n    // 防抖后调用API\n    debouncedUpdatePage(currentProject.id, pageId, data);\n  },\n\n  // 立即保存所有页面的更改（用于保存按钮）\n  // 等待防抖完成，然后同步项目状态以确保updated_at更新\n  saveAllPages: async () => {\n    const { currentProject } = get();\n    if (!currentProject) return;\n\n    // 等待防抖延迟时间（1秒）+ 额外时间确保API调用完成\n    await new Promise(resolve => setTimeout(resolve, 1500));\n    \n    // 同步项目状态，这会从后端获取最新的updated_at\n    await get().syncProject(currentProject.id);\n  },\n\n  // 重新排序页面\n  reorderPages: async (newOrder) => {\n    const { currentProject } = get();\n    if (!currentProject) return;\n\n    // 乐观更新\n    const reorderedPages = newOrder\n      .map((id) => currentProject.pages.find((p) => p.id === id))\n      .filter(Boolean) as any[];\n\n    set({\n      currentProject: {\n        ...currentProject,\n        pages: reorderedPages,\n      },\n    });\n\n    try {\n      await api.updatePagesOrder(currentProject.id, newOrder);\n    } catch (error: any) {\n      set({ error: error.message || t('store.updateOrderFailed') });\n      // 失败后重新同步\n      await get().syncProject();\n    }\n  },\n\n  // 添加新页面\n  addNewPage: async () => {\n    const { currentProject } = get();\n    if (!currentProject) return;\n\n    try {\n      const newPage = {\n        outline_content: { title: t('store.newPage'), points: [] },\n        order_index: currentProject.pages.length,\n      };\n\n      const response = await api.addPage(currentProject.id, newPage);\n      if (response.data) {\n        await get().syncProject();\n      }\n    } catch (error: any) {\n      set({ error: error.message || t('store.addPageFailed') });\n    }\n  },\n\n  // 删除页面\n  deletePageById: async (pageId) => {\n    const { currentProject } = get();\n    if (!currentProject) return;\n\n    try {\n      await api.deletePage(currentProject.id, pageId);\n      await get().syncProject();\n    } catch (error: any) {\n      set({ error: error.message || t('store.deletePageFailed') });\n    }\n  },\n\n  // 启动异步任务\n  startAsyncTask: async (apiCall) => {\n    devLog('[异步任务] 启动异步任务...');\n    set({ isGlobalLoading: true, error: null });\n    try {\n      const response = await apiCall();\n      devLog('[异步任务] API响应:', response);\n      \n      // task_id 在 response.data 中\n      const taskId = response.data?.task_id;\n      if (taskId) {\n        devLog('[异步任务] 收到task_id:', taskId, '开始轮询...');\n        set({ activeTaskId: taskId });\n        await get().pollTask(taskId);\n      } else {\n        console.warn('[异步任务] 响应中没有task_id，可能是同步操作:', response);\n        // 同步操作完成后，刷新项目数据\n        await get().syncProject();\n        set({ isGlobalLoading: false });\n      }\n    } catch (error: any) {\n      console.error('[异步任务] 启动失败:', error);\n      set({ error: error.message || t('store.taskStartFailed'), isGlobalLoading: false });\n      throw error;\n    }\n  },\n\n  // 轮询任务状态\n  pollTask: async (taskId) => {\n    devLog(`[轮询] 开始轮询任务: ${taskId}`);\n    const { currentProject } = get();\n    if (!currentProject) {\n      console.warn('[轮询] 没有当前项目，停止轮询');\n      return;\n    }\n    const projectId = currentProject.id!;\n\n    const poll = async () => {\n      try {\n        devLog(`[轮询] 查询任务状态: ${taskId}`);\n        const response = await api.getTaskStatus(projectId, taskId);\n        const task = response.data;\n        \n        if (!task) {\n          console.warn('[轮询] 响应中没有任务数据');\n          return;\n        }\n\n        // 更新进度\n        if (task.progress) {\n          set({ taskProgress: task.progress });\n        }\n\n        devLog(`[轮询] Task ${taskId} 状态: ${task.status}`, task);\n\n        // 检查任务状态\n        if (task.status === 'COMPLETED') {\n          devLog(`[轮询] Task ${taskId} 已完成，刷新项目数据`);\n          \n          // 如果是导出可编辑PPTX任务，检查是否有下载链接\n          if (task.task_type === 'EXPORT_EDITABLE_PPTX' && task.progress) {\n            const progress = typeof task.progress === 'string' \n              ? JSON.parse(task.progress) \n              : task.progress;\n            \n            const downloadUrl = progress?.download_url;\n            if (downloadUrl) {\n              devLog('[导出可编辑PPTX] 从任务响应中获取下载链接:', downloadUrl);\n              // 延迟一下，确保状态更新完成后再打开下载链接\n              setTimeout(() => {\n                window.open(downloadUrl, '_blank');\n              }, 500);\n            } else {\n              console.warn('[导出可编辑PPTX] 任务完成但没有下载链接');\n            }\n          }\n          \n          set({ \n            activeTaskId: null, \n            taskProgress: null, \n            isGlobalLoading: false \n          });\n          // 刷新项目数据\n          await get().syncProject();\n        } else if (task.status === 'FAILED') {\n          console.error(`[轮询] Task ${taskId} 失败:`, task.error_message || task.error);\n          set({ \n            error: normalizeErrorMessage(task.error_message || task.error || t('store.taskFailed')),\n            activeTaskId: null,\n            taskProgress: null,\n            isGlobalLoading: false\n          });\n        } else if (task.status === 'PENDING' || task.status === 'PROCESSING') {\n          // 继续轮询（PENDING 或 PROCESSING）\n          devLog(`[轮询] Task ${taskId} 处理中，2秒后继续轮询...`);\n          setTimeout(poll, 2000);\n        } else {\n          // 未知状态，停止轮询\n          console.warn(`[轮询] Task ${taskId} 未知状态: ${task.status}，停止轮询`);\n          set({ \n            error: `${t('store.unknownTaskStatus', { status: task.status })}`,\n            activeTaskId: null,\n            taskProgress: null,\n            isGlobalLoading: false\n          });\n        }\n      } catch (error: any) {\n        console.error('任务轮询错误:', error);\n        set({ \n          error: normalizeErrorMessage(error.message || t('store.taskQueryFailed')),\n          activeTaskId: null,\n          isGlobalLoading: false\n        });\n      }\n    };\n\n    await poll();\n  },\n\n  // 生成大纲（同步操作，不需要轮询）\n  generateOutline: async () => {\n    const { currentProject } = get();\n    if (!currentProject) return;\n\n    set({ isGlobalLoading: true, error: null });\n    try {\n      const response = await api.generateOutline(currentProject.id!);\n      devLog('[生成大纲] API响应:', response);\n      \n      // 刷新项目数据，确保获取最新的大纲页面\n      await get().syncProject();\n      \n      // 再次确认数据已更新\n      const { currentProject: updatedProject } = get();\n      devLog('[生成大纲] 刷新后的项目:', updatedProject?.pages.length, '个页面');\n    } catch (error: any) {\n      console.error('[生成大纲] 错误:', error);\n      const message =\n        error.response?.data?.error?.message ||\n        error.response?.data?.message ||\n        error.message ||\n        t('store.generateOutlineFailed');\n      set({ error: normalizeErrorMessage(message) });\n      throw error;\n    } finally {\n      set({ isGlobalLoading: false });\n    }\n  },\n\n  // 流式生成大纲（SSE，逐页渲染）\n  generateOutlineStream: async (lockPageCount?: boolean) => {\n    const { currentProject } = get();\n    if (!currentProject) return;\n\n    set({ isOutlineStreaming: true, error: null });\n\n    // Clear existing pages for fresh streaming display\n    set({\n      currentProject: { ...currentProject, pages: [] },\n    });\n\n    // Concurrent queue: pages are pushed by SSE callbacks, drained by a timer loop\n    const pageQueue: any[] = [];\n    let streamDone = false;\n    let doneData: { total: number; pages: any[]; complete?: boolean } | null = null;\n    const STAGGER_MS = 150;\n\n    // Start the render loop — runs concurrently with the SSE stream\n    const renderPromise = new Promise<void>((resolve) => {\n      const tick = () => {\n        if (pageQueue.length > 0) {\n          const page = pageQueue.shift()!;\n          const { currentProject: proj } = get();\n          if (proj) {\n            const tempPage: any = {\n              id: `streaming-${page.index}`,\n              order_index: page.index,\n              outline_content: { title: page.title, points: page.points },\n              part: page.part,\n              status: 'DRAFT',\n            };\n            set({\n              currentProject: { ...proj, pages: [...proj.pages, tempPage] },\n            });\n          }\n          setTimeout(tick, STAGGER_MS);\n        } else if (streamDone) {\n          resolve();\n        } else {\n          // Queue empty but stream still going — poll quickly\n          setTimeout(tick, 30);\n        }\n      };\n      tick();\n    });\n\n    try {\n      await api.generateOutlineStream(currentProject.id!, {\n        onPage: (page) => { pageQueue.push(page); },\n        onDone: (data) => { doneData = data; },\n        onError: (message) => {\n          console.error('[流式大纲] 错误:', message);\n          set({ error: normalizeErrorMessage(message), isOutlineStreaming: false });\n          streamDone = true;\n        },\n      }, undefined /* language */, lockPageCount);\n\n      streamDone = true;\n      await renderPromise;\n\n      // Replace temp pages with real persisted pages\n      if (doneData) {\n        const { currentProject: proj } = get();\n        if (proj) {\n          const normalized = normalizeProject({ ...proj, pages: doneData.pages });\n          set({ currentProject: normalized, isOutlineStreaming: false });\n        }\n        devLog('[流式大纲] 完成:', doneData.total, '个页面');\n        return { complete: doneData.complete ?? false };\n      } else {\n        set({ isOutlineStreaming: false });\n        return { complete: false };\n      }\n    } catch (error: any) {\n      console.error('[流式大纲] 错误:', error);\n      streamDone = true;\n      set({\n        error: normalizeErrorMessage(error.message || t('store.generateOutlineFailed')),\n        isOutlineStreaming: false,\n      });\n      throw error;\n    }\n  },\n\n  // 从描述生成大纲和页面描述（同步操作）\n  generateFromDescription: async () => {\n    const { currentProject } = get();\n    if (!currentProject) return;\n\n    set({ isGlobalLoading: true, error: null });\n    try {\n      const response = await api.generateFromDescription(currentProject.id!);\n      devLog('[从描述生成] API响应:', response);\n      \n      // 刷新项目数据，确保获取最新的大纲和描述\n      await get().syncProject();\n      \n      // 再次确认数据已更新\n      const { currentProject: updatedProject } = get();\n      devLog('[从描述生成] 刷新后的项目:', updatedProject?.pages.length, '个页面');\n    } catch (error: any) {\n      console.error('[从描述生成] 错误:', error);\n      set({ error: error.message || t('store.generateFromDescFailed') });\n      throw error;\n    } finally {\n      set({ isGlobalLoading: false });\n    }\n  },\n\n  // 生成描述（根据设置选择流式或并行模式）\n  generateDescriptions: async (detailLevel?: string) => {\n    const { currentProject } = get();\n    if (!currentProject || !currentProject.id) return;\n\n    const pages = currentProject.pages.filter((p) => p.id);\n    if (pages.length === 0) return;\n\n    // 检查描述生成模式，优先从 sessionStorage 缓存读取以避免额外 API 调用\n    let mode: string = 'streaming';\n    try {\n      const cached = sessionStorage.getItem('banana-settings');\n      if (cached) {\n        const parsed = JSON.parse(cached);\n        if (parsed?.description_generation_mode) {\n          mode = parsed.description_generation_mode;\n        }\n      }\n    } catch { /* ignore */ }\n\n    if (mode === 'streaming') {\n      // 流式模式\n      set({ isDescriptionStreaming: true, error: null });\n\n      const updatedPages = currentProject.pages.map((page) =>\n        page.id ? { ...page, status: 'GENERATING_DESCRIPTION' as const } : page\n      );\n      set({ currentProject: { ...currentProject, pages: updatedPages } });\n\n      // Concurrent queue + render loop (like outline streaming)\n      const descQueue: api.DescriptionStreamEvent[] = [];\n      let streamDone = false;\n      let doneData: { total: number; pages: any[]; warning?: string } | null = null;\n      const STAGGER_MS = 100;\n\n      const renderPromise = new Promise<void>((resolve) => {\n        const tick = () => {\n          if (descQueue.length > 0) {\n            const desc = descQueue.shift()!;\n            const { currentProject: proj } = get();\n            if (proj) {\n              const updatedPages = proj.pages.map((page) => {\n                if (page.id === desc.page_id) {\n                  return {\n                    ...page,\n                    status: 'DESCRIPTION_GENERATED' as const,\n                    description_content: {\n                      text: desc.text,\n                      ...(desc.extra_fields ? { extra_fields: desc.extra_fields } : {}),\n                    },\n                  };\n                }\n                return page;\n              });\n              set({ currentProject: { ...proj, pages: updatedPages } });\n            }\n            setTimeout(tick, STAGGER_MS);\n          } else if (streamDone) {\n            resolve();\n          } else {\n            setTimeout(tick, 30);\n          }\n        };\n        tick();\n      });\n\n      try {\n        await api.generateDescriptionsStream(currentProject.id, {\n          onDescription: (data) => { descQueue.push(data); },\n          onDone: (data) => { doneData = data; },\n          onError: (message) => {\n            console.error('[流式描述] 错误:', message);\n            set({ error: normalizeErrorMessage(message) });\n            streamDone = true;\n          },\n        }, undefined, detailLevel);\n\n        streamDone = true;\n        await renderPromise;\n\n        if (doneData) {\n          const { currentProject: proj } = get();\n          if (proj) {\n            const normalized = normalizeProject({ ...proj, pages: doneData.pages });\n            set({\n              currentProject: normalized,\n              isDescriptionStreaming: false,\n              ...(doneData.warning ? { error: doneData.warning } : {}),\n            });\n          }\n          devLog('[流式描述] 完成:', doneData.total, '个页面');\n        } else {\n          // 无 doneData（SSE error 或连接中断）→ 从后端恢复真实状态\n          await get().syncProject();\n          set({ isDescriptionStreaming: false });\n        }\n      } catch (error: any) {\n        console.error('[流式描述] 错误:', error);\n        streamDone = true;\n        await get().syncProject();\n        set({\n          error: normalizeErrorMessage(error.message || t('store.generateDescFailed')),\n          isDescriptionStreaming: false,\n        });\n        throw error;\n      }\n    } else {\n      // 并行模式（原有逻辑）\n      set({ error: null });\n\n      const updatedPages = currentProject.pages.map((page) =>\n        page.id ? { ...page, status: 'GENERATING_DESCRIPTION' as const } : page\n      );\n      set({ currentProject: { ...currentProject, pages: updatedPages } });\n\n      try {\n        const projectId = currentProject.id;\n        if (!projectId) {\n          throw new Error(t('store.projectIdMissing'));\n        }\n\n        const response = await api.generateDescriptions(projectId, undefined, detailLevel);\n        const taskId = response.data?.task_id;\n\n        if (!taskId) {\n          throw new Error(t('store.noTaskId'));\n        }\n\n        let pollErrors = 0;\n        const pollAndSync = async () => {\n          try {\n            const taskResponse = await api.getTaskStatus(projectId, taskId);\n            const task = taskResponse.data;\n\n            if (task) {\n              if (task.progress) {\n                set({ taskProgress: task.progress });\n              }\n\n              await get().syncProject();\n\n              if (task.status === 'COMPLETED') {\n                set({ taskProgress: null, activeTaskId: null });\n                await get().syncProject();\n              } else if (task.status === 'FAILED') {\n                set({\n                  taskProgress: null,\n                  activeTaskId: null,\n                  error: normalizeErrorMessage(task.error_message || task.error || t('store.generateDescFailed'))\n                });\n                await get().syncProject();\n              } else if (task.status === 'PENDING' || task.status === 'PROCESSING') {\n                setTimeout(pollAndSync, 2000);\n              }\n            }\n          } catch (error: any) {\n            console.error('[生成描述] 轮询错误:', error);\n            pollErrors++;\n            if (pollErrors >= 10) {\n              console.error('[生成描述] 轮询错误次数过多，停止轮询');\n              set({\n                taskProgress: null,\n                activeTaskId: null,\n                error: normalizeErrorMessage(error.message || t('store.generateDescTimeout'))\n              });\n              await get().syncProject();\n              return;\n            }\n            await get().syncProject();\n            setTimeout(pollAndSync, 2000);\n          }\n        };\n\n        setTimeout(pollAndSync, 2000);\n\n      } catch (error: any) {\n        console.error('[生成描述] 启动任务失败:', error);\n        await get().syncProject();\n        set({ error: normalizeErrorMessage(error.message || t('store.startGenerationFailed')) });\n        throw error;\n      }\n    }\n  },\n\n  // 生成单页描述\n  generatePageDescription: async (pageId: string, detailLevel?: string) => {\n    const { currentProject } = get();\n    if (!currentProject) return;\n\n    // 如果该页面正在生成，不重复提交\n    const targetPage = currentProject.pages.find((p) => p.id === pageId);\n    if (targetPage?.status === 'GENERATING_DESCRIPTION') {\n      devLog(`[生成描述] 页面 ${pageId} 正在生成中，跳过重复请求`);\n      return;\n    }\n\n    set({ error: null });\n\n    // 乐观更新：设置页面状态为 GENERATING_DESCRIPTION\n    const updatedPages = currentProject.pages.map((page) =>\n      page.id === pageId ? { ...page, status: 'GENERATING_DESCRIPTION' as const } : page\n    );\n    set({ currentProject: { ...currentProject, pages: updatedPages } });\n\n    try {\n      const response = await api.generatePageDescription(currentProject.id, pageId, true, undefined, detailLevel);\n\n      if (response.data) {\n        const updatedPageData = response.data;\n        const { currentProject: latestProject } = get();\n        if (latestProject) {\n          const newPages = latestProject.pages.map((page) =>\n            page.id === pageId ? { ...page, ...updatedPageData } : page\n          );\n          set({ currentProject: { ...latestProject, pages: newPages } });\n          devLog(`[生成描述] 页面 ${pageId} 描述已更新，数据来自 API 响应`);\n        }\n      }\n    } catch (error: any) {\n      // 恢复页面状态\n      await get().syncProject();\n      set({ error: normalizeErrorMessage(error.message || t('store.generateDescFailed')) });\n      throw error;\n    }\n  },\n\n  // 重新生成 PPT 翻新项目的单页（重新解析原 PDF 并提取内容）\n  regenerateRenovationPage: async (pageId: string, keepLayout: boolean = false) => {\n    const { currentProject } = get();\n    if (!currentProject) return;\n\n    // 如果该页面正在生成，不重复提交\n    const targetPage = currentProject.pages.find((p) => p.id === pageId);\n    if (targetPage?.status === 'GENERATING_DESCRIPTION') {\n      devLog(`[PPT翻新] 页面 ${pageId} 正在生成中，跳过重复请求`);\n      return;\n    }\n\n    set({ error: null });\n\n    // 乐观更新：设置页面状态为 GENERATING_DESCRIPTION\n    const updatedPages = currentProject.pages.map((page) =>\n      page.id === pageId ? { ...page, status: 'GENERATING_DESCRIPTION' as const } : page\n    );\n    set({ currentProject: { ...currentProject, pages: updatedPages } });\n\n    try {\n      const response = await api.regenerateRenovationPage(currentProject.id, pageId, keepLayout);\n\n      if (response.data) {\n        const updatedPageData = response.data;\n        const { currentProject: latestProject } = get();\n        if (latestProject) {\n          const newPages = latestProject.pages.map((page) =>\n            page.id === pageId ? { ...page, ...updatedPageData } : page\n          );\n          set({ currentProject: { ...latestProject, pages: newPages } });\n          devLog(`[PPT翻新] 页面 ${pageId} 大纲和描述已更新`);\n        }\n      }\n    } catch (error: any) {\n      await get().syncProject();\n      set({ error: normalizeErrorMessage(error.message || t('store.regenerateFailed')) });\n      throw error;\n    }\n  },\n\n  // 生成图片（非阻塞，每个页面显示生成状态）\n  generateImages: async (pageIds?: string[]) => {\n    const { currentProject, pageGeneratingTasks } = get();\n    if (!currentProject) return;\n\n    // 确定要生成的页面ID列表\n    const targetPageIds = pageIds || currentProject.pages.map(p => p.id).filter((id): id is string => !!id);\n    \n    // 检查是否有页面正在生成\n    const alreadyGenerating = targetPageIds.filter(id => pageGeneratingTasks[id]);\n    if (alreadyGenerating.length > 0) {\n      devLog(`[批量生成] ${alreadyGenerating.length} 个页面正在生成中，跳过`);\n      // 过滤掉已经在生成的页面\n      const newPageIds = targetPageIds.filter(id => !pageGeneratingTasks[id]);\n      if (newPageIds.length === 0) {\n        devLog('[批量生成] 所有页面都在生成中，跳过请求');\n        return;\n      }\n    }\n\n    set({ error: null, warningMessage: null });\n    \n    try {\n      // 调用批量生成 API\n      const response = await api.generateImages(currentProject.id, undefined, pageIds);\n      const taskId = response.data?.task_id;\n      \n      if (taskId) {\n        devLog(`[批量生成] 收到 task_id: ${taskId}，标记 ${targetPageIds.length} 个页面为生成中`);\n        \n        // 为所有目标页面设置任务ID\n        const newPageGeneratingTasks = { ...pageGeneratingTasks };\n        targetPageIds.forEach(id => {\n          newPageGeneratingTasks[id] = taskId;\n        });\n        set({ pageGeneratingTasks: newPageGeneratingTasks });\n        \n        // 立即同步一次项目数据，以获取后端设置的 'QUEUED' 状态\n        await get().syncProject();\n\n        // 开始轮询批量任务状态（非阻塞）\n        get().pollImageTask(taskId, targetPageIds);\n      } else {\n        // 如果没有返回 task_id，可能是同步接口，直接刷新\n        await get().syncProject();\n      }\n    } catch (error: any) {\n      console.error('[批量生成] 启动失败:', error);\n      throw error;\n    }\n  },\n\n  // 轮询图片生成任务（非阻塞，支持单页和批量）\n  pollImageTask: async (taskId: string, pageIds: string[]) => {\n    const { currentProject } = get();\n    if (!currentProject) {\n      console.warn('[批量轮询] 没有当前项目，停止轮询');\n      return;\n    }\n    const projectId = currentProject.id!;\n\n    const poll = async () => {\n      try {\n        const response = await api.getTaskStatus(projectId, taskId);\n        const task = response.data;\n        \n        if (!task) {\n          console.warn('[批量轮询] 响应中没有任务数据');\n          return;\n        }\n\n        devLog(`[批量轮询] Task ${taskId} 状态: ${task.status}`, task.progress);\n\n        // 检查任务状态\n        if (task.status === 'COMPLETED') {\n          devLog(`[批量轮询] Task ${taskId} 已完成，清除任务记录`);\n          // 清除所有相关页面的任务记录\n          const { pageGeneratingTasks } = get();\n          const newTasks = { ...pageGeneratingTasks };\n          pageIds.forEach(id => {\n            if (newTasks[id] === taskId) {\n              delete newTasks[id];\n            }\n          });\n          \n          // 提取警告消息（如果有）\n          const warningMessage = task.progress?.warning_message || null;\n          \n          set({ pageGeneratingTasks: newTasks, warningMessage });\n\n          // 刷新项目数据，并验证图片路径已更新\n          // 使用重试机制确保数据同步完成\n          let retryCount = 0;\n          const maxRetries = 5;\n          const retryDelay = 1000; // 1秒\n\n          const syncWithRetry = async (): Promise<void> => {\n            await get().syncProject();\n\n            // 验证所有页面的图片路径是否已更新\n            const { currentProject: updatedProject } = get();\n            if (updatedProject) {\n              const allImagesReady = pageIds.every(pageId => {\n                const page = updatedProject.pages.find(p => p.id === pageId);\n                return page?.generated_image_path;\n              });\n\n              if (allImagesReady) {\n                devLog(`[批量轮询] 所有图片路径已同步`);\n                return;\n              }\n\n              if (retryCount < maxRetries) {\n                retryCount++;\n                devLog(`[批量轮询] 图片路径尚未完全同步，${retryDelay}ms 后重试 (${retryCount}/${maxRetries})`);\n                await new Promise(resolve => setTimeout(resolve, retryDelay));\n                return syncWithRetry();\n              } else {\n                console.warn(`[批量轮询] 达到最大重试次数，部分图片路径可能未同步`);\n              }\n            }\n          };\n\n          await syncWithRetry();\n        } else if (task.status === 'FAILED') {\n          console.error(`[批量轮询] Task ${taskId} 失败:`, task.error_message || task.error);\n          // 清除所有相关页面的任务记录\n          const { pageGeneratingTasks } = get();\n          const newTasks = { ...pageGeneratingTasks };\n          pageIds.forEach(id => {\n            if (newTasks[id] === taskId) {\n              delete newTasks[id];\n            }\n          });\n          set({ \n            pageGeneratingTasks: newTasks,\n            error: normalizeErrorMessage(task.error_message || task.error || t('store.batchGenerateFailed'))\n          });\n          // 刷新项目数据以更新页面状态\n          await get().syncProject();\n        } else if (task.status === 'PENDING' || task.status === 'PROCESSING') {\n          // 检查警告消息\n          const newWarning = task.progress?.warning_message;\n          if (newWarning && get().warningMessage !== newWarning) {\n            set({ warningMessage: newWarning });\n          }\n          // 继续轮询，同时同步项目数据以更新页面状态\n          devLog(`[批量轮询] Task ${taskId} 处理中，同步项目数据...`);\n          await get().syncProject();\n\n          // 逐个释放已完成的页面，让缩略图立刻显示\n          const { currentProject: proj, pageGeneratingTasks: pgt } = get();\n          if (proj) {\n            const updated = { ...pgt };\n            let changed = false;\n            pageIds.forEach(id => {\n              if (updated[id] === taskId) {\n                const page = proj.pages.find(p => p.id === id);\n                // 只释放已完成或失败的页面，避免误释放尚未被线程池拾取的页面\n                // （未拾取的页面仍为 DESCRIPTION_GENERATED，不应提前释放）\n                if (page && (page.status === 'COMPLETED' || page.status === 'FAILED')) {\n                  delete updated[id];\n                  changed = true;\n                }\n              }\n            });\n            if (changed) set({ pageGeneratingTasks: updated });\n          }\n\n          devLog(`[批量轮询] Task ${taskId} 处理中，2秒后继续轮询...`);\n          setTimeout(poll, 2000);\n        } else {\n          // 未知状态，停止轮询\n          console.warn(`[批量轮询] Task ${taskId} 未知状态: ${task.status}，停止轮询`);\n          const { pageGeneratingTasks } = get();\n          const newTasks = { ...pageGeneratingTasks };\n          pageIds.forEach(id => {\n            if (newTasks[id] === taskId) {\n              delete newTasks[id];\n            }\n          });\n          set({ pageGeneratingTasks: newTasks });\n        }\n      } catch (error: any) {\n        console.error('[批量轮询] 轮询错误:', error);\n        // 清除所有相关页面的任务记录\n        const { pageGeneratingTasks } = get();\n        const newTasks = { ...pageGeneratingTasks };\n        pageIds.forEach(id => {\n          if (newTasks[id] === taskId) {\n            delete newTasks[id];\n          }\n        });\n        set({ pageGeneratingTasks: newTasks });\n      }\n    };\n\n    // 开始轮询（不 await，立即返回让 UI 继续响应）\n    poll();\n  },\n\n  // 编辑页面图片（异步）\n  editPageImage: async (pageId, editPrompt, contextImages) => {\n    const { currentProject, pageGeneratingTasks } = get();\n    if (!currentProject) return;\n\n    // 如果该页面正在生成，不重复提交\n    if (pageGeneratingTasks[pageId]) {\n      devLog(`[编辑] 页面 ${pageId} 正在生成中，跳过重复请求`);\n      return;\n    }\n\n    set({ error: null });\n    try {\n      const response = await api.editPageImage(currentProject.id, pageId, editPrompt, contextImages);\n      const taskId = response.data?.task_id;\n      \n      if (taskId) {\n        // 记录该页面的任务ID\n        set({ \n          pageGeneratingTasks: { ...pageGeneratingTasks, [pageId]: taskId }\n        });\n        \n        // 立即同步一次项目数据，以获取后端设置的'GENERATING'状态\n        await get().syncProject();\n        \n        // 开始轮询（使用统一的轮询函数）\n        get().pollImageTask(taskId, [pageId]);\n      } else {\n        // 如果没有返回task_id，可能是同步接口，直接刷新\n        await get().syncProject();\n      }\n    } catch (error: any) {\n      // 清除该页面的任务记录\n      const { pageGeneratingTasks } = get();\n      const newTasks = { ...pageGeneratingTasks };\n      delete newTasks[pageId];\n      set({ pageGeneratingTasks: newTasks, error: normalizeErrorMessage(error.message || t('store.editImageFailed')) });\n      throw error;\n    }\n  },\n\n  // 导出PPTX\n  exportPPTX: async (pageIds?: string[]) => {\n    const { currentProject } = get();\n    if (!currentProject) return;\n\n    set({ isGlobalLoading: true, error: null });\n    try {\n      const response = await api.exportPPTX(currentProject.id, pageIds);\n      // 优先使用相对路径，避免 Docker 环境下的端口问题\n      const downloadUrl =\n        response.data?.download_url || response.data?.download_url_absolute;\n\n      if (!downloadUrl) {\n        throw new Error(t('store.exportLinkFailed'));\n      }\n\n      // 使用浏览器直接下载链接，避免 axios 受带宽和超时影响\n      window.open(downloadUrl, '_blank');\n    } catch (error: any) {\n      set({ error: error.message || t('store.exportFailed') });\n    } finally {\n      set({ isGlobalLoading: false });\n    }\n  },\n\n  // 导出PDF\n  exportPDF: async (pageIds?: string[]) => {\n    const { currentProject } = get();\n    if (!currentProject) return;\n\n    set({ isGlobalLoading: true, error: null });\n    try {\n      const response = await api.exportPDF(currentProject.id, pageIds);\n      // 优先使用相对路径，避免 Docker 环境下的端口问题\n      const downloadUrl =\n        response.data?.download_url || response.data?.download_url_absolute;\n\n      if (!downloadUrl) {\n        throw new Error(t('store.exportLinkFailed'));\n      }\n\n      // 使用浏览器直接下载链接，避免 axios 受带宽和超时影响\n      window.open(downloadUrl, '_blank');\n    } catch (error: any) {\n      set({ error: error.message || t('store.exportFailed') });\n    } finally {\n      set({ isGlobalLoading: false });\n    }\n  },\n\n  // 导出可编辑PPTX（异步任务）\n  exportEditablePPTX: async (filename?: string, pageIds?: string[]) => {\n    const { currentProject, startAsyncTask } = get();\n    if (!currentProject) return;\n\n    try {\n      devLog('[导出可编辑PPTX] 启动异步导出任务...');\n      // startAsyncTask 中的 pollTask 会在任务完成时自动处理下载\n      await startAsyncTask(() => api.exportEditablePPTX(currentProject.id, filename, pageIds));\n      devLog('[导出可编辑PPTX] 异步任务完成');\n    } catch (error: any) {\n      console.error('[导出可编辑PPTX] 导出失败:', error);\n      set({ error: error.message || t('store.exportEditableFailed') });\n    }\n  },\n};});"
  },
  {
    "path": "frontend/src/tests/components/Button.test.tsx",
    "content": "/**\n * Button 组件测试\n */\n\nimport { describe, it, expect, vi } from 'vitest'\nimport { render, screen, fireEvent } from '@testing-library/react'\nimport { Button } from '@/components/shared/Button'\n\ndescribe('Button Component', () => {\n  it('renders button with text', () => {\n    render(<Button>Click me</Button>)\n    expect(screen.getByText('Click me')).toBeInTheDocument()\n  })\n\n  it('calls onClick handler when clicked', () => {\n    const handleClick = vi.fn()\n    render(<Button onClick={handleClick}>Click</Button>)\n    \n    fireEvent.click(screen.getByText('Click'))\n    expect(handleClick).toHaveBeenCalledTimes(1)\n  })\n\n  it('disables button when disabled prop is true', () => {\n    render(<Button disabled>Disabled</Button>)\n    expect(screen.getByText('Disabled')).toBeDisabled()\n  })\n\n  it('applies gradient styles for primary variant', () => {\n    render(<Button>Primary</Button>)\n    const button = screen.getByText('Primary')\n    // 实际使用gradient样式\n    expect(button).toHaveClass('bg-gradient-to-r')\n  })\n\n  it('applies border styles for secondary variant', () => {\n    render(<Button variant=\"secondary\">Secondary</Button>)\n    const button = screen.getByText('Secondary')\n    // secondary使用border样式\n    expect(button).toHaveClass('border-banana-500')\n  })\n\n  it('shows loading state and disables button', () => {\n    render(<Button loading>Loading</Button>)\n    const button = screen.getByRole('button')\n    expect(button).toBeDisabled()\n    // 应该有loading spinner\n    expect(button.querySelector('svg')).toBeInTheDocument()\n  })\n\n  it('renders with custom className', () => {\n    render(<Button className=\"custom-class\">Custom</Button>)\n    const button = screen.getByText('Custom')\n    expect(button).toHaveClass('custom-class')\n  })\n\n  it('renders icon when provided', () => {\n    const TestIcon = () => <span data-testid=\"test-icon\">★</span>\n    render(<Button icon={<TestIcon />}>With Icon</Button>)\n    expect(screen.getByTestId('test-icon')).toBeInTheDocument()\n  })\n})\n\n"
  },
  {
    "path": "frontend/src/tests/components/DescriptionCard.test.tsx",
    "content": "/**\n * DescriptionCard 组件测试 - 验证图片粘贴功能\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest'\nimport { render, screen, fireEvent, waitFor } from '@testing-library/react'\nimport { DescriptionCard } from '@/components/preview/DescriptionCard'\nimport type { Page } from '@/types'\n\n// Mock uploadMaterial\nconst mockUploadMaterial = vi.fn()\nvi.mock('@/api/endpoints', () => ({\n  uploadMaterial: (...args: any[]) => mockUploadMaterial(...args),\n}))\n\n// Mock MarkdownTextarea as a plain textarea so getByDisplayValue works\nvi.mock('@/components/shared/MarkdownTextarea', () => {\n  // eslint-disable-next-line @typescript-eslint/no-var-requires\n  const React = require('react')\n  return {\n    MarkdownTextarea: React.forwardRef(\n      ({ value, onChange, onPaste, onFocus, placeholder, label }: any, ref: any) => {\n        const textareaRef = React.useRef<HTMLTextAreaElement>(null)\n        React.useImperativeHandle(ref, () => ({\n          insertAtCursor: (text: string) => {\n            // Simulate inserting text at end\n            onChange(value + text)\n          },\n          focus: () => textareaRef.current?.focus(),\n        }))\n        return (\n          <div>\n            {label && <label>{label}</label>}\n            <textarea\n              ref={textareaRef}\n              value={value}\n              onChange={(e: any) => onChange(e.target.value)}\n              onPaste={onPaste}\n              onFocus={onFocus}\n              placeholder={placeholder}\n            />\n          </div>\n        )\n      }\n    ),\n  }\n})\n\n// Mock useT hook to return the key as-is for testing\nvi.mock('@/hooks/useT', () => ({\n  useT: () => (key: string, params?: Record<string, any>) => {\n    if (params) {\n      let result = key\n      for (const [k, v] of Object.entries(params)) {\n        result = result.replace(`{{${k}}}`, String(v))\n      }\n      return result\n    }\n    return key\n  },\n}))\n\n// Mock useGeneratingState hook\nvi.mock('@/hooks/useGeneratingState', () => ({\n  useDescriptionGeneratingState: (page: any, isAiRefining: boolean) =>\n    page.status === 'GENERATING_DESCRIPTION' || isAiRefining,\n}))\n\n// jsdom doesn't have URL.createObjectURL\nif (typeof URL.createObjectURL === 'undefined') {\n  URL.createObjectURL = vi.fn(() => 'blob:mock-url')\n  URL.revokeObjectURL = vi.fn()\n}\n\ndescribe('DescriptionCard', () => {\n  const mockPage: Page = {\n    id: 'page-1',\n    project_id: 'proj-1',\n    order_index: 0,\n    status: 'DESCRIPTION_GENERATED',\n    description_content: { text: 'Test description content' },\n    outline_content: { title: 'Test Page', points: ['point 1'] },\n  } as Page\n\n  const defaultProps = {\n    page: mockPage,\n    index: 0,\n    projectId: 'proj-1',\n    showToast: vi.fn(),\n    onUpdate: vi.fn(),\n    onRegenerate: vi.fn(),\n  }\n\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('renders description text', () => {\n    render(<DescriptionCard {...defaultProps} />)\n    expect(screen.getByText('Test description content')).toBeInTheDocument()\n  })\n\n  it('renders page number', () => {\n    render(<DescriptionCard {...defaultProps} />)\n    expect(screen.getByText('descriptionCard.page')).toBeInTheDocument()\n  })\n\n  it('opens edit modal when edit button is clicked', () => {\n    render(<DescriptionCard {...defaultProps} />)\n    fireEvent.click(screen.getByText('common.edit'))\n    // Modal should now be open with the textarea\n    expect(screen.getByText('descriptionCard.descriptionTitle')).toBeInTheDocument()\n  })\n\n  it('saves edited content when save is clicked', async () => {\n    const onUpdate = vi.fn()\n    render(<DescriptionCard {...defaultProps} onUpdate={onUpdate} />)\n\n    // Open edit modal\n    fireEvent.click(screen.getByText('common.edit'))\n\n    // Find textarea and change content\n    const textarea = screen.getByDisplayValue('Test description content')\n    fireEvent.change(textarea, { target: { value: 'Updated content' } })\n\n    // Save\n    fireEvent.click(screen.getByText('common.save'))\n\n    expect(onUpdate).toHaveBeenCalledWith({\n      description_content: { text: 'Updated content' },\n    })\n  })\n\n  it('handles image paste in edit modal', async () => {\n    mockUploadMaterial.mockResolvedValue({\n      data: { url: 'https://example.com/uploaded-image.png' },\n    })\n\n    render(<DescriptionCard {...defaultProps} />)\n\n    // Open edit modal\n    fireEvent.click(screen.getByText('common.edit'))\n\n    // Get the textarea and focus it (triggers focusMainDesc to set up paste target)\n    const textarea = screen.getByDisplayValue('Test description content')\n    fireEvent.focus(textarea)\n\n    // Create a mock paste event with an image file\n    const file = new File(['image-data'], 'screenshot.png', { type: 'image/png' })\n    const clipboardData = {\n      items: [\n        {\n          kind: 'file',\n          type: 'image/png',\n          getAsFile: () => file,\n        },\n      ],\n    }\n\n    // Fire paste event\n    fireEvent.paste(textarea, { clipboardData })\n\n    // Wait for upload to complete\n    await waitFor(() => {\n      expect(mockUploadMaterial).toHaveBeenCalledWith(file, 'proj-1', true)\n    })\n\n    // The textarea value should contain the markdown image link after state update\n    await waitFor(() => {\n      const updatedTextarea = screen.getByRole('textbox') as HTMLTextAreaElement\n      expect(updatedTextarea.value).toContain('![screenshot](https://example.com/uploaded-image.png)')\n    })\n  })\n\n  it('does not trigger upload for non-image paste', () => {\n    render(<DescriptionCard {...defaultProps} />)\n\n    // Open edit modal\n    fireEvent.click(screen.getByText('common.edit'))\n\n    const textarea = screen.getByDisplayValue('Test description content')\n\n    // Paste plain text (no file items)\n    const clipboardData = {\n      items: [\n        {\n          kind: 'string',\n          type: 'text/plain',\n          getAsFile: () => null,\n        },\n      ],\n    }\n\n    fireEvent.paste(textarea, { clipboardData })\n\n    expect(mockUploadMaterial).not.toHaveBeenCalled()\n  })\n\n  it('shows no description message when description is empty', () => {\n    const emptyPage = { ...mockPage, description_content: undefined }\n    render(<DescriptionCard {...defaultProps} page={emptyPage as Page} />)\n    expect(screen.getByText('descriptionCard.noDescription')).toBeInTheDocument()\n  })\n\n  it('shows generating state when page status is GENERATING_DESCRIPTION', () => {\n    const generatingPage = { ...mockPage, status: 'GENERATING_DESCRIPTION' as const }\n    render(<DescriptionCard {...defaultProps} page={generatingPage} />)\n    // \"common.generating\" appears both in skeleton area and regenerate button\n    const generatingElements = screen.getAllByText('common.generating')\n    expect(generatingElements.length).toBeGreaterThanOrEqual(1)\n  })\n\n  it('passes projectId to uploadMaterial for paste', async () => {\n    mockUploadMaterial.mockResolvedValue({\n      data: { url: 'https://example.com/img.png' },\n    })\n\n    render(<DescriptionCard {...defaultProps} projectId=\"proj-42\" />)\n\n    fireEvent.click(screen.getByText('common.edit'))\n\n    const textarea = screen.getByDisplayValue('Test description content')\n\n    const file = new File(['data'], 'img.png', { type: 'image/png' })\n    fireEvent.paste(textarea, {\n      clipboardData: {\n        items: [{\n          kind: 'file',\n          type: 'image/png',\n          getAsFile: () => file,\n        }],\n      },\n    })\n\n    await waitFor(() => {\n      expect(mockUploadMaterial).toHaveBeenCalledWith(file, 'proj-42', true)\n    })\n  })\n\n  it('uses null projectId when not provided', async () => {\n    mockUploadMaterial.mockResolvedValue({\n      data: { url: 'https://example.com/img.png' },\n    })\n\n    render(<DescriptionCard {...defaultProps} projectId={undefined} />)\n\n    fireEvent.click(screen.getByText('common.edit'))\n\n    const textarea = screen.getByDisplayValue('Test description content')\n\n    const file = new File(['data'], 'img.png', { type: 'image/png' })\n    fireEvent.paste(textarea, {\n      clipboardData: {\n        items: [{\n          kind: 'file',\n          type: 'image/png',\n          getAsFile: () => file,\n        }],\n      },\n    })\n\n    await waitFor(() => {\n      expect(mockUploadMaterial).toHaveBeenCalledWith(file, null, true)\n    })\n  })\n})\n"
  },
  {
    "path": "frontend/src/tests/components/Markdown.test.tsx",
    "content": "/**\n * Markdown 组件测试 - 验证 LaTeX 公式渲染\n */\n\nimport { describe, it, expect } from 'vitest'\nimport { render, screen } from '@testing-library/react'\nimport { Markdown } from '@/components/shared/Markdown'\n\ndescribe('Markdown Component', () => {\n  it('renders plain text', () => {\n    render(<Markdown>Hello World</Markdown>)\n    expect(screen.getByText('Hello World')).toBeInTheDocument()\n  })\n\n  it('renders markdown bold text', () => {\n    render(<Markdown>**bold text**</Markdown>)\n    const bold = screen.getByText('bold text')\n    expect(bold.tagName).toBe('STRONG')\n  })\n\n  it('renders markdown links', () => {\n    render(<Markdown>[link](https://example.com)</Markdown>)\n    const link = screen.getByText('link')\n    expect(link.tagName).toBe('A')\n    expect(link).toHaveAttribute('href', 'https://example.com')\n    expect(link).toHaveAttribute('target', '_blank')\n  })\n\n  it('renders markdown images', () => {\n    render(<Markdown>![alt text](https://example.com/img.png)</Markdown>)\n    const img = screen.getByAltText('alt text')\n    expect(img).toBeInTheDocument()\n    expect(img).toHaveAttribute('src', 'https://example.com/img.png')\n  })\n\n  it('renders inline LaTeX formula with $ delimiters', () => {\n    const { container } = render(<Markdown>The formula $E = mc^2$ is famous</Markdown>)\n    // KaTeX renders math into spans with class \"katex\"\n    const katexElements = container.querySelectorAll('.katex')\n    expect(katexElements.length).toBeGreaterThan(0)\n  })\n\n  it('renders block LaTeX formula with $$ delimiters', () => {\n    const { container } = render(\n      <Markdown>{`Before\n\n$$x^2 + y^2 = z^2$$\n\nAfter`}</Markdown>\n    )\n    // Block math should render with katex class (katex-display wraps it)\n    const katexElements = container.querySelectorAll('.katex')\n    expect(katexElements.length).toBeGreaterThan(0)\n  })\n\n  it('renders multiple LaTeX formulas in same text', () => {\n    const { container } = render(\n      <Markdown>Given $a^2 + b^2 = c^2$ and $E = mc^2$, we can derive</Markdown>\n    )\n    const katexElements = container.querySelectorAll('.katex')\n    expect(katexElements.length).toBe(2)\n  })\n\n  it('renders GFM tables', () => {\n    const tableMarkdown = `| Header | Value |\n| --- | --- |\n| Row 1 | Data 1 |`\n\n    render(<Markdown>{tableMarkdown}</Markdown>)\n    expect(screen.getByText('Header')).toBeInTheDocument()\n    expect(screen.getByText('Data 1')).toBeInTheDocument()\n  })\n\n  it('applies custom className', () => {\n    const { container } = render(<Markdown className=\"custom-class\">text</Markdown>)\n    expect(container.firstChild).toHaveClass('markdown-content')\n    expect(container.firstChild).toHaveClass('custom-class')\n  })\n\n  it('renders mixed markdown and LaTeX', () => {\n    const { container } = render(\n      <Markdown>{`**Bold** and $x^2$ together`}</Markdown>\n    )\n    expect(screen.getByText('Bold')).toBeInTheDocument()\n    const katexElements = container.querySelectorAll('.katex')\n    expect(katexElements.length).toBeGreaterThan(0)\n  })\n\n  it('renders inline LaTeX with \\\\(...\\\\) delimiters', () => {\n    const { container } = render(\n      <Markdown>{`The formula \\\\(E = mc^2\\\\) is famous`}</Markdown>\n    )\n    const katexElements = container.querySelectorAll('.katex')\n    expect(katexElements.length).toBeGreaterThan(0)\n  })\n\n  it('renders block LaTeX with \\\\[...\\\\] delimiters', () => {\n    const { container } = render(\n      <Markdown>{`Before\n\n\\\\[x^2 + y^2 = z^2\\\\]\n\nAfter`}</Markdown>\n    )\n    const katexElements = container.querySelectorAll('.katex')\n    expect(katexElements.length).toBeGreaterThan(0)\n  })\n\n  it('renders mixed delimiter formats', () => {\n    const { container } = render(\n      <Markdown>{`Inline $a^2$ and \\\\(b^2\\\\) with block:\n\n$$c^2$$\n\nand\n\n\\\\[d^2\\\\]`}</Markdown>\n    )\n    const katexElements = container.querySelectorAll('.katex')\n    expect(katexElements.length).toBe(4)\n  })\n})\n"
  },
  {
    "path": "frontend/src/tests/setup.ts",
    "content": "/**\n * Vitest 测试环境设置文件\n * \n * 配置测试所需的全局设置和模拟\n */\n\nimport '@testing-library/jest-dom'\nimport { afterEach, vi } from 'vitest'\nimport { cleanup } from '@testing-library/react'\n\n// 每个测试后清理\nafterEach(() => {\n  cleanup()\n})\n\n// Mock matchMedia（某些组件需要）\nObject.defineProperty(window, 'matchMedia', {\n  writable: true,\n  value: vi.fn().mockImplementation(query => ({\n    matches: false,\n    media: query,\n    onchange: null,\n    addListener: vi.fn(),\n    removeListener: vi.fn(),\n    addEventListener: vi.fn(),\n    removeEventListener: vi.fn(),\n    dispatchEvent: vi.fn(),\n  })),\n})\n\n// Mock ResizeObserver\nglobal.ResizeObserver = vi.fn().mockImplementation(() => ({\n  observe: vi.fn(),\n  unobserve: vi.fn(),\n  disconnect: vi.fn(),\n}))\n\n// Mock IntersectionObserver\nglobal.IntersectionObserver = vi.fn().mockImplementation(() => ({\n  observe: vi.fn(),\n  unobserve: vi.fn(),\n  disconnect: vi.fn(),\n}))\n\n// Mock scrollTo\nwindow.scrollTo = vi.fn()\n\n// Mock fetch (可以在具体测试中覆盖)\nglobal.fetch = vi.fn()\n\n"
  },
  {
    "path": "frontend/src/tests/store/useProjectStore.initializeProject.test.ts",
    "content": "/**\n * initializeProject 测试 - 验证参考文件在 AI 生成前被关联到项目\n */\n\nimport { describe, it, expect, beforeEach, vi } from 'vitest'\nimport { act, renderHook } from '@testing-library/react'\nimport { useProjectStore } from '@/store/useProjectStore'\n\n// Track call order to verify files are associated before generation\nconst callOrder: string[] = []\n\nconst mockCreateProject = vi.fn()\nconst mockGetProject = vi.fn()\nconst mockAssociateFileToProject = vi.fn()\nconst mockUploadTemplate = vi.fn()\nconst mockGenerateFromDescription = vi.fn()\n\nvi.mock('@/api/endpoints', () => ({\n  createProject: (...args: any[]) => {\n    callOrder.push('createProject')\n    return mockCreateProject(...args)\n  },\n  getProject: (...args: any[]) => {\n    callOrder.push('getProject')\n    return mockGetProject(...args)\n  },\n  associateFileToProject: (...args: any[]) => {\n    callOrder.push('associateFileToProject')\n    return mockAssociateFileToProject(...args)\n  },\n  uploadTemplate: (...args: any[]) => {\n    callOrder.push('uploadTemplate')\n    return mockUploadTemplate(...args)\n  },\n  generateFromDescription: (...args: any[]) => {\n    callOrder.push('generateFromDescription')\n    return mockGenerateFromDescription(...args)\n  },\n  // Other mocks needed by the store\n  updatePage: vi.fn(),\n  updatePageDescription: vi.fn(),\n  updatePageOutline: vi.fn(),\n  generateOutline: vi.fn(),\n  generateDescriptions: vi.fn(),\n  generateImages: vi.fn(),\n  getTaskStatus: vi.fn(),\n  exportPPTX: vi.fn(),\n  exportPDF: vi.fn(),\n  getStoredOutputLanguage: vi.fn().mockResolvedValue('zh'),\n}))\n\nvi.mock('@/api/auth', () => ({\n  refreshCredits: vi.fn(),\n}))\n\nvi.mock('@/utils', () => ({\n  debounce: (fn: any) => fn,\n  normalizeProject: (data: any) => data,\n  normalizeErrorMessage: (msg: string) => msg,\n}))\n\ndescribe('initializeProject - reference file association', () => {\n  beforeEach(() => {\n    callOrder.length = 0\n    vi.clearAllMocks()\n\n    // Default mock responses\n    mockCreateProject.mockResolvedValue({\n      data: { project_id: 'proj-001' }\n    })\n    mockGetProject.mockResolvedValue({\n      data: { id: 'proj-001', status: 'DRAFT', pages: [] }\n    })\n    mockAssociateFileToProject.mockResolvedValue({\n      data: { file: { id: 'file-1', project_id: 'proj-001' } }\n    })\n    mockUploadTemplate.mockResolvedValue({ data: {} })\n    mockGenerateFromDescription.mockResolvedValue({ data: {} })\n\n    // Reset store\n    const { result } = renderHook(() => useProjectStore())\n    act(() => {\n      result.current.setCurrentProject(null)\n      result.current.setError(null)\n      result.current.setGlobalLoading(false)\n    })\n  })\n\n  it('should pass reference file IDs and associate them after project creation', async () => {\n    const { result } = renderHook(() => useProjectStore())\n\n    await act(async () => {\n      await result.current.initializeProject(\n        'idea',\n        'Test idea prompt',\n        undefined,\n        undefined,\n        ['file-1', 'file-2']\n      )\n    })\n\n    expect(mockAssociateFileToProject).toHaveBeenCalledTimes(2)\n    expect(mockAssociateFileToProject).toHaveBeenCalledWith('file-1', 'proj-001')\n    expect(mockAssociateFileToProject).toHaveBeenCalledWith('file-2', 'proj-001')\n  })\n\n  it('should associate files BEFORE generating from description', async () => {\n    const { result } = renderHook(() => useProjectStore())\n\n    await act(async () => {\n      await result.current.initializeProject(\n        'description',\n        'Full description text',\n        undefined,\n        undefined,\n        ['file-1']\n      )\n    })\n\n    // Verify call order: create → associate → generate\n    const createIdx = callOrder.indexOf('createProject')\n    const associateIdx = callOrder.indexOf('associateFileToProject')\n    const generateIdx = callOrder.indexOf('generateFromDescription')\n\n    expect(createIdx).toBeLessThan(associateIdx)\n    expect(associateIdx).toBeLessThan(generateIdx)\n  })\n\n  it('should not call associateFileToProject when no file IDs provided', async () => {\n    const { result } = renderHook(() => useProjectStore())\n\n    await act(async () => {\n      await result.current.initializeProject('idea', 'Test prompt')\n    })\n\n    expect(mockAssociateFileToProject).not.toHaveBeenCalled()\n  })\n\n  it('should not call associateFileToProject when empty array provided', async () => {\n    const { result } = renderHook(() => useProjectStore())\n\n    await act(async () => {\n      await result.current.initializeProject('idea', 'Test prompt', undefined, undefined, [])\n    })\n\n    expect(mockAssociateFileToProject).not.toHaveBeenCalled()\n  })\n\n  it('should continue even if file association fails', async () => {\n    mockAssociateFileToProject.mockRejectedValue(new Error('Network error'))\n\n    const { result } = renderHook(() => useProjectStore())\n\n    await act(async () => {\n      await result.current.initializeProject(\n        'idea',\n        'Test prompt',\n        undefined,\n        undefined,\n        ['file-1']\n      )\n    })\n\n    // Should still complete successfully\n    expect(result.current.currentProject).not.toBeNull()\n    expect(result.current.error).toBeNull()\n  })\n\n  it('should associate files before uploading template', async () => {\n    const templateFile = new File(['dummy'], 'template.png', { type: 'image/png' })\n\n    const { result } = renderHook(() => useProjectStore())\n\n    await act(async () => {\n      await result.current.initializeProject(\n        'idea',\n        'Test prompt',\n        templateFile,\n        undefined,\n        ['file-1']\n      )\n    })\n\n    const associateIdx = callOrder.indexOf('associateFileToProject')\n    const templateIdx = callOrder.indexOf('uploadTemplate')\n\n    expect(associateIdx).toBeLessThan(templateIdx)\n  })\n})\n"
  },
  {
    "path": "frontend/src/tests/store/useProjectStore.test.ts",
    "content": "/**\n * Zustand Store 测试\n * \n * 测试useProjectStore的核心状态管理功能\n */\n\nimport { describe, it, expect, beforeEach, vi } from 'vitest'\nimport { act, renderHook } from '@testing-library/react'\nimport { useProjectStore } from '@/store/useProjectStore'\n\n// Mock API模块\nvi.mock('@/api/endpoints', () => ({\n  createProject: vi.fn(),\n  getProject: vi.fn(),\n  updatePage: vi.fn(),\n  updatePageDescription: vi.fn(),\n  updatePageOutline: vi.fn(),\n  generateOutline: vi.fn(),\n  generateDescriptions: vi.fn(),\n  generateImages: vi.fn(),\n  getTaskStatus: vi.fn(),\n  exportPPTX: vi.fn(),\n  exportPDF: vi.fn(),\n}))\n\ndescribe('useProjectStore', () => {\n  beforeEach(() => {\n    // 重置store状态\n    const { result } = renderHook(() => useProjectStore())\n    act(() => {\n      result.current.setCurrentProject(null)\n      result.current.setError(null)\n      result.current.setGlobalLoading(false)\n    })\n  })\n\n  describe('初始状态', () => {\n    it('should initialize with default state', () => {\n      const { result } = renderHook(() => useProjectStore())\n      \n      expect(result.current.currentProject).toBeNull()\n      expect(result.current.isGlobalLoading).toBe(false)\n      expect(result.current.error).toBeNull()\n      expect(result.current.activeTaskId).toBeNull()\n    })\n  })\n\n  describe('基础Setters', () => {\n    it('should set current project correctly', () => {\n      const { result } = renderHook(() => useProjectStore())\n      const mockProject = { \n        id: '123', \n        status: 'DRAFT',\n        pages: [],\n        created_at: new Date().toISOString()\n      }\n      \n      act(() => {\n        result.current.setCurrentProject(mockProject as any)\n      })\n      \n      expect(result.current.currentProject).toEqual(mockProject)\n    })\n\n    it('should set global loading state', () => {\n      const { result } = renderHook(() => useProjectStore())\n      \n      act(() => {\n        result.current.setGlobalLoading(true)\n      })\n      \n      expect(result.current.isGlobalLoading).toBe(true)\n      \n      act(() => {\n        result.current.setGlobalLoading(false)\n      })\n      \n      expect(result.current.isGlobalLoading).toBe(false)\n    })\n\n    it('should set error correctly', () => {\n      const { result } = renderHook(() => useProjectStore())\n      \n      act(() => {\n        result.current.setError('Test error')\n      })\n      \n      expect(result.current.error).toBe('Test error')\n      \n      act(() => {\n        result.current.setError(null)\n      })\n      \n      expect(result.current.error).toBeNull()\n    })\n  })\n\n  describe('本地页面更新', () => {\n    it('should update page locally (optimistic update)', () => {\n      const { result } = renderHook(() => useProjectStore())\n      \n      // 先设置项目\n      const mockProject = {\n        id: 'proj-123',\n        status: 'DRAFT',\n        pages: [\n          { id: 'page-1', outline_content: { title: 'Page 1', points: [] } },\n          { id: 'page-2', outline_content: { title: 'Page 2', points: [] } },\n        ]\n      }\n      \n      act(() => {\n        result.current.setCurrentProject(mockProject as any)\n      })\n      \n      // 更新页面\n      act(() => {\n        result.current.updatePageLocal('page-1', { \n          outline_content: { title: 'Updated Page 1', points: ['new point'] }\n        })\n      })\n      \n      // 验证乐观更新\n      const updatedPage = result.current.currentProject?.pages.find(p => p.id === 'page-1')\n      expect(updatedPage?.outline_content?.title).toBe('Updated Page 1')\n    })\n  })\n\n  describe('清除状态', () => {\n    it('should clear project by setting null', () => {\n      const { result } = renderHook(() => useProjectStore())\n      \n      // 先设置项目\n      act(() => {\n        result.current.setCurrentProject({ id: '123', pages: [] } as any)\n      })\n      \n      expect(result.current.currentProject).not.toBeNull()\n      \n      // 清除\n      act(() => {\n        result.current.setCurrentProject(null)\n      })\n      \n      expect(result.current.currentProject).toBeNull()\n    })\n  })\n})\n\n"
  },
  {
    "path": "frontend/src/tests/utils.normalizeErrorMessage.test.ts",
    "content": "import { beforeEach, describe, expect, test } from 'vitest';\nimport { normalizeErrorMessage } from '@/utils';\n\ndescribe('normalizeErrorMessage', () => {\n  beforeEach(() => {\n    localStorage.setItem('i18nextLng', 'zh-CN');\n  });\n\n  test('maps style extraction image-input failures to actionable export guidance', () => {\n    const message = normalizeErrorMessage('文本样式提取失败: 当前图片样式提取模型不支持图片输入: caption_provider 不支持图片输入');\n    expect(message).toContain('不支持图片输入');\n    expect(message).toContain('image caption');\n  });\n\n  test('maps generic style extraction failures to editable pptx guidance', () => {\n    const message = normalizeErrorMessage('文本样式提取失败: 调用视觉模型提取文本样式失败');\n    expect(message).toContain('可编辑 PPTX 导出失败');\n    expect(message).toContain('允许返回半成品');\n  });\n});\n"
  },
  {
    "path": "frontend/src/types/index.ts",
    "content": "// 页面状态\nexport type PageStatus = 'DRAFT' | 'GENERATING_DESCRIPTION' | 'DESCRIPTION_GENERATED' | 'QUEUED' | 'GENERATING' | 'COMPLETED' | 'FAILED';\n\n// 项目状态\nexport type ProjectStatus = 'DRAFT' | 'OUTLINE_GENERATED' | 'DESCRIPTIONS_GENERATED' | 'COMPLETED';\n\n// 大纲内容\nexport interface OutlineContent {\n  title: string;\n  points: string[];\n}\n\n// 描述内容 - 支持两种格式：后端可能返回纯文本或结构化内容\nexport type DescriptionContent =\n  | {\n      // 格式1: 后端返回的纯文本格式\n      text: string;\n      extra_fields?: Record<string, string>;\n      layout_suggestion?: string; // 向后兼容\n    }\n  | {\n      // 格式2: 类型定义中的结构化格式\n      title: string;\n      text_content: string[];\n      extra_fields?: Record<string, string>;\n      layout_suggestion?: string; // 向后兼容\n    };\n\n// 图片版本\nexport interface ImageVersion {\n  version_id: string;\n  page_id: string;\n  image_path: string;\n  image_url?: string;\n  version_number: number;\n  is_current: boolean;\n  created_at?: string;\n}\n\n// 页面\nexport interface Page {\n  page_id: string;  // 后端返回 page_id\n  id?: string;      // 前端使用的别名\n  order_index: number;\n  part?: string; // 章节名\n  outline_content: OutlineContent | null;\n  description_content?: DescriptionContent;\n  generated_image_url?: string; // 后端返回 generated_image_url\n  generated_image_path?: string; // 前端使用的别名\n  status: PageStatus;\n  created_at?: string;\n  updated_at?: string;\n  image_versions?: ImageVersion[]; // 历史版本列表\n}\n\n// 导出设置 - 组件提取方法\nexport type ExportExtractorMethod = 'mineru' | 'hybrid';\n\n// 导出设置 - 背景图获取方法\nexport type ExportInpaintMethod = 'generative' | 'baidu' | 'hybrid';\n\n// 项目\nexport interface Project {\n  project_id: string;  // 后端返回 project_id\n  id?: string;         // 前端使用的别名\n  idea_prompt: string;\n  outline_text?: string;  // 用户输入的大纲文本（用于outline类型）\n  description_text?: string;  // 用户输入的描述文本（用于description类型）\n  extra_requirements?: string; // 额外要求，应用到每个页面的AI提示词\n  outline_requirements?: string; // 大纲生成要求\n  description_requirements?: string; // 页面描述生成要求\n  creation_type?: string;\n  template_image_url?: string; // 后端返回 template_image_url\n  template_image_path?: string; // 前端使用的别名\n  template_style?: string; // 风格描述文本（无模板图模式）\n  // 导出设置\n  export_extractor_method?: ExportExtractorMethod; // 组件提取方法\n  export_inpaint_method?: ExportInpaintMethod; // 背景图获取方法\n  export_allow_partial?: boolean; // 是否允许返回半成品（导出出错时继续而非停止）\n  image_aspect_ratio?: string; // 画面比例（如 16:9, 4:3）\n  status: ProjectStatus;\n  pages: Page[];\n  created_at: string;\n  updated_at: string;\n}\n\n// 任务状态\nexport type TaskStatus = 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED';\n\n// 任务信息\nexport interface Task {\n  task_id: string;\n  id?: string; // 别名\n  task_type?: string;\n  status: TaskStatus;\n  progress?: {\n    total: number;\n    completed: number;\n    failed?: number;\n    [key: string]: any; // 允许额外的字段，如material_id, image_url等\n  };\n  error_message?: string;\n  result?: any;\n  error?: string; // 别名\n  created_at?: string;\n  completed_at?: string;\n}\n\n// 创建项目请求\nexport interface CreateProjectRequest {\n  idea_prompt?: string;\n  outline_text?: string;\n  description_text?: string;\n  template_image?: File;\n  template_style?: string;\n  image_aspect_ratio?: string;\n}\n\n// API响应\nexport interface ApiResponse<T = any> {\n  success?: boolean;\n  data?: T;\n  task_id?: string;\n  message?: string;\n  error?: string;\n}\n\n// 设置\nexport interface Settings {\n  id: number;\n  ai_provider_format: string;\n  api_base_url?: string;\n  api_key_length: number;\n  image_resolution: string;\n  image_aspect_ratio: string;\n  max_description_workers: number;\n  max_image_workers: number;\n  text_model?: string;\n  image_model?: string;\n  mineru_api_base?: string;\n  mineru_token_length: number;\n  image_caption_model?: string;\n  output_language: 'zh' | 'en' | 'ja' | 'auto';\n  // 描述生成模式\n  description_generation_mode: 'streaming' | 'parallel';\n  // 描述额外字段\n  description_extra_fields?: string[];\n  image_prompt_extra_fields?: string[];\n  // 推理模式配置（分别控制文本和图像）\n  enable_text_reasoning: boolean;\n  text_thinking_budget: number;\n  enable_image_reasoning: boolean;\n  image_thinking_budget: number;\n  baidu_api_key_length: number;\n  // LazyLLM 配置\n  text_model_source?: string;\n  image_model_source?: string;\n  image_caption_model_source?: string;\n  lazyllm_api_keys_info?: Record<string, number>;  // {vendor: key_length}\n  // Per-model API credentials (for gemini/openai per-model overrides)\n  text_api_key_length: number;\n  text_api_base_url?: string;\n  image_api_key_length: number;\n  image_api_base_url?: string;\n  image_caption_api_key_length: number;\n  image_caption_api_base_url?: string;\n  created_at?: string;\n  updated_at?: string;\n}\n\n"
  },
  {
    "path": "frontend/src/utils/i18nHelper.ts",
    "content": "import i18n from '@/i18n';\n\ntype NestedRecord = Record<string, unknown>;\ntype Translations = { zh: NestedRecord; en: NestedRecord };\n\nfunction getNestedValue(obj: NestedRecord, path: string): string | undefined {\n  let current: unknown = obj;\n  for (const key of path.split('.')) {\n    if (current && typeof current === 'object' && key in current) {\n      current = (current as NestedRecord)[key];\n    } else {\n      return undefined;\n    }\n  }\n  return typeof current === 'string' ? current : undefined;\n}\n\n/**\n * Non-React translation helper for stores/utils.\n * Same pattern as useT but without React hooks.\n */\nexport function getT<T extends Translations>(translations: T) {\n  return (key: string, params?: Record<string, string | number>): string => {\n    const lang = i18n.language?.startsWith('zh') ? 'zh' : 'en';\n    const dict = translations[lang] || translations['zh'];\n    const localValue = getNestedValue(dict, key);\n\n    if (localValue !== undefined) {\n      let text = localValue;\n      if (params) {\n        Object.entries(params).forEach(([k, v]) => {\n          text = text.replace(new RegExp(`{{${k}}}`, 'g'), String(v));\n        });\n      }\n      return text;\n    }\n\n    // Fallback to global i18n\n    return i18n.t(key, params as any);\n  };\n}\n"
  },
  {
    "path": "frontend/src/utils/index.ts",
    "content": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\nimport type { Project, Page } from '@/types';\n\n/**\n * 合并 className (支持 Tailwind CSS)\n */\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\n/**\n * 标准化后端返回的项目数据\n */\nexport function normalizeProject(data: any): Project {\n  return {\n    ...data,\n    id: data.project_id || data.id,\n    template_image_path: data.template_image_url || data.template_image_path,\n    pages: (data.pages || []).map(normalizePage),\n  };\n}\n\n/**\n * 标准化后端返回的页面数据\n */\nexport function normalizePage(data: any): Page {\n  return {\n    ...data,\n    id: data.page_id || data.id,\n    generated_image_path: data.generated_image_url || data.generated_image_path,\n  };\n}\n\n/**\n * 防抖函数\n */\nexport function debounce<T extends (...args: any[]) => any>(\n  func: T,\n  wait: number\n): (...args: Parameters<T>) => void {\n  let timeout: ReturnType<typeof setTimeout> | null = null;\n  return (...args: Parameters<T>) => {\n    if (timeout) clearTimeout(timeout);\n    timeout = setTimeout(() => func(...args), wait);\n  };\n}\n\n/**\n * 节流函数\n */\nexport function throttle<T extends (...args: any[]) => any>(\n  func: T,\n  limit: number\n): (...args: Parameters<T>) => void {\n  let inThrottle: boolean;\n  return (...args: Parameters<T>) => {\n    if (!inThrottle) {\n      func(...args);\n      inThrottle = true;\n      setTimeout(() => (inThrottle = false), limit);\n    }\n  };\n}\n\n/**\n * 下载文件\n */\nexport function downloadFile(blob: Blob, filename: string) {\n  const url = window.URL.createObjectURL(blob);\n  const link = document.createElement('a');\n  link.href = url;\n  link.download = filename;\n  document.body.appendChild(link);\n  link.click();\n  document.body.removeChild(link);\n  window.URL.revokeObjectURL(url);\n}\n\n/**\n * 格式化日期\n */\nexport function formatDate(dateString: string): string {\n  const date = new Date(dateString);\n  const lang = localStorage.getItem('i18nextLng') || navigator.language || 'zh-CN';\n  const locale = lang.startsWith('zh') ? 'zh-CN' : 'en-US';\n  return date.toLocaleString(locale, {\n    year: 'numeric',\n    month: '2-digit',\n    day: '2-digit',\n    hour: '2-digit',\n    minute: '2-digit',\n  });\n}\n\n/**\n * 生成唯一ID\n */\nexport function generateId(): string {\n  return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;\n}\n\n/**\n * 将错误消息转换为友好的中英文提示\n */\nexport function normalizeErrorMessage(errorMessage: string | null | undefined): string {\n  const lang = localStorage.getItem('i18nextLng') || navigator.language || 'zh';\n  const isZh = lang.startsWith('zh');\n\n  if (!errorMessage) return isZh ? '操作失败' : 'Operation failed';\n\n  const message = errorMessage.toLowerCase();\n\n  // Handle specific error messages\n  if (message.includes('no template image found')) {\n    return isZh\n      ? '当前项目还没有模板，请先点击页面工具栏的\"更换模板\"按钮，选择或上传一张模板图片后再生成。'\n      : 'No template found. Please select or upload a template image first.';\n  } else if (message.includes('page must have description content')) {\n    return isZh\n      ? '该页面还没有描述内容，请先在\"编辑页面描述\"步骤为此页生成或填写描述。'\n      : 'This page has no description. Please generate or write a description first.';\n  } else if (message.includes('image already exists')) {\n    return isZh\n      ? '该页面已经有图片，如需重新生成，请在生成时选择\"重新生成\"或稍后重试。'\n      : 'Image already exists. Choose \"Regenerate\" to create a new one.';\n  }\n\n  // Handle HTTP error codes\n  if (message.includes('503') || message.includes('service unavailable')) {\n    return isZh ? 'AI 服务暂时不可用，请稍后重试。如果问题持续，请检查设置页的 API 配置。' : 'AI service temporarily unavailable. Please try again later.';\n  } else if (message.includes('500') || message.includes('internal server error')) {\n    return isZh ? '服务器内部错误，请稍后重试。' : 'Internal server error. Please try again later.';\n  } else if (message.includes('502') || message.includes('bad gateway')) {\n    return isZh ? '网关错误，请稍后重试。' : 'Bad gateway. Please try again later.';\n  } else if (message.includes('504') || message.includes('gateway timeout')) {\n    return isZh ? '请求超时，请稍后重试。' : 'Gateway timeout. Please try again later.';\n  } else if (message.includes('429') || message.includes('too many requests')) {\n    return isZh ? '请求过于频繁，请稍后重试。' : 'Too many requests. Please try again later.';\n  } else if (message.includes('401') || message.includes('unauthorized')) {\n    return isZh ? '认证失败，请检查 API 密钥配置。' : 'Authentication failed. Please check API key settings.';\n  } else if (message.includes('403') || message.includes('forbidden')) {\n    return isZh ? '访问被拒绝，请检查 API 权限配置。' : 'Access denied. Please check API permissions.';\n  } else if (message.includes('aspect_ratio') || message.includes('aspect ratio')) {\n    return isZh\n      ? '当前画面比例不被该模型支持，请在项目设置中尝试其他画面比例后重试。'\n      : 'The selected aspect ratio is not supported by this model. Please try a different ratio in project settings.';\n  } else if (message.includes('network error') || message.includes('econnrefused')) {\n    return isZh ? '网络连接失败，请检查网络或后端服务是否正常运行。' : 'Network error. Please check your connection.';\n  } else if (message.includes('timeout')) {\n    return isZh ? '请求超时，请稍后重试。' : 'Request timed out. Please try again later.';\n  } else if (message.includes('样式提取失败') || message.includes('style extraction failed')) {\n    if (message.includes('不支持图片输入') || message.includes('support image input')) {\n      return isZh\n        ? '可编辑 PPTX 导出失败：当前图片样式提取模型不支持图片输入。请在设置中改用支持视觉输入的 image caption 模型，或切换 provider 后重试。'\n        : 'Editable PPTX export failed: the current style extraction model does not support image input. Switch to a vision-capable image caption model and try again.';\n    }\n    return isZh\n      ? '可编辑 PPTX 导出失败：文本样式提取没有成功完成。请检查 image caption 模型/API 配置，或在项目设置中开启“允许返回半成品”后重试。'\n      : 'Editable PPTX export failed because text style extraction did not complete. Check the image caption model/API settings, or enable partial results and try again.';\n  }\n\n  return errorMessage;\n}\n"
  },
  {
    "path": "frontend/src/utils/logger.ts",
    "content": "/** console.log that only fires in dev mode (import.meta.env.DEV) */\nexport const devLog: typeof console.log = import.meta.env.DEV\n  ? console.log.bind(console)\n  : () => {};\n"
  },
  {
    "path": "frontend/src/utils/projectUtils.ts",
    "content": "import { getImageUrl } from '@/api/client';\nimport type { Project, Page, DescriptionContent } from '@/types';\nimport { downloadFile } from './index';\nimport { getT } from './i18nHelper';\nimport i18n from '@/i18n';\n\nconst utilsI18n = {\n  zh: {\n    projectUtils: {\n      untitled: '未命名项目',\n      notStarted: '未开始',\n      completed: '已完成',\n      pendingImages: '待生成图片',\n      pendingDesc: '待生成描述',\n      pageNum: '第 {{num}} 页',\n      pageHeading: '## 第 {{num}} 页: {{title}}',\n      chapter: '章节',\n      outlinePoints: '**大纲要点：**',\n      noPoints: '*暂无要点*',\n      pageDesc: '**页面描述：**',\n      noDesc: '*暂无描述*',\n      generatedAt: '生成时间',\n      prefixDesc: '描述',\n      prefixOutline: '大纲',\n      prefixProject: '项目',\n    }\n  },\n  en: {\n    projectUtils: {\n      untitled: 'Untitled Project',\n      notStarted: 'Not Started',\n      completed: 'Completed',\n      pendingImages: 'Pending Images',\n      pendingDesc: 'Pending Descriptions',\n      pageNum: 'Page {{num}}',\n      pageHeading: '## Page {{num}}: {{title}}',\n      chapter: 'Chapter',\n      outlinePoints: '**Outline Points:**',\n      noPoints: '*No points yet*',\n      pageDesc: '**Page Description:**',\n      noDesc: '*No description yet*',\n      generatedAt: 'Generated at',\n      prefixDesc: 'Descriptions',\n      prefixOutline: 'Outline',\n      prefixProject: 'Project',\n    }\n  }\n};\nconst t = getT(utilsI18n);\n\n/**\n * 获取项目标题\n */\nexport const getProjectTitle = (project: Project): string => {\n  // 从第一个页面的大纲标题获取项目名称\n  if (project.pages && project.pages.length > 0) {\n    const sortedPages = [...project.pages].sort((a, b) =>\n      (a.order_index || 0) - (b.order_index || 0)\n    );\n    const firstPage = sortedPages[0];\n\n    const title = firstPage?.outline_content?.title;\n    if (title) {\n      return title;\n    }\n  }\n\n  return t('projectUtils.untitled');\n};\n\n/**\n * 获取第一页图片URL\n */\nexport const getFirstPageImage = (project: Project): string | null => {\n  if (!project.pages || project.pages.length === 0) {\n    return null;\n  }\n\n  // 找到第一页有图片的页面，优先使用 generated_image_url（已包含缩略图逻辑）\n  const firstPageWithImage = project.pages.find(p => p.generated_image_url);\n  if (firstPageWithImage?.generated_image_url) {\n    return getImageUrl(firstPageWithImage.generated_image_url, firstPageWithImage.updated_at);\n  }\n\n  return null;\n};\n\n/**\n * 格式化日期\n */\nexport const formatDate = (dateString: string): string => {\n  const date = new Date(dateString);\n  const locale = i18n.language?.startsWith('zh') ? 'zh-CN' : 'en-US';\n  return date.toLocaleString(locale, {\n    year: 'numeric',\n    month: '2-digit',\n    day: '2-digit',\n    hour: '2-digit',\n    minute: '2-digit',\n  });\n};\n\ntype StatusKey = 'notStarted' | 'completed' | 'pendingImages' | 'pendingDesc';\n\nconst getStatusKey = (project: Project): StatusKey => {\n  if (!project.pages || project.pages.length === 0) return 'notStarted';\n  if (project.pages.some(p => p.generated_image_path)) return 'completed';\n  if (project.pages.some(p => p.description_content)) return 'pendingImages';\n  return 'pendingDesc';\n};\n\n/**\n * 获取项目状态文本\n */\nexport const getStatusText = (project: Project): string => {\n  return t(`projectUtils.${getStatusKey(project)}`);\n};\n\nconst statusColorMap: Record<StatusKey, string> = {\n  completed: 'text-green-600 bg-green-50',\n  pendingImages: 'text-yellow-600 bg-yellow-50',\n  pendingDesc: 'text-blue-600 bg-blue-50',\n  notStarted: 'text-gray-600 bg-gray-50',\n};\n\n/**\n * 获取项目状态颜色样式\n */\nexport const getStatusColor = (project: Project): string => {\n  return statusColorMap[getStatusKey(project)];\n};\n\n/**\n * 获取项目路由路径\n */\nexport const getProjectRoute = (project: Project): string => {\n  const projectId = project.id || project.project_id;\n  if (!projectId) return '/';\n  \n  if (project.pages && project.pages.length > 0) {\n    const hasImages = project.pages.some(p => p.generated_image_path);\n    if (hasImages) {\n      return `/project/${projectId}/preview`;\n    }\n    const hasDescriptions = project.pages.some(p => p.description_content);\n    if (hasDescriptions) {\n      return `/project/${projectId}/detail`;\n    }\n    return `/project/${projectId}/outline`;\n  }\n  return `/project/${projectId}/outline`;\n};\n\n// ========== Markdown 导出/导入 ==========\n\nexport const getDescriptionText = (descContent: DescriptionContent | undefined | null): string => {\n  if (!descContent) return '';\n  if ('text' in descContent) return (descContent.text as string) || '';\n  if ('text_content' in descContent && Array.isArray(descContent.text_content)) return descContent.text_content.join('\\n');\n  return '';\n};\n\nconst getExtraFields = (descContent: DescriptionContent | undefined | null): Record<string, string> | undefined => {\n  if (!descContent) return undefined;\n  // New format\n  if (descContent.extra_fields && typeof descContent.extra_fields === 'object') {\n    return descContent.extra_fields;\n  }\n  // Backward compat\n  if (descContent.layout_suggestion) {\n    return { '排版建议': descContent.layout_suggestion };\n  }\n  return undefined;\n};\n\nexport interface ExportOptions {\n  outline?: boolean;\n  description?: boolean;\n}\n\nconst pageToMarkdown = (page: Page, index: number, opts: ExportOptions = {}): string => {\n  const includeOutline = opts.outline !== false;\n  const includeDesc = opts.description !== false;\n  const title = page.outline_content?.title || t('projectUtils.pageNum', { num: index + 1 });\n  const points = page.outline_content?.points || [];\n  const descText = getDescriptionText(page.description_content);\n  const extraFields = getExtraFields(page.description_content);\n\n  let md = t('projectUtils.pageHeading', { num: index + 1, title }) + '\\n\\n';\n  if (page.part) md += `> ${t('projectUtils.chapter')}: ${page.part}\\n\\n`;\n\n  if (includeOutline) {\n    md += `${t('projectUtils.outlinePoints')}\\n`;\n    if (points.length > 0) {\n      points.forEach(p => { md += `- ${p}\\n`; });\n    } else {\n      md += `${t('projectUtils.noPoints')}\\n`;\n    }\n    md += '\\n';\n  }\n\n  if (includeDesc) {\n    md += `${t('projectUtils.pageDesc')}\\n`;\n    if (descText) {\n      md += `${descText}\\n`;\n    } else {\n      md += `${t('projectUtils.noDesc')}\\n`;\n    }\n    // 额外字段\n    if (extraFields) {\n      md += '\\n';\n      for (const [name, value] of Object.entries(extraFields)) {\n        if (value) md += `${name}：${value}\\n`;\n      }\n    }\n    md += '\\n';\n  }\n\n  md += '---\\n\\n';\n  return md;\n};\n\nexport const exportProjectToMarkdown = (project: Project, opts?: ExportOptions): void => {\n  const locale = i18n.language?.startsWith('zh') ? 'zh-CN' : 'en-US';\n  let md = `# ${getProjectTitle(project)}\\n\\n`;\n  md += `> ${t('projectUtils.generatedAt')}: ${new Date().toLocaleString(locale)}\\n\\n---\\n\\n`;\n  project.pages.forEach((page, i) => { md += pageToMarkdown(page, i, opts); });\n  const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });\n  const prefix = opts?.outline === false\n    ? t('projectUtils.prefixDesc')\n    : opts?.description === false\n      ? t('projectUtils.prefixOutline')\n      : t('projectUtils.prefixProject');\n  downloadFile(blob, `${prefix}_${project.id?.slice(0, 8) || 'export'}.md`);\n};\n\n// --- 导入 ---\n\nexport interface ParsedPage {\n  title: string;\n  points: string[];\n  text: string;\n  part?: string;\n  extra_fields?: Record<string, string>;\n}\n\nconst sanitize = (s: string) => s.replace(/<[^>]*>/g, '');\n\nconst splitMarkdownPages = (markdown: string): string[] => {\n  // Support both Chinese \"## 第 N 页:\" and English \"## Page N:\" formats\n  return markdown.split(/^## (?:第 \\d+ 页|Page \\d+):/m).slice(1);\n};\n\n// 额外字段行模式：短名称 + 中/英冒号 + 内容\nconst EXTRA_FIELD_RE = /^([^\\s：:]{1,20})[：:](.+)/;\n\nconst splitDescAndExtraFields = (descLines: string[]): { text: string; extra_fields?: Record<string, string> } => {\n  // 从末尾向前扫描连续的额外字段行\n  const fields: Record<string, string> = {};\n  let i = descLines.length - 1;\n  while (i >= 0) {\n    const m = EXTRA_FIELD_RE.exec(descLines[i].trim());\n    if (m) {\n      fields[m[1]] = m[2].trim();\n      i--;\n    } else if (descLines[i].trim() === '') {\n      i--; // 跳过空行\n    } else {\n      break;\n    }\n  }\n  const text = descLines.slice(0, i + 1).join('\\n').trim();\n  return Object.keys(fields).length > 0 ? { text, extra_fields: fields } : { text };\n};\n\nexport const parseMarkdownPages = (markdown: string): ParsedPage[] => {\n  return splitMarkdownPages(markdown).map(section => {\n    const lines = section.split('\\n');\n    const title = sanitize(lines[0].trim());\n\n    // Extract metadata (support both Chinese and English)\n    const partLine = lines.find(l => l.startsWith('> 章节: ') || l.startsWith('> Chapter: '));\n    const part = partLine ? sanitize(partLine.replace(/^> (?:章节|Chapter): /, '').trim()) : undefined;\n\n    // Find section markers (support both Chinese and English)\n    const outlineIdx = lines.findIndex(l => l.trim() === '**大纲要点：**' || l.trim() === '**Outline Points:**');\n    const descIdx = lines.findIndex(l => l.trim() === '**页面描述：**' || l.trim() === '**Page Description:**');\n\n    let points: string[] = [];\n    let text = '';\n    let extra_fields: Record<string, string> | undefined;\n\n    const trailingPatterns = new Set(['---', '', '*暂无要点*', '*暂无描述*', '*No points yet*', '*No description yet*']);\n    const stripTrailing = (arr: string[]) => {\n      while (arr.length && trailingPatterns.has(arr[arr.length - 1].trim())) arr.pop();\n    };\n\n    if (outlineIdx >= 0) {\n      const end = descIdx >= 0 ? descIdx : lines.length;\n      points = lines.slice(outlineIdx + 1, end)\n        .filter(l => l.startsWith('- '))\n        .map(l => sanitize(l.slice(2).trim()));\n    }\n\n    if (descIdx >= 0) {\n      const descLines = lines.slice(descIdx + 1);\n      stripTrailing(descLines);\n      const parsed = splitDescAndExtraFields(descLines);\n      text = sanitize(parsed.text);\n      extra_fields = parsed.extra_fields;\n    }\n\n    if (outlineIdx < 0 && descIdx < 0) {\n      // Legacy format: no markers\n      const contentLines = lines.slice(1);\n      while (contentLines.length && (contentLines[0].startsWith('> ') || contentLines[0].trim() === '')) contentLines.shift();\n      stripTrailing(contentLines);\n      points = contentLines.filter(l => l.startsWith('- ')).map(l => sanitize(l.slice(2).trim()));\n      text = sanitize(contentLines.filter(l => !l.startsWith('- ')).join('\\n').trim());\n    }\n\n    return { title, points, text, part, extra_fields };\n  });\n};\n"
  },
  {
    "path": "frontend/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n\ninterface ImportMetaEnv {\n}\n\ninterface ImportMeta {\n  readonly env: ImportMetaEnv\n}\n\n"
  },
  {
    "path": "frontend/start.bat",
    "content": "@echo off\necho ====================================\necho 蕉幻 (Banana Slides) 前端启动脚本\necho ====================================\necho.\n\necho [1/3] 检查依赖...\nif not exist \"node_modules\\\" (\n    echo 未检测到依赖，正在安装...\n    call npm install\n    if errorlevel 1 (\n        echo 依赖安装失败，请手动运行: npm install\n        pause\n        exit /b 1\n    )\n) else (\n    echo 依赖已存在\n)\n\necho.\necho [2/3] 检查环境变量...\necho.\necho [3/3] 启动开发服务器...\necho 前端将运行在 http://localhost:3000\necho 请确保后端服务已启动在 http://localhost:5000\necho.\necho 按 Ctrl+C 可以停止服务器\necho.\n\ncall npm run dev\n\npause\n\n"
  },
  {
    "path": "frontend/start.sh",
    "content": "#!/bin/bash\n\necho \"====================================\"\necho \"蕉幻 (Banana Slides) 前端启动脚本\"\necho \"====================================\"\necho \"\"\n\necho \"[1/3] 检查依赖...\"\nif [ ! -d \"node_modules\" ]; then\n    echo \"未检测到依赖，正在安装...\"\n    npm install\n    if [ $? -ne 0 ]; then\n        echo \"依赖安装失败，请手动运行: npm install\"\n        exit 1\n    fi\nelse\n    echo \"依赖已存在\"\nfi\n\necho \"\"\necho \"[2/3] 检查环境变量...\"\necho \"\"\necho \"[3/3] 启动开发服务器...\"\necho \"前端将运行在 http://localhost:3000\"\necho \"请确保后端服务已启动在 http://localhost:5000\"\necho \"\"\necho \"按 Ctrl+C 可以停止服务器\"\necho \"\"\n\nnpm run dev\n\n"
  },
  {
    "path": "frontend/tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\nexport default {\n  darkMode: 'class',\n  content: [\n    \"./index.html\",\n    \"./src/**/*.{js,ts,jsx,tsx}\",\n  ],\n  theme: {\n    extend: {\n      colors: {\n        // 品牌色 - 使用 CSS 变量\n        'banana': {\n          DEFAULT: 'var(--banana-yellow)',\n          light: 'var(--banana-yellow-light)',\n          dark: 'var(--banana-yellow-dark)',\n          pale: 'var(--banana-yellow-pale)',\n          // 保留静态色用于渐变等特殊场景\n          50: '#FFF9E6',\n          100: '#FFE44D',\n          200: '#FFD93D',\n          300: '#FFD21F',\n          400: '#FFCA00',\n          500: '#FFD700',\n          600: '#FFC700',\n        },\n        // 背景色 - 语义化 token\n        'background': {\n          primary: 'var(--bg-primary)',\n          secondary: 'var(--bg-secondary)',\n          tertiary: 'var(--bg-tertiary)',\n          elevated: 'var(--bg-elevated)',\n          hover: 'var(--bg-hover)',\n        },\n        // 文字色 - 语义化 token\n        'foreground': {\n          DEFAULT: 'var(--text-primary)',\n          primary: 'var(--text-primary)',\n          secondary: 'var(--text-secondary)',\n          tertiary: 'var(--text-tertiary)',\n        },\n        // 边框色 - 语义化 token\n        'border': {\n          DEFAULT: 'var(--border-primary)',\n          primary: 'var(--border-primary)',\n          secondary: 'var(--border-secondary)',\n          hover: 'var(--border-hover)',\n        },\n        // 功能色\n        'success': 'var(--success)',\n        'warning': 'var(--warning)',\n        'error': 'var(--error)',\n        'info': 'var(--info)',\n      },\n      borderRadius: {\n        'card': '12px',\n        'panel': '16px',\n      },\n      boxShadow: {\n        'yellow': '0 4px 12px rgba(255, 215, 0, 0.3)',\n        'sm': '0 1px 2px rgba(0,0,0,0.05)',\n        'md': '0 4px 6px rgba(0,0,0,0.07)',\n        'lg': '0 10px 15px rgba(0,0,0,0.1)',\n        'xl': '0 20px 25px rgba(0,0,0,0.15)',\n        '2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.15)',\n        '3xl': '0 35px 60px -12px rgba(0, 0, 0, 0.2)',\n      },\n      animation: {\n        'pulse': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',\n        'shimmer': 'shimmer 1.5s infinite',\n        'gradient': 'gradient 3s ease infinite',\n        'gradient-x': 'gradient-x 2s ease infinite',\n        'float': 'float 6s ease-in-out infinite',\n        'float-slow': 'float 8s ease-in-out infinite',\n        'float-delayed': 'float 7s ease-in-out infinite 1s',\n        'fade-in-up': 'fadeInUp 0.8s ease-out forwards',\n        'fade-in': 'fadeIn 1s ease-out forwards',\n        'slide-in-up': 'slideInUp 0.35s ease-out both',\n      },\n      keyframes: {\n        slideInUp: {\n          '0%': { opacity: '0', transform: 'translateY(16px)' },\n          '100%': { opacity: '1', transform: 'translateY(0)' },\n        },\n        float: {\n          '0%, 100%': { transform: 'translateY(0)' },\n          '50%': { transform: 'translateY(-20px)' },\n        },\n        fadeInUp: {\n          '0%': { opacity: '0', transform: 'translateY(20px)' },\n          '100%': { opacity: '1', transform: 'translateY(0)' },\n        },\n        fadeIn: {\n          '0%': { opacity: '0' },\n          '100%': { opacity: '1' },\n        },\n        pulse: {\n          '0%, 100%': { opacity: '1' },\n          '50%': { opacity: '0.6' },\n        },\n        shimmer: {\n          '0%': { backgroundPosition: '-200% 0' },\n          '100%': { backgroundPosition: '200% 0' },\n        },\n        gradient: {\n          '0%, 100%': { backgroundPosition: '0% 50%' },\n          '50%': { backgroundPosition: '100% 50%' },\n        },\n        'gradient-x': {\n          '0%, 100%': { backgroundPosition: '0% 0%' },\n          '50%': { backgroundPosition: '100% 0%' },\n        },\n      },\n    },\n  },\n  plugins: [],\n}\n"
  },
  {
    "path": "frontend/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n\n    /* Path mapping */\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\"src\"],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }]\n}\n\n"
  },
  {
    "path": "frontend/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowSyntheticDefaultImports\": true,\n    \"types\": [\"node\"]\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "frontend/vite.config.ts",
    "content": "/// <reference types=\"vitest\" />\nimport { defineConfig, loadEnv } from 'vite'\nimport react from '@vitejs/plugin-react'\nimport path from 'node:path'\nimport crypto from 'node:crypto'\nimport { fileURLToPath } from 'node:url'\n\nconst __filename = fileURLToPath(import.meta.url)\nconst __dirname = path.dirname(__filename)\n\n/**\n * Compute a deterministic port from the worktree directory name.\n * Must match the algorithm in backend/app.py `_compute_worktree_port`.\n */\nfunction computeWorktreePort(basePort: number): number {\n  const basename = path.basename(path.resolve(__dirname, '..'))\n  const hashHex = crypto.createHash('md5').update(basename).digest('hex').substring(0, 8)\n  const offset = parseInt(hashHex, 16) % 500\n  return basePort + offset\n}\n\n// https://vitejs.dev/config/\nexport default defineConfig(({ mode }) => {\n  // 从项目根目录读取 .env 文件（相对于 frontend 目录的上一级）\n  const envDir = path.resolve(__dirname, '..')\n\n  // 使用 loadEnv 加载环境变量（第三个参数为空字符串表示加载所有变量，不仅仅是 VITE_ 前缀的）\n  const env = loadEnv(mode, envDir, '')\n\n  // 端口：优先读 env，否则按 worktree 目录名自动计算\n  const backendPort = env.BACKEND_PORT || String(computeWorktreePort(5000))\n  const frontendPort = Number(env.FRONTEND_PORT) || computeWorktreePort(3000)\n  const backendUrl = `http://localhost:${backendPort}`\n  \n  return {\n    envDir,\n    plugins: [react()],\n    resolve: {\n      alias: {\n        '@': path.resolve(__dirname, './src'),\n      },\n    },\n    server: {\n      port: frontendPort,\n      host: true, // 监听所有地址\n      watch: {\n        usePolling: true, // WSL 环境下需要启用轮询\n      },\n      hmr: {\n        overlay: true, // 显示错误覆盖层\n      },\n      proxy: {\n        // API 请求代理到后端（端口从环境变量 BACKEND_PORT 读取）\n        '/api': {\n          target: backendUrl,\n          changeOrigin: true,\n        },\n        // 文件服务代理到后端\n        '/files': {\n          target: backendUrl,\n          changeOrigin: true,\n        },\n        // 健康检查代理到后端\n        '/health': {\n          target: backendUrl,\n          changeOrigin: true,\n        },\n      },\n    },\n    // Vitest 测试配置\n    test: {\n      globals: true,\n      environment: 'jsdom',\n      setupFiles: './src/tests/setup.ts',\n      include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],\n      exclude: ['node_modules', 'dist'],\n      coverage: {\n        provider: 'v8',\n        reporter: ['text', 'json', 'html'],\n        exclude: [\n          'node_modules/',\n          'src/tests/',\n          '**/*.d.ts',\n          '**/*.config.*',\n        ],\n      },\n    },\n  }\n})\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"banana-slides\",\n  \"version\": \"0.3.0\",\n  \"description\": \"AI-powered PPT generation application\",\n  \"private\": true,\n  \"scripts\": {\n    \"setup:test\": \"uv sync --extra test && cd frontend && npm install\",\n    \"setup:hooks\": \"chmod +x scripts/setup_git_hooks.sh && ./scripts/setup_git_hooks.sh\",\n    \"test\": \"npm run test:backend && npm run test:frontend\",\n    \"test:backend\": \"uv run pytest backend/tests/ -v\",\n    \"test:frontend\": \"cd frontend && npm test -- --run\",\n    \"test:e2e\": \"cd frontend && npm run test:e2e\",\n    \"test:e2e:ui\": \"cd frontend && npm run test:e2e:ui\",\n    \"test:docker\": \"chmod +x scripts/test_docker_environment.sh && ./scripts/test_docker_environment.sh\",\n    \"test:all\": \"npm run test && npm run test:docker && npm run test:e2e\",\n    \"lint\": \"npm run lint:backend && npm run lint:frontend\",\n    \"lint:backend\": \"uv run flake8 backend/ --count --select=E9,F63,F7,F82 --show-source --statistics\",\n    \"lint:frontend\": \"cd frontend && npm run lint\",\n    \"dev\": \"docker compose up\",\n    \"dev:backend\": \"cd backend && uv run python app.py\",\n    \"dev:frontend\": \"cd frontend && npm run dev\",\n    \"build\": \"docker compose build\",\n    \"start\": \"docker compose up -d\",\n    \"stop\": \"docker compose down\",\n    \"quick-check\": \"npm run lint && npm run test:frontend && npm run test:backend\"\n  },\n  \"engines\": {\n    \"node\": \">=18.0.0\"\n  }\n}\n\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"banana-slides\"\nversion = \"0.3.0\"\ndescription = \"Banana Slides – AI native PPT generator\"\nreadme = \"README.md\"\nrequires-python = \">=3.10\"\ndependencies = [\n    \"flask>=3.0.0\",\n    \"flask-cors>=4.0.0\",\n    \"flask-sqlalchemy>=3.1.1\",\n    \"google-genai>=1.52.0\",\n    \"openai>=1.0.0\",\n    \"pydantic>=2.9.0\",\n    \"pillow>=12.0.0\",\n    \"python-pptx>=1.0.0\",\n    \"python-dotenv>=1.0.1\",\n    \"reportlab>=4.1.0\",\n    \"werkzeug>=3.0.1\",\n    \"markitdown[all]\",\n    \"tenacity>=9.0.0\",\n    \"alembic>=1.13.0\",\n    \"flask-migrate>=4.0.0\",\n    \"img2pdf>=0.5.1\",\n    \"lazyllm[online-advanced]>=0.7.3\",\n    \"volcengine-python-sdk[ark]>=5.0.9\",\n    \"PyPDF2>=3.0.0\",\n    \"PyMuPDF>=1.24.0\",\n]\n\n[project.optional-dependencies]\ntest = [\n    \"pytest>=7.4.0\",\n    \"pytest-mock>=3.12.0\",\n    \"pytest-cov>=4.1.0\",\n    \"pytest-asyncio>=0.21.0\",\n    \"httpx>=0.25.0\",\n    \"flake8>=6.1.0\",\n    \"black>=23.0.0\",\n]\n\n[tool.uv]\nindex-url = \"https://pypi.tuna.tsinghua.edu.cn/simple\"\n\n[tool.pytest.ini_options]\ntestpaths = [\"backend/tests\"]\npython_files = [\"test_*.py\"]\npython_functions = [\"test_*\"]\naddopts = \"-v --tb=short\"\n"
  },
  {
    "path": "scripts/export_editable_pptx.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\n可编辑 PPTX 导出脚本\n\n此脚本用于从指定的图片生成可编辑的 PPTX 文件。\n支持单张图片或多张图片批量处理。\n\n使用方法:\n    # 处理单张图片\n    python scripts/export_editable_pptx.py path/to/image.png\n    \n    # 处理多张图片\n    python scripts/export_editable_pptx.py img1.png img2.png img3.png\n    \n    # 处理目录中的所有图片\n    python scripts/export_editable_pptx.py path/to/images/\n    \n    # 指定输出文件\n    python scripts/export_editable_pptx.py image.png -o output.pptx\n    \n    # 使用不同的提取方法\n    python scripts/export_editable_pptx.py image.png --extractor mineru\n    python scripts/export_editable_pptx.py image.png --extractor hybrid\n    \n    # 使用不同的背景修复方法\n    python scripts/export_editable_pptx.py image.png --inpaint baidu\n    python scripts/export_editable_pptx.py image.png --inpaint generative\n    python scripts/export_editable_pptx.py image.png --inpaint hybrid\n\n环境要求:\n    需要配置 .env 文件，包含以下变量：\n    - MINERU_TOKEN: MinerU API token\n    - BAIDU_API_KEY, BAIDU_SECRET_KEY: 百度 API 密钥（用于 baidu/hybrid 方法）\n    - GEMINI_API_KEY 或 OPENAI_API_KEY: 用于 generative/hybrid 方法\n\n成本提示:\n    - 'generative' 和 'hybrid' 背景修复方法会调用文生图模型 API，产生额外费用\n    - 'baidu' 方法使用百度图像修复 API，费用较低\n    - 'mineru' 和 'hybrid' 提取方法都使用 MinerU API\n\"\"\"\n\nimport os\nimport sys\nimport argparse\nimport logging\nfrom pathlib import Path\nfrom typing import List, Optional\n\n# 添加项目根目录到 Python 路径\nSCRIPT_DIR = Path(__file__).resolve().parent\nPROJECT_ROOT = SCRIPT_DIR.parent\nBACKEND_DIR = PROJECT_ROOT / 'backend'\nsys.path.insert(0, str(BACKEND_DIR))\n\n# 设置日志\nlogging.basicConfig(\n    level=logging.INFO,\n    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'\n)\nlogger = logging.getLogger(__name__)\n\n\ndef setup_flask_app():\n    \"\"\"初始化 Flask 应用上下文（用于加载配置）\"\"\"\n    from dotenv import load_dotenv\n    \n    # 加载 .env 文件\n    env_path = PROJECT_ROOT / '.env'\n    if env_path.exists():\n        load_dotenv(env_path)\n        logger.info(f\"已加载环境变量: {env_path}\")\n    \n    # 创建 Flask 应用\n    from app import create_app\n    app = create_app()\n    return app\n\n\ndef collect_image_paths(paths: List[str]) -> List[str]:\n    \"\"\"收集所有要处理的图片路径\"\"\"\n    image_extensions = {'.png', '.jpg', '.jpeg', '.webp', '.bmp'}\n    result = []\n    \n    for path_str in paths:\n        path = Path(path_str)\n        \n        if path.is_file():\n            if path.suffix.lower() in image_extensions:\n                result.append(str(path.resolve()))\n            else:\n                logger.warning(f\"跳过非图片文件: {path}\")\n        elif path.is_dir():\n            for file in sorted(path.iterdir()):\n                if file.suffix.lower() in image_extensions:\n                    result.append(str(file.resolve()))\n        else:\n            logger.warning(f\"路径不存在: {path}\")\n    \n    return result\n\n\ndef create_service_config(\n    extractor_method: str = 'hybrid',\n    inpaint_method: str = 'hybrid'\n):\n    \"\"\"\n    创建服务配置\n    \n    Args:\n        extractor_method: 提取方法 ('mineru' 或 'hybrid')\n        inpaint_method: 背景修复方法 ('generative', 'baidu', 'hybrid')\n    \"\"\"\n    from services.image_editability import ServiceConfig\n    \n    # 根据方法选择配置\n    use_hybrid_extractor = (extractor_method == 'hybrid')\n    use_hybrid_inpaint = (inpaint_method == 'hybrid')\n    \n    logger.info(f\"配置: 提取方法={extractor_method}, 背景修复={inpaint_method}\")\n    \n    config = ServiceConfig.from_defaults(\n        use_hybrid_extractor=use_hybrid_extractor,\n        use_hybrid_inpaint=use_hybrid_inpaint,\n        max_depth=1  # 递归深度\n    )\n    \n    # 如果指定了非 hybrid 的 inpaint 方法，需要手动配置\n    if inpaint_method != 'hybrid':\n        from services.image_editability import (\n            InpaintProviderFactory,\n            InpaintProviderRegistry\n        )\n        \n        inpaint_registry = InpaintProviderRegistry()\n        \n        if inpaint_method == 'generative':\n            provider = InpaintProviderFactory.create_generative_edit_provider()\n            inpaint_registry.register_default(provider)\n            logger.info(\"使用生成式修复方法（会调用文生图模型 API）\")\n        elif inpaint_method == 'baidu':\n            provider = InpaintProviderFactory.create_baidu_inpaint_provider()\n            if provider:\n                inpaint_registry.register_default(provider)\n                logger.info(\"使用百度图像修复方法\")\n            else:\n                logger.warning(\"百度修复不可用，回退到生成式方法\")\n                provider = InpaintProviderFactory.create_generative_edit_provider()\n                inpaint_registry.register_default(provider)\n        \n        config.inpaint_registry = inpaint_registry\n    \n    return config\n\n\ndef export_editable_pptx(\n    image_paths: List[str],\n    output_file: str,\n    extractor_method: str = 'hybrid',\n    inpaint_method: str = 'hybrid',\n    extract_text_styles: bool = True\n):\n    \"\"\"\n    导出可编辑 PPTX\n    \n    Args:\n        image_paths: 图片路径列表\n        output_file: 输出文件路径\n        extractor_method: 提取方法\n        inpaint_method: 背景修复方法\n        extract_text_styles: 是否提取文字样式（颜色、粗体等）\n    \"\"\"\n    from services.image_editability import ImageEditabilityService\n    from services.export_service import ExportService\n    from concurrent.futures import ThreadPoolExecutor, as_completed\n    \n    logger.info(f\"开始处理 {len(image_paths)} 张图片...\")\n    \n    # 创建配置和服务\n    config = create_service_config(extractor_method, inpaint_method)\n    service = ImageEditabilityService(config)\n    \n    # 并行分析所有图片\n    logger.info(\"步骤 1/3: 分析图片结构...\")\n    editable_images = []\n    \n    with ThreadPoolExecutor(max_workers=4) as executor:\n        futures = {\n            executor.submit(service.make_image_editable, path): idx\n            for idx, path in enumerate(image_paths)\n        }\n        \n        results = [None] * len(image_paths)\n        for future in as_completed(futures):\n            idx = futures[future]\n            try:\n                results[idx] = future.result()\n                logger.info(f\"  完成: {image_paths[idx]}\")\n            except Exception as e:\n                logger.error(f\"  失败: {image_paths[idx]} - {e}\")\n                raise\n        \n        editable_images = results\n    \n    # 创建文字属性提取器（可选）\n    text_attribute_extractor = None\n    if extract_text_styles:\n        logger.info(\"步骤 2/3: 提取文字样式...\")\n        try:\n            from services.image_editability import TextAttributeExtractorFactory\n            text_attribute_extractor = TextAttributeExtractorFactory.create_caption_model_extractor()\n            logger.info(\"  文字样式提取器已创建（会调用视觉语言模型 API）\")\n        except Exception as e:\n            logger.warning(f\"  无法创建文字样式提取器: {e}\")\n    else:\n        logger.info(\"步骤 2/3: 跳过文字样式提取\")\n    \n    # 生成 PPTX\n    logger.info(\"步骤 3/3: 生成可编辑 PPTX...\")\n    \n    def progress_callback(step, message, percent):\n        logger.info(f\"  [{percent}%] {step}: {message}\")\n    \n    # 如果output_file已经存在，给一个后缀防止冲突\n    if os.path.exists(output_file):\n        output_file = output_file.rsplit('.', 1)[0] + '_1.pptx'\n        logger.warning(f\"输出文件已存在，给一个后缀防止冲突: {output_file}\")\n    \n    # 根据实际图片尺寸动态设置幻灯片尺寸\n    # 统一到最小尺寸，并检查所有图片是否为16:9比例\n    if editable_images:\n        # 16:9 比例的标准值\n        ASPECT_RATIO_16_9 = 16 / 9  # ≈ 1.7778\n        ASPECT_RATIO_TOLERANCE = 0.02  # 允许2%的误差\n        \n        # 检查所有图片是否为16:9比例，并找到最小尺寸\n        min_width = float('inf')\n        min_height = float('inf')\n        \n        for idx, img in enumerate(editable_images):\n            aspect_ratio = img.width / img.height\n            ratio_diff = abs(aspect_ratio - ASPECT_RATIO_16_9) / ASPECT_RATIO_16_9\n            \n            if ratio_diff > ASPECT_RATIO_TOLERANCE:\n                logger.error(f\"图片 {idx + 1} ({image_paths[idx]}) 不是16:9比例: \"\n                           f\"{img.width}x{img.height} (比例 {aspect_ratio:.4f}, 期望 {ASPECT_RATIO_16_9:.4f})\")\n                raise ValueError(f\"所有图片必须是16:9比例，但第 {idx + 1} 张图片 ({img.width}x{img.height}) 不符合要求\")\n            \n            min_width = min(min_width, img.width)\n            min_height = min(min_height, img.height)\n            logger.info(f\"图片 {idx + 1}: {img.width}x{img.height} (比例 {aspect_ratio:.4f})\")\n        \n        slide_width_pixels = int(min_width)\n        slide_height_pixels = int(min_height)\n        logger.info(f\"统一使用最小尺寸作为幻灯片尺寸: {slide_width_pixels}x{slide_height_pixels}\")\n        \n        # 如果图片尺寸不一致，给出警告\n        if any(img.width != slide_width_pixels or img.height != slide_height_pixels for img in editable_images):\n            logger.warning(f\"图片尺寸不一致，已统一到最小尺寸 {slide_width_pixels}x{slide_height_pixels}\")\n    else:\n        # 如果没有图片，使用默认尺寸\n        slide_width_pixels = 1920\n        slide_height_pixels = 1080\n        logger.warning(\"没有图片，使用默认尺寸: 1920x1080\")\n    \n    ExportService.create_editable_pptx_with_recursive_analysis(\n        editable_images=editable_images,\n        output_file=output_file,\n        slide_width_pixels=slide_width_pixels,\n        slide_height_pixels=slide_height_pixels,\n        text_attribute_extractor=text_attribute_extractor,\n        progress_callback=progress_callback\n    )\n    \n    logger.info(f\"✓ 导出完成: {output_file}\")\n\n\ndef main():\n    parser = argparse.ArgumentParser(\n        description='从图片生成可编辑的 PPTX 文件',\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n        epilog=\"\"\"\n示例:\n  %(prog)s slide1.png slide2.png -o presentation.pptx\n  %(prog)s ./slides/ --extractor hybrid --inpaint baidu\n  %(prog)s image.png --no-text-styles\n\n成本提示:\n  - 'generative' 和 'hybrid' 背景修复方法会调用文生图模型 API\n  - '--no-text-styles' 可跳过文字样式提取，减少 API 调用\n        \"\"\"\n    )\n    \n    parser.add_argument(\n        'images',\n        nargs='+',\n        help='图片文件或目录路径'\n    )\n    \n    parser.add_argument(\n        '-o', '--output',\n        default='output_editable.pptx',\n        help='输出 PPTX 文件路径（默认: output_editable.pptx）'\n    )\n    \n    parser.add_argument(\n        '--extractor',\n        choices=['mineru', 'hybrid'],\n        default='hybrid',\n        help='组件提取方法（默认: hybrid）'\n    )\n    \n    parser.add_argument(\n        '--inpaint',\n        choices=['generative', 'baidu', 'hybrid'],\n        default='hybrid',\n        help='背景修复方法（默认: hybrid）。generative/hybrid 会调用文生图模型'\n    )\n    \n    parser.add_argument(\n        '--no-text-styles',\n        action='store_true',\n        help='跳过文字样式提取（减少 API 调用）'\n    )\n    \n    parser.add_argument(\n        '-v', '--verbose',\n        action='store_true',\n        help='显示详细日志'\n    )\n    \n    args = parser.parse_args()\n    \n    if args.verbose:\n        logging.getLogger().setLevel(logging.DEBUG)\n    \n    # 收集图片路径\n    image_paths = collect_image_paths(args.images)\n    \n    if not image_paths:\n        logger.error(\"未找到任何图片文件\")\n        sys.exit(1)\n    \n    logger.info(f\"找到 {len(image_paths)} 张图片:\")\n    for path in image_paths:\n        logger.info(f\"  - {path}\")\n    \n    # 初始化 Flask 应用\n    app = setup_flask_app()\n    \n    with app.app_context():\n        try:\n            export_editable_pptx(\n                image_paths=image_paths,\n                output_file=args.output,\n                extractor_method=args.extractor,\n                inpaint_method=args.inpaint,\n                extract_text_styles=not args.no_text_styles\n            )\n        except Exception as e:\n            logger.error(f\"导出失败: {e}\", exc_info=True)\n            sys.exit(1)\n\n\nif __name__ == '__main__':\n    main()\n\n"
  },
  {
    "path": "scripts/pre-push-check.sh",
    "content": "#!/bin/bash\n# ===========================================\n# Local quick check script\n# Run before pushing code to ensure basic quality\n# ===========================================\n\nset -e  # Exit immediately on error\n\n# Color definitions\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m'\n\necho \"\"\necho \"==========================\"\necho \"   Local Quick Check\"\necho \"   (Ensure pass before push)\"\necho \"==========================\"\necho \"\"\n\n# Record start time\nSTART_TIME=$(date +%s)\n\n# 1. Backend lint check\necho -e \"${BLUE}[1/4]${NC} Backend code check...\"\ncd backend\nif command -v uv &> /dev/null; then\n    if uv run --quiet python -c \"import flake8\" 2>/dev/null; then\n        if ! uv run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics; then\n            echo -e \"${RED}[FAIL]${NC} Backend lint check failed\"\n            exit 1\n        fi\n    else\n        echo -e \"${YELLOW}[!] flake8 not installed, skipping backend lint${NC}\"\n    fi\nelse\n    echo -e \"${YELLOW}[!] uv not installed, skipping backend check${NC}\"\nfi\necho -e \"${GREEN}[PASS]${NC} Backend check complete\"\ncd ..\n\n# 2. Frontend lint check\necho -e \"${BLUE}[2/4]${NC} Frontend code check...\"\ncd frontend\nnpm run lint 2>/dev/null || {\n    echo -e \"${RED}[FAIL] Frontend lint check failed${NC}\"\n    exit 1\n}\necho -e \"${GREEN}[PASS]${NC} Frontend lint check passed\"\ncd ..\n\n# 3. Frontend build check\necho -e \"${BLUE}[3/4]${NC} Frontend build check...\"\ncd frontend\nnpm run build 2>/dev/null || {\n    echo -e \"${RED}[FAIL] Frontend build failed${NC}\"\n    exit 1\n}\necho -e \"${GREEN}[PASS]${NC} Frontend build successful\"\ncd ..\n\n# 4. Backend unit tests\necho -e \"${BLUE}[4/4]${NC} Backend unit tests...\"\ncd backend\nif command -v uv &> /dev/null; then\n    if [ -d \"tests/unit\" ] && [ \"$(ls -A tests/unit 2>/dev/null)\" ]; then\n        uv run pytest tests/unit -v --tb=short 2>/dev/null || {\n            echo -e \"${YELLOW}[!] Backend tests failed or not configured${NC}\"\n        }\n    else\n        echo -e \"${YELLOW}[!] Unit tests not found, skipping${NC}\"\n    fi\nfi\necho -e \"${GREEN}[PASS]${NC} Backend tests complete\"\ncd ..\n\n# Calculate duration\nEND_TIME=$(date +%s)\nDURATION=$((END_TIME - START_TIME))\n\necho \"\"\necho \"==========================\"\necho -e \"${GREEN}Quick check passed!${NC}\"\necho \"Duration: ${DURATION}s\"\necho \"==========================\"\necho \"\"\necho \"Next steps:\"\necho \"  git push origin <branch>\"\necho \"\"\necho \"Run full tests with:\"\necho \"  npm run test:all\"\necho \"\"\n"
  },
  {
    "path": "scripts/run-local-ci.sh",
    "content": "#!/bin/bash\n# Local CI test script - Simulates GitHub Actions test workflow\n# Usage: ./scripts/run-local-ci.sh [light|full]\n\nset -e\n\n# Color definitions\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m'\n\nlog_info() { echo -e \"${BLUE}[INFO]${NC} $1\"; }\nlog_success() { echo -e \"${GREEN}[PASS]${NC} $1\"; }\nlog_error() { echo -e \"${RED}[FAIL]${NC} $1\"; }\nlog_warning() { echo -e \"${YELLOW}[WARN]${NC} $1\"; }\n\nTEST_MODE=\"${1:-light}\"\n\necho \"\"\necho \"=================================\"\necho \"Local CI Test - $TEST_MODE mode\"\necho \"=================================\"\necho \"\"\n\n# ================================\n# Light Check (Quick)\n# ================================\nif [ \"$TEST_MODE\" = \"light\" ] || [ \"$TEST_MODE\" = \"full\" ]; then\n    echo \"\"\n    log_info \"========== Light Check ==========\"\n    \n    # 1. Backend syntax check\n    log_info \"Step 1: Backend syntax check...\"\n    if command -v flake8 &> /dev/null; then\n        cd backend\n        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics || {\n            log_error \"Backend syntax check failed\"\n            exit 1\n        }\n        cd ..\n        log_success \"Backend syntax check passed\"\n    else\n        log_warning \"flake8 not installed, skipping backend syntax check (pip install flake8)\"\n    fi\n    \n    # 2. Frontend lint check\n    log_info \"Step 2: Frontend lint check...\"\n    if [ -d \"frontend/node_modules\" ]; then\n        cd frontend\n        npm run lint || {\n            log_error \"Frontend lint check failed\"\n            exit 1\n        }\n        cd ..\n        log_success \"Frontend lint check passed\"\n    else\n        log_warning \"Frontend dependencies not installed, skipping lint check (cd frontend && npm ci)\"\n    fi\n    \n    # 3. Frontend build check\n    log_info \"Step 3: Frontend build check...\"\n    if [ -d \"frontend/node_modules\" ]; then\n        cd frontend\n        npm run build || {\n            log_error \"Frontend build failed\"\n            exit 1\n        }\n        cd ..\n        log_success \"Frontend build passed\"\n    else\n        log_warning \"Frontend dependencies not installed, skipping build check\"\n    fi\n    \n    log_success \"========== Light Check Complete ==========\"\nfi\n\n# ================================\n# Full Test (Complete)\n# ================================\nif [ \"$TEST_MODE\" = \"full\" ]; then\n    echo \"\"\n    log_info \"========== Full Test ==========\"\n    \n    # 4. Backend unit tests\n    log_info \"Step 4: Backend unit tests...\"\n    if command -v uv &> /dev/null; then\n        uv sync --extra test 2>/dev/null || log_warning \"Dependency sync failed, continuing...\"\n        cd backend\n        uv run pytest tests/unit -v || {\n            log_error \"Backend unit tests failed\"\n            exit 1\n        }\n        cd ..\n        log_success \"Backend unit tests passed\"\n    else\n        log_warning \"uv not installed, skipping backend unit tests\"\n        log_info \"  Install: curl -LsSf https://astral.sh/uv/install.sh | sh\"\n    fi\n    \n    # 5. Backend integration tests\n    log_info \"Step 5: Backend integration tests...\"\n    if command -v uv &> /dev/null; then\n        cd backend\n        TESTING=true uv run pytest tests/integration -v || {\n            log_error \"Backend integration tests failed\"\n            exit 1\n        }\n        cd ..\n        log_success \"Backend integration tests passed\"\n    else\n        log_warning \"Skipping backend integration tests\"\n    fi\n    \n    # 6. Frontend unit tests\n    log_info \"Step 6: Frontend unit tests...\"\n    if [ -d \"frontend/node_modules\" ]; then\n        cd frontend\n        npm test -- --run || {\n            log_error \"Frontend unit tests failed\"\n            exit 1\n        }\n        cd ..\n        log_success \"Frontend unit tests passed\"\n    else\n        log_warning \"Skipping frontend unit tests\"\n    fi\n    \n    # 7. Docker environment tests\n    log_info \"Step 7: Docker environment tests...\"\n    if command -v docker &> /dev/null; then\n        log_info \"  Starting Docker environment test (this will take a few minutes)...\"\n        chmod +x scripts/test_docker_environment.sh\n        AUTO_CLEANUP=false ./scripts/test_docker_environment.sh || {\n            log_error \"Docker environment test failed\"\n            exit 1\n        }\n        log_success \"Docker environment test passed\"\n    else\n        log_warning \"Docker not installed, skipping Docker tests\"\n    fi\n    \n    # 8. E2E tests\n    log_info \"Step 8: E2E tests...\"\n    if command -v npx &> /dev/null; then\n        # Check if Docker is running\n        if docker compose ps | grep -q \"Up\"; then\n            log_info \"  Docker environment is running, starting E2E tests...\"\n        else\n            log_info \"  Starting Docker environment...\"\n            docker compose up -d\n            sleep 20\n        fi\n        \n        # Run basic E2E tests\n        log_info \"  Running basic E2E tests...\"\n        npx playwright test home.spec.ts create-ppt.spec.ts || {\n            log_warning \"Basic E2E tests failed (may need to run: npx playwright install)\"\n        }\n        \n        # Run full flow E2E tests (if API key is available)\n        if [ -n \"$GOOGLE_API_KEY\" ] && [ \"$GOOGLE_API_KEY\" != \"mock-api-key\" ]; then\n            log_info \"  Running full flow E2E tests (using real API)...\"\n            npx playwright test api-full-flow.spec.ts --workers=1 || {\n                log_error \"Full flow E2E tests failed\"\n                exit 1\n            }\n            log_success \"Full flow E2E tests passed\"\n        else\n            log_warning \"GOOGLE_API_KEY not configured, skipping full flow E2E tests\"\n            log_info \"  Hint: export GOOGLE_API_KEY=your-key before running\"\n        fi\n        \n        log_success \"E2E tests complete\"\n    else\n        log_warning \"npx not installed, skipping E2E tests\"\n    fi\n    \n    log_success \"========== Full Test Complete ==========\"\nfi\n\n# Summary\necho \"\"\necho \"=================================\"\necho \"Local CI Test Complete\"\necho \"=================================\"\necho \"\"\necho \"Test Summary:\"\nif [ \"$TEST_MODE\" = \"light\" ]; then\n    echo \"  - Backend syntax check: PASSED\"\n    echo \"  - Frontend lint check: PASSED\"\n    echo \"  - Frontend build check: PASSED\"\n    echo \"\"\n    echo \"Tip: Run full test: ./scripts/run-local-ci.sh full\"\nelse\n    echo \"  - Light check (syntax + lint + build): PASSED\"\n    echo \"  - Backend unit tests: PASSED\"\n    echo \"  - Backend integration tests: PASSED\"\n    echo \"  - Frontend unit tests: PASSED\"\n    echo \"  - Docker environment tests: PASSED\"\n    echo \"  - E2E tests: PASSED\"\nfi\necho \"\"\necho \"Ready to push code safely!\"\necho \"\"\n\nexit 0\n"
  },
  {
    "path": "scripts/setup-env-from-secrets.sh",
    "content": "#!/bin/bash\n# Generic environment variable replacement script\n# Automatically replaces configuration items in .env file from GitHub Secrets or environment variables\n#\n# Usage:\n#   ./scripts/setup-env-from-secrets.sh\n\nset -e\n\nENV_FILE=\"${1:-.env}\"\nENV_EXAMPLE=\"${2:-.env.example}\"\n\necho \"Setting up environment variables...\"\necho \"   Source file: $ENV_EXAMPLE\"\necho \"   Target file: $ENV_FILE\"\necho \"\"\n\n# Copy .env.example to .env\ncp \"$ENV_EXAMPLE\" \"$ENV_FILE\"\n\n# Define list of configuration items to be replaced from environment variables/Secrets\n# Format: configuration item name\nREPLACEABLE_VARS=(\n  \"AI_PROVIDER_FORMAT\"\n  \"GOOGLE_API_KEY\"\n  \"GOOGLE_API_BASE\"\n  \"OPENAI_API_KEY\"\n  \"OPENAI_API_BASE\"\n  \"OPENAI_TIMEOUT\"\n  \"OPENAI_MAX_RETRIES\"\n  \"TEXT_MODEL\"\n  \"IMAGE_MODEL\"\n  \"LOG_LEVEL\"\n  \"FLASK_ENV\"\n  \"SECRET_KEY\"\n  \"BACKEND_PORT\"\n  \"CORS_ORIGINS\"\n  \"MAX_DESCRIPTION_WORKERS\"\n  \"MAX_IMAGE_WORKERS\"\n  \"MINERU_TOKEN\"\n  \"MINERU_API_BASE\"\n  \"IMAGE_CAPTION_MODEL\"\n  \"OUTPUT_LANGUAGE\"\n)\n\nreplaced_count=0\nskipped_count=0\n\n# Iterate through each configuration item\nfor var_name in \"${REPLACEABLE_VARS[@]}\"; do\n  # Get the value of the environment variable (if exists)\n  var_value=\"${!var_name}\"\n  \n    # If environment variable exists and is not empty, replace it\n  if [ -n \"$var_value\" ]; then\n    # Check if this configuration item exists in .env file\n    if grep -q \"^${var_name}=\" \"$ENV_FILE\"; then\n      # Escape special characters for sed replacement string (RHS)\n      escaped_value=$(printf '%s\\n' \"$var_value\" | sed -e 's/[\\/&]/\\\\&/g')\n      # Use sed to replace the entire line (handles special characters)\n      # Use | as delimiter to support values with / like URLs\n      sed -i \"s|^${var_name}=.*|${var_name}=${escaped_value}|\" \"$ENV_FILE\"\n      echo \"Replaced ${var_name}\"\n      replaced_count=$((replaced_count + 1))\n    else\n      echo \"Warning: ${var_name} does not exist in .env file, skipping\"\n    fi\n  else\n    # Environment variable does not exist, keep default value\n    skipped_count=$((skipped_count + 1))\n  fi\ndone\n\n# Special handling: If GOOGLE_API_KEY is not configured, use mock-api-key\nif [ -z \"${GOOGLE_API_KEY}\" ]; then\n  sed -i '/^GOOGLE_API_KEY=/s/your-api-key-here/mock-api-key/' \"$ENV_FILE\"\n  echo \"Warning: GOOGLE_API_KEY using mock-api-key (not configured)\"\nfi\n\necho \"\"\necho \"Configuration complete:\"\necho \"   Replaced: $replaced_count items\"\necho \"   Using defaults: $skipped_count items\"\necho \"\"\n\n"
  },
  {
    "path": "scripts/setup_git_hooks.sh",
    "content": "#!/bin/bash\n# 设置Git Hooks\n\nset -e\n\necho \"=================================\"\necho \"设置Git Hooks\"\necho \"=================================\"\necho \"\"\necho \"ℹ️  注意: README 自动翻译已迁移到 GitHub Actions\"\necho \"\"\n\n# 检查是否在项目根目录\nif [ ! -d \".git\" ]; then\n    echo \"错误: 请在项目根目录运行此脚本\"\n    exit 1\nfi\n\n# 创建.githooks目录（如果不存在）\nif [ ! -d \".githooks\" ]; then\n    echo \"错误: .githooks目录不存在\"\n    exit 1\nfi\n\n# 检查是否有启用的 hooks\nif [ -f \".githooks/pre-commit.disabled\" ]; then\n    echo \"发现已禁用的 pre-commit hook\"\n    echo \"\"\n    echo \"README 翻译现在由 GitHub Actions 自动处理：\"\n    echo \"  - 推送到主分支时自动翻译\"\n    echo \"  - 不阻塞本地提交\"\n    echo \"  - 节省 API 调用\"\n    echo \"\"\n    read -p \"是否要重新启用 pre-commit hook？(不推荐) [y/N]: \" -n 1 -r\n    echo\n    if [[ $REPLY =~ ^[Yy]$ ]]; then\n        mv .githooks/pre-commit.disabled .githooks/pre-commit\n        echo \"✅ Pre-commit hook 已启用\"\n    else\n        echo \"✅ 保持 GitHub Actions 方案（推荐）\"\n        echo \"\"\n        echo \"手动翻译命令: uv run python scripts/translate_readme.py\"\n        exit 0\n    fi\nfi\n\n# 配置Git使用自定义hooks目录\nif [ -f \".githooks/pre-commit\" ]; then\n    echo \"配置Git使用.githooks目录...\"\n    git config core.hooksPath .githooks\n\n    # 确保hooks有执行权限\n    echo \"设置hooks执行权限...\"\n    chmod +x .githooks/pre-commit\n\n    echo \"\"\n    echo \"=================================\"\n    echo \"✅ Git Hooks设置完成！\"\n    echo \"=================================\"\n    echo \"\"\n    echo \"已启用的功能：\"\n    echo \"  • pre-commit: 当README.md修改时自动翻译到README_EN.md\"\n    echo \"\"\n    echo \"提示：\"\n    echo \"  - 修改README.md并提交时，会自动翻译README_EN.md\"\n    echo \"  - 需要在.env中配置GOOGLE_API_KEY\"\n    echo \"  - 如果翻译失败，不会阻止提交\"\n    echo \"  - 每次提交可能需要等待 30-60 秒\"\nelse\n    echo \"\"\n    echo \"=================================\"\n    echo \"✅ 无需设置 Git Hooks\"\n    echo \"=================================\"\n    echo \"\"\n    echo \"README 翻译由 GitHub Actions 自动处理\"\n    echo \"查看详情: .githooks/README.md\"\nfi\n\necho \"\"\n\n"
  },
  {
    "path": "scripts/test_docker_environment.sh",
    "content": "#!/bin/bash\n# Docker environment full test script\n# Tests complete functionality in Docker environment\n\nset -e  # Exit immediately on error\n\n# Color definitions\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\n# Logging functions\nlog_info() {\n    echo -e \"${BLUE}[INFO]${NC} $1\"\n}\n\nlog_success() {\n    echo -e \"${GREEN}[PASS]${NC} $1\"\n}\n\nlog_error() {\n    echo -e \"${RED}[FAIL]${NC} $1\"\n}\n\nlog_warning() {\n    echo -e \"${YELLOW}[WARN]${NC} $1\"\n}\n\n# Test start\necho \"\"\necho \"=================================\"\necho \"Docker Environment Full Test\"\necho \"=================================\"\necho \"\"\n\n# Check prerequisites\nlog_info \"Checking prerequisites...\"\n\nif ! command -v docker &> /dev/null; then\n    log_error \"Docker is not installed, please install Docker first\"\n    exit 1\nfi\n\nif ! command -v docker compose &> /dev/null && ! docker compose version &> /dev/null; then\n    log_error \"Docker Compose is not installed\"\n    exit 1\nfi\n\nif ! command -v jq &> /dev/null; then\n    log_error \"jq is not installed, please install jq first (apt-get install jq or brew install jq)\"\n    exit 1\nfi\n\nif [ ! -f \".env\" ]; then\n    log_warning \".env file does not exist, copying from .env.example\"\n    cp .env.example .env\nfi\n\nlog_success \"Prerequisites check passed\"\n\n# 1. Clean up old environment\nlog_info \"Step 1/10: Cleaning up old environment...\"\ndocker compose down -v 2>/dev/null || true\ndocker system prune -f >/dev/null 2>&1 || true\nlog_success \"Environment cleanup complete\"\n\n# 2. Build images\nlog_info \"Step 2/10: Building Docker images...\"\nif docker compose build --no-cache; then\n    log_success \"Image build successful\"\nelse\n    log_error \"Image build failed\"\n    exit 1\nfi\n\n# 3. Start services\nlog_info \"Step 3/10: Starting Docker services...\"\nif docker compose up -d; then\n    log_success \"Services started successfully\"\nelse\n    log_error \"Services failed to start\"\n    docker compose logs\n    exit 1\nfi\n\n# 4. Wait for services to be ready\nlog_info \"Step 4/10: Waiting for services to be ready (max 60s)...\"\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nWAIT_SCRIPT=\"${SCRIPT_DIR}/wait-for-health.sh\"\n\nif [ ! -f \"$WAIT_SCRIPT\" ]; then\n    log_warning \"wait-for-health.sh not found, using fallback method\"\n    # Fallback to old method\n    max_wait=60\n    waited=0\n    backend_ready=false\n    frontend_ready=false\n    \n    while [ $waited -lt $max_wait ]; do\n        if curl -s http://localhost:5000/health >/dev/null 2>&1; then\n            backend_ready=true\n        fi\n        if curl -s http://localhost:3000 >/dev/null 2>&1; then\n            frontend_ready=true\n        fi\n        if [ \"$backend_ready\" = true ] && [ \"$frontend_ready\" = true ]; then\n            break\n        fi\n        sleep 2\n        waited=$((waited + 2))\n        echo -n \".\"\n    done\n    echo \"\"\n    \n    if [ \"$backend_ready\" = false ] || [ \"$frontend_ready\" = false ]; then\n        log_error \"Services startup timeout\"\n        exit 1\n    fi\n    log_success \"Services ready (took ${waited}s)\"\nelse\n    # Use wait-for-health.sh script\n    chmod +x \"$WAIT_SCRIPT\"\n    \n    log_info \"Waiting for backend...\"\n    if \"$WAIT_SCRIPT\" http://localhost:5000/health 60 2; then\n        log_success \"Backend is ready\"\n    else\n        log_error \"Backend startup timeout\"\n        docker compose logs backend\n        exit 1\n    fi\n    \n    log_info \"Waiting for frontend...\"\n    if \"$WAIT_SCRIPT\" http://localhost:3000 60 2; then\n        log_success \"Frontend is ready\"\n    else\n        log_error \"Frontend startup timeout\"\n        docker compose logs frontend\n        exit 1\n    fi\nfi\n\n# 5. Check container health status\nlog_info \"Step 5/10: Checking container health status...\"\nbackend_status=$(docker compose ps backend | grep -c \"Up\" || echo \"0\")\nfrontend_status=$(docker compose ps frontend | grep -c \"Up\" || echo \"0\")\n\nif [ \"$backend_status\" -eq \"0\" ] || [ \"$frontend_status\" -eq \"0\" ]; then\n    log_error \"Container status abnormal\"\n    docker compose ps\n    exit 1\nfi\nlog_success \"Container status normal\"\n\n# 6. Backend health check\nlog_info \"Step 6/10: Backend health check...\"\nbackend_health=$(curl -s http://localhost:5000/health)\nif echo \"$backend_health\" | grep -q '\"status\":\"ok\"'; then\n    log_success \"Backend health check passed\"\n    echo \"    Response: $backend_health\"\nelse\n    log_error \"Backend health check failed\"\n    echo \"    Response: $backend_health\"\n    exit 1\nfi\n\n# 7. Frontend access test\nlog_info \"Step 7/10: Frontend access test...\"\nfrontend_status_code=$(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000)\nif [ \"$frontend_status_code\" = \"200\" ]; then\n    log_success \"Frontend access normal (HTTP $frontend_status_code)\"\nelse\n    log_error \"Frontend access failed (HTTP $frontend_status_code)\"\n    exit 1\nfi\n\n# 8. API functionality tests\nlog_info \"Step 8/10: API functionality tests...\"\n\n# 8.1 Create project\nlog_info \"  8.1 Creating project...\"\ncreate_response=$(curl -s -X POST http://localhost:5000/api/projects \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\"creation_type\":\"idea\",\"idea_prompt\":\"Docker test project\"}')\n\nif echo \"$create_response\" | jq -e '.success == true' > /dev/null 2>&1; then\n    project_id=$(echo \"$create_response\" | jq -r '.data.project_id')\n    log_success \"  Project created successfully: $project_id\"\nelse\n    log_error \"  Project creation failed\"\n    echo \"    Response: $create_response\"\n    exit 1\nfi\n\n# 8.2 Get project\nlog_info \"  8.2 Getting project details...\"\nget_response=$(curl -s http://localhost:5000/api/projects/$project_id)\nif echo \"$get_response\" | grep -q '\"success\":true'; then\n    log_success \"  Project retrieved successfully\"\nelse\n    log_error \"  Project retrieval failed\"\n    exit 1\nfi\n\n# 8.3 Upload template (if exists)\nif [ -f \"template_g.png\" ]; then\n    log_info \"  8.3 Uploading template file...\"\n    upload_response=$(curl -s -X POST http://localhost:5000/api/projects/$project_id/template \\\n        -F \"template_image=@template_g.png\")\n    \n    if echo \"$upload_response\" | grep -q '\"success\":true'; then\n        log_success \"  Template uploaded successfully\"\n    else\n        log_warning \"  Template upload failed (non-critical)\"\n    fi\nelse\n    log_warning \"  8.3 Skipping template upload (file does not exist)\"\nfi\n\n# 8.4 Delete project (cleanup)\nlog_info \"  8.4 Deleting test project...\"\ndelete_response=$(curl -s -X DELETE http://localhost:5000/api/projects/$project_id)\nif echo \"$delete_response\" | grep -q '\"success\":true'; then\n    log_success \"  Project deleted successfully\"\nelse\n    log_warning \"  Project deletion failed (non-critical)\"\nfi\n\nlog_success \"API functionality tests passed\"\n\n# 9. Data persistence test\nlog_info \"Step 9/10: Data persistence test...\"\n\n# Create a project\ncreate_response=$(curl -s -X POST http://localhost:5000/api/projects \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\"creation_type\":\"idea\",\"idea_prompt\":\"Persistence test\"}')\npersist_project_id=$(echo \"$create_response\" | jq -r '.data.project_id')\n\n# Restart backend container\nlog_info \"  Restarting backend container...\"\ndocker compose restart backend\nsleep 5\n\n# Wait for backend to recover\nfor i in {1..30}; do\n    if curl -s http://localhost:5000/health >/dev/null 2>&1; then\n        break\n    fi\n    sleep 1\ndone\n\n# Check if project still exists\npersist_check=$(curl -s http://localhost:5000/api/projects/$persist_project_id)\nif echo \"$persist_check\" | grep -q '\"success\":true'; then\n    log_success \"Data persistence test passed\"\nelse\n    log_error \"Data persistence test failed\"\n    exit 1\nfi\n\n# Cleanup test data\ncurl -s -X DELETE http://localhost:5000/api/projects/$persist_project_id >/dev/null\n\n# 10. Log check\nlog_info \"Step 10/10: Checking container logs for errors...\"\nbackend_errors=$(docker compose logs backend 2>&1 | grep -i \"error\\|exception\\|traceback\" | grep -v \"DEBUG\" | wc -l)\nfrontend_errors=$(docker compose logs frontend 2>&1 | grep -i \"error\" | grep -v \"warn\" | wc -l)\n\nif [ \"$backend_errors\" -gt 5 ]; then\n    log_warning \"Found $backend_errors errors in backend logs\"\n    docker compose logs backend | grep -i \"error\\|exception\" | tail -10\nelse\n    log_success \"Backend log check passed ($backend_errors errors)\"\nfi\n\nif [ \"$frontend_errors\" -gt 5 ]; then\n    log_warning \"Found $frontend_errors errors in frontend logs\"\nelse\n    log_success \"Frontend log check passed ($frontend_errors errors)\"\nfi\n\n# Test summary\necho \"\"\necho \"=================================\"\necho \"Docker Environment Test Complete\"\necho \"=================================\"\necho \"\"\necho \"Test Summary:\"\necho \"  - Image build: PASSED\"\necho \"  - Service startup: PASSED\"\necho \"  - Health check: PASSED\"\necho \"  - API functionality: PASSED\"\necho \"  - Data persistence: PASSED\"\necho \"  - Log check: PASSED\"\necho \"\"\necho \"Next Steps:\"\necho \"  1. Run full API tests: cd backend && python ../tests/test_e2e.py\"\necho \"  2. Run E2E tests: npx playwright test\"\necho \"  3. Stop environment: docker compose down\"\necho \"\"\n\n# Ask whether to cleanup environment\nif [ \"${AUTO_CLEANUP}\" != \"false\" ]; then\n    read -p \"Stop Docker environment? (y/N) \" -n 1 -r\n    echo\n    if [[ $REPLY =~ ^[Yy]$ ]]; then\n        log_info \"Stopping Docker environment...\"\n        docker compose down\n        log_success \"Environment cleaned up\"\n    else\n        log_info \"Keeping environment running, manually stop with: docker compose down\"\n    fi\nfi\n\nexit 0\n\n"
  },
  {
    "path": "scripts/translate_readme.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\n自动翻译README.md到README_EN.md\n\n使用项目的AI服务将中文README翻译成英文。\n适用于CI/CD自动化流程。\n\"\"\"\n\nimport os\nimport sys\nimport logging\nfrom pathlib import Path\n\n# 添加backend目录到Python路径\nbackend_dir = Path(__file__).parent.parent / \"backend\"\nsys.path.insert(0, str(backend_dir))\n\n# 配置日志\nlogging.basicConfig(\n    level=logging.INFO,\n    format='%(asctime)s - %(levelname)s - %(message)s'\n)\nlogger = logging.getLogger(__name__)\n\n\ndef translate_readme(source_file: str, target_file: str):\n    \"\"\"\n    翻译README文件\n    \n    Args:\n        source_file: 源文件路径 (中文README.md)\n        target_file: 目标文件路径 (英文README_EN.md)\n    \"\"\"\n    try:\n        # 导入AI服务\n        from services.ai_providers import get_text_provider\n        \n        # 读取源文件\n        logger.info(f\"读取源文件: {source_file}\")\n        with open(source_file, 'r', encoding='utf-8') as f:\n            source_content = f.read()\n        \n        if not source_content.strip():\n            logger.error(\"源文件为空\")\n            sys.exit(1)\n        \n        logger.info(f\"源文件长度: {len(source_content)} 字符\")\n        \n        # 获取文本提供者（使用环境变量中的配置）\n        logger.info(\"初始化AI文本提供者...\")\n        text_model = os.getenv('TEXT_MODEL', 'gemini-3-flash-preview')\n        text_provider = get_text_provider(model=text_model)\n        logger.info(f\"使用模型: {text_model}\")\n        \n        # 构建翻译提示词\n        translation_prompt = f\"\"\"请将以下中文Markdown文档翻译成英文。\n\n要求：\n1. 保持Markdown格式不变（包括标题、链接、图片、代码块等）\n2. 保持所有HTML标签和属性不变\n3. 保持所有URL链接不变\n4. 保持徽章（badges）的链接和格式不变\n5. 技术术语使用常见的英文表达\n6. 语言风格要专业、清晰、易读\n7. 保持原文的段落结构和排版\n8. 不要添加任何额外的解释或注释，只输出翻译后的内容\n\n原文：\n\n{source_content}\n\n翻译后的英文版本：\"\"\"\n\n        # 调用AI进行翻译\n        logger.info(\"开始翻译...\")\n        translated_content = text_provider.generate_text(translation_prompt)\n        \n        if not translated_content or not translated_content.strip():\n            logger.error(\"翻译结果为空\")\n            sys.exit(1)\n        \n        logger.info(f\"翻译完成，长度: {len(translated_content)} 字符\")\n        \n        # 后处理：确保中英文链接互换\n        # 将 **中文 | [English](README_EN.md)** 替换为 **[中文](README.md) | English**\n        translated_content = translated_content.replace(\n            '**中文 | [English](README_EN.md)**',\n            '**[中文](README.md) | English**'\n        ).replace(\n            '**Chinese | [English](README_EN.md)**',\n            '**[中文](README.md) | English**'\n        )\n        \n        # 写入目标文件\n        logger.info(f\"写入目标文件: {target_file}\")\n        with open(target_file, 'w', encoding='utf-8') as f:\n            f.write(translated_content)\n        \n        logger.info(\"✅ 翻译成功完成！\")\n        return True\n        \n    except ImportError as e:\n        logger.error(f\"导入错误: {e}\")\n        logger.error(\"请确保已安装所有依赖: uv sync\")\n        sys.exit(1)\n    except FileNotFoundError as e:\n        logger.error(f\"文件不存在: {e}\")\n        sys.exit(1)\n    except Exception as e:\n        logger.error(f\"翻译失败: {e}\")\n        import traceback\n        traceback.print_exc()\n        sys.exit(1)\n\n\ndef main():\n    \"\"\"主函数\"\"\"\n    # 获取项目根目录\n    project_root = Path(__file__).parent.parent\n    source_file = project_root / \"README.md\"\n    target_file = project_root / \"README_EN.md\"\n    \n    logger.info(\"README 自动翻译工具:\")\n    logger.info(f\"项目根目录: {project_root}\")\n    logger.info(f\"源文件: {source_file}\")\n    logger.info(f\"目标文件: {target_file}\")\n    \n    # 检查源文件是否存在\n    if not source_file.exists():\n        logger.error(f\"源文件不存在: {source_file}\")\n        sys.exit(1)\n    \n    # 执行翻译\n    translate_readme(str(source_file), str(target_file))\n\n\nif __name__ == \"__main__\":\n    main()\n\n"
  },
  {
    "path": "scripts/translate_readme_incremental.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\n基于 diff 的增量翻译 README.md 到 README_EN.md\n\n\"\"\"\n\nimport os\nimport sys\nimport logging\nimport re\nimport subprocess\nfrom pathlib import Path\nfrom typing import List, Tuple, Dict\n\n# 添加backend目录到Python路径\nbackend_dir = Path(__file__).parent.parent / \"backend\"\nsys.path.insert(0, str(backend_dir))\n\n# 配置日志\nlogging.basicConfig(\n    level=logging.INFO,\n    format='%(asctime)s - %(levelname)s - %(message)s'\n)\nlogger = logging.getLogger(__name__)\n\n\ndef split_by_headers(content: str) -> List[Tuple[str, str, str]]:\n    \"\"\"\n    按 Markdown 标题将内容分块\n\n    Returns:\n        List of (header, title, content) tuples\n        header: 标题行 (如 \"## 功能特性\")\n        title: 标题文本 (如 \"功能特性\")\n        content: 该标题下的内容（不含标题本身）\n    \"\"\"\n    # 匹配 Markdown 标题（# 到 #### 级别）\n    header_pattern = re.compile(r'^(#{1,4})\\s+(.+)$', re.MULTILINE)\n\n    blocks = []\n    last_pos = 0\n    last_header = \"\"\n    last_title = \"\"\n\n    for match in header_pattern.finditer(content):\n        # 保存上一个块的内容\n        if last_pos > 0 or match.start() > 0:\n            block_content = content[last_pos:match.start()].strip()\n            if last_header or block_content:  # 保存非空块\n                blocks.append((last_header, last_title, block_content))\n\n        # 更新当前标题信息\n        last_header = match.group(0)  # 完整的标题行\n        last_title = match.group(2).strip()  # 标题文本\n        last_pos = match.end() + 1  # 跳过换行符\n\n    # 保存最后一个块\n    if last_pos < len(content):\n        block_content = content[last_pos:].strip()\n        blocks.append((last_header, last_title, block_content))\n    elif last_header:\n        # 如果最后一个标题后面没有内容\n        blocks.append((last_header, last_title, \"\"))\n\n    return blocks\n\n\ndef get_git_diff_lines(file_path: str) -> set:\n    \"\"\"\n    获取文件在 git 中修改的行号\n\n    Returns:\n        修改的行号集合\n    \"\"\"\n    try:\n        # 获取 git diff，显示修改的行\n        result = subprocess.run(\n            ['git', 'diff', '-U0', 'HEAD', file_path],\n            capture_output=True,\n            text=True,\n            check=False\n        )\n\n        if result.returncode != 0:\n            logger.warning(f\"Git diff 失败，将翻译全部内容\")\n            return set()\n\n        # 解析 diff 输出，提取修改的行号\n        changed_lines = set()\n        for line in result.stdout.split('\\n'):\n            # 匹配 @@ -x,y +a,b @@ 格式\n            if line.startswith('@@'):\n                # 提取新文件的行号范围 (+a,b)\n                match = re.search(r'\\+(\\d+)(?:,(\\d+))?', line)\n                if match:\n                    start = int(match.group(1))\n                    count = int(match.group(2)) if match.group(2) else 1\n                    changed_lines.update(range(start, start + count))\n\n        logger.info(f\"检测到 {len(changed_lines)} 行修改\")\n        return changed_lines\n\n    except Exception as e:\n        logger.warning(f\"获取 git diff 失败: {e}，将翻译全部内容\")\n        return set()\n\n\ndef find_changed_blocks(content: str, changed_lines: set) -> set:\n    \"\"\"\n    根据修改的行号，找出哪些块被修改了\n\n    Returns:\n        修改的块的标题集合\n    \"\"\"\n    if not changed_lines:\n        logger.info(\"没有检测到具体的修改行，将翻译所有块\")\n        return set()\n\n    blocks = split_by_headers(content)\n    changed_blocks = set()\n\n    current_line = 1\n    for header, title, block_content in blocks:\n        # 计算这个块的行范围\n        block_lines = len(header.split('\\n')) + len(block_content.split('\\n'))\n        block_range = set(range(current_line, current_line + block_lines))\n\n        # 检查是否有交集\n        if block_range & changed_lines:\n            changed_blocks.add(title)\n            logger.info(f\"检测到修改的块: {title}\")\n\n        current_line += block_lines\n\n    return changed_blocks\n\n\ndef translate_block(content: str, text_provider) -> str:\n    \"\"\"翻译单个内容块（provider 层已有重试机制）\"\"\"\n    translation_prompt = f\"\"\"Please translate the following Chinese Markdown content to English.\n\nRequirements:\n1. Keep Markdown format unchanged (headings, links, images, code blocks, etc.)\n2. Keep all HTML tags and attributes unchanged\n3. Keep all URLs unchanged\n4. Keep all badges links and format unchanged\n5. Use common English expressions for technical terms\n6. Professional, clear, and readable style\n7. Keep original paragraph structure and layout\n8. Output ONLY the translated content without any extra explanations\n\nOriginal content:\n\n{content}\n\nTranslated English version:\"\"\"\n\n    translated = text_provider.generate_text(translation_prompt)\n    return translated.strip()\n\n\ndef incremental_translate(source_file: str, target_file: str, force_full: bool = False):\n    \"\"\"\n    增量翻译 README\n\n    Args:\n        source_file: 源文件路径 (中文README.md)\n        target_file: 目标文件路径 (英文README_EN.md)\n        force_full: 是否强制全文翻译\n    \"\"\"\n    try:\n        from services.ai_providers import get_text_provider\n\n        # 读取源文件\n        logger.info(f\"读取源文件: {source_file}\")\n        with open(source_file, 'r', encoding='utf-8') as f:\n            source_content = f.read()\n\n        if not source_content.strip():\n            logger.error(\"源文件为空\")\n            sys.exit(1)\n\n        # 读取现有的英文文件（如果存在）\n        target_content = \"\"\n        target_blocks = {}\n        if os.path.exists(target_file) and not force_full:\n            logger.info(f\"读取现有英文文件: {target_file}\")\n            with open(target_file, 'r', encoding='utf-8') as f:\n                target_content = f.read()\n\n            # 解析英文文件的块\n            for header, title, content in split_by_headers(target_content):\n                target_blocks[title] = (header, content)\n\n        # 获取 AI 提供者\n        logger.info(\"初始化AI文本提供者...\")\n        text_model = os.getenv('TEXT_MODEL', 'gemini-3-flash-preview')\n        text_provider = get_text_provider(model=text_model)\n        logger.info(f\"使用模型: {text_model}\")\n\n        # 检测修改的行\n        changed_lines = get_git_diff_lines(source_file) if not force_full else set()\n\n        # 分块处理\n        source_blocks = split_by_headers(source_content)\n        changed_block_titles = find_changed_blocks(source_content, changed_lines) if changed_lines else set()\n\n        # 如果没有检测到具体的变化，或者是新文件，则翻译全部\n        if not target_content or force_full or not changed_lines:\n            logger.info(\"执行全文翻译\")\n            changed_block_titles = {title for _, title, _ in source_blocks}\n\n        # 翻译修改的块\n        translated_blocks = []\n        total_blocks = len(source_blocks)\n        translated_count = 0\n\n        for idx, (header, title, content) in enumerate(source_blocks, 1):\n            # 如果这个块被修改了，或者目标文件中不存在，则需要翻译\n            needs_translation = (\n                not changed_lines or  # 没有 diff 信息，翻译全部\n                title in changed_block_titles or  # 块被修改\n                title not in target_blocks  # 新增的块\n            )\n\n            if needs_translation:\n                logger.info(f\"[{idx}/{total_blocks}] 翻译块: {title}\")\n\n                # 翻译标题和内容\n                if header:\n                    translated_header = translate_block(header, text_provider)\n                else:\n                    translated_header = \"\"\n\n                if content:\n                    translated_content = translate_block(content, text_provider)\n                else:\n                    translated_content = \"\"\n\n                translated_blocks.append((translated_header, translated_content))\n                translated_count += 1\n            else:\n                # 使用现有的翻译\n                logger.info(f\"[{idx}/{total_blocks}] 复用现有翻译: {title}\")\n                if title in target_blocks:\n                    existing_header, existing_content = target_blocks[title]\n                    translated_blocks.append((existing_header, existing_content))\n                else:\n                    # 不应该到这里，但以防万一\n                    logger.warning(f\"未找到现有翻译，将翻译: {title}\")\n                    translated_header = translate_block(header, text_provider) if header else \"\"\n                    translated_content = translate_block(content, text_provider) if content else \"\"\n                    translated_blocks.append((translated_header, translated_content))\n                    translated_count += 1\n\n        # 组装最终内容\n        final_content = \"\"\n        for header, content in translated_blocks:\n            if header:\n                final_content += header + \"\\n\\n\"\n            if content:\n                final_content += content + \"\\n\\n\"\n\n        # 后处理：确保中英文链接互换\n        final_content = final_content.replace(\n            '**中文 | [English](README_EN.md)**',\n            '**[中文](README.md) | English**'\n        ).replace(\n            '**Chinese | [English](README_EN.md)**',\n            '**[中文](README.md) | English**'\n        )\n\n        # 写入目标文件\n        logger.info(f\"写入目标文件: {target_file}\")\n        with open(target_file, 'w', encoding='utf-8') as f:\n            f.write(final_content.strip() + \"\\n\")\n\n        logger.info(f\"✅ 翻译完成！共处理 {total_blocks} 个块，翻译了 {translated_count} 个块\")\n\n        return True\n\n    except ImportError as e:\n        logger.error(f\"导入错误: {e}\")\n        logger.error(\"请确保已安装所有依赖: uv sync\")\n        sys.exit(1)\n    except FileNotFoundError as e:\n        logger.error(f\"文件不存在: {e}\")\n        sys.exit(1)\n    except Exception as e:\n        logger.error(f\"翻译失败: {e}\")\n        import traceback\n        traceback.print_exc()\n        sys.exit(1)\n\n\ndef main():\n    \"\"\"主函数\"\"\"\n    # 获取项目根目录\n    project_root = Path(__file__).parent.parent\n    source_file = project_root / \"README.md\"\n    target_file = project_root / \"README_EN.md\"\n\n    # 检查是否强制全文翻译\n    force_full = \"--full\" in sys.argv\n\n    logger.info(\"README 增量翻译工具\")\n    logger.info(f\"项目根目录: {project_root}\")\n    logger.info(f\"源文件: {source_file}\")\n    logger.info(f\"目标文件: {target_file}\")\n    if force_full:\n        logger.info(\"模式: 强制全文翻译\")\n    else:\n        logger.info(\"模式: 增量翻译（仅翻译修改的部分）\")\n\n    # 检查源文件是否存在\n    if not source_file.exists():\n        logger.error(f\"源文件不存在: {source_file}\")\n        sys.exit(1)\n\n    # 执行翻译\n    incremental_translate(str(source_file), str(target_file), force_full=force_full)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "scripts/verify-e2e-refactoring.sh",
    "content": "#!/bin/bash\n\n# E2E 测试重构验证脚本\n# 用于验证重构是否成功完成\n\nset -e\n\necho \"======================================\"\necho \"🔍 E2E 测试重构验证\"\necho \"======================================\"\necho\n\n# 颜色输出\nGREEN='\\033[0;32m'\nRED='\\033[0;31m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m' # No Color\n\ncheck_pass() {\n    echo -e \"${GREEN}✓${NC} $1\"\n}\n\ncheck_fail() {\n    echo -e \"${RED}✗${NC} $1\"\n    exit 1\n}\n\ncheck_warn() {\n    echo -e \"${YELLOW}⚠${NC} $1\"\n}\n\n# 1. 检查前端 E2E 目录\necho \"1. 检查前端 E2E 目录...\"\nif [ -d \"frontend/e2e\" ]; then\n    check_pass \"frontend/e2e/ 目录存在\"\nelse\n    check_fail \"frontend/e2e/ 目录不存在\"\nfi\n\n# 2. 检查前端 E2E 测试文件\necho \"2. 检查前端 E2E 测试文件...\"\nif [ -f \"frontend/e2e/ui-full-flow.spec.ts\" ]; then\n    check_pass \"ui-full-flow.spec.ts 已移动到 frontend/e2e/\"\nelse\n    check_fail \"ui-full-flow.spec.ts 不在 frontend/e2e/\"\nfi\n\nif [ -f \"frontend/e2e/visual-regression.spec.ts\" ]; then\n    check_pass \"visual-regression.spec.ts 已移动到 frontend/e2e/\"\nelse\n    check_fail \"visual-regression.spec.ts 不在 frontend/e2e/\"\nfi\n\n# 3. 检查 Playwright 配置\necho \"3. 检查 Playwright 配置...\"\nif [ -f \"frontend/playwright.config.ts\" ]; then\n    check_pass \"playwright.config.ts 已移动到 frontend/\"\nelse\n    check_fail \"playwright.config.ts 不在 frontend/\"\nfi\n\n# 4. 检查前端 package.json\necho \"4. 检查前端 package.json...\"\nif grep -q \"@playwright/test\" frontend/package.json; then\n    check_pass \"frontend/package.json 包含 Playwright 依赖\"\nelse\n    check_fail \"frontend/package.json 缺少 Playwright 依赖\"\nfi\n\nif grep -q \"test:e2e\" frontend/package.json; then\n    check_pass \"frontend/package.json 包含 E2E 测试脚本\"\nelse\n    check_fail \"frontend/package.json 缺少 E2E 测试脚本\"\nfi\n\n# 5. 检查后端集成测试\necho \"5. 检查后端集成测试...\"\nif [ -f \"backend/tests/integration/test_api_full_flow.py\" ]; then\n    check_pass \"test_api_full_flow.py 已创建在 backend/tests/integration/\"\nelse\n    check_fail \"test_api_full_flow.py 不在 backend/tests/integration/\"\nfi\n\n# 6. 检查根目录清理\necho \"6. 检查根目录清理...\"\nif [ ! -d \"e2e\" ]; then\n    check_pass \"根目录 e2e/ 已删除\"\nelse\n    check_warn \"根目录 e2e/ 仍然存在（应该已删除）\"\nfi\n\nif [ ! -f \"playwright.config.ts\" ]; then\n    check_pass \"根目录 playwright.config.ts 已删除\"\nelse\n    check_warn \"根目录 playwright.config.ts 仍然存在（应该已删除）\"\nfi\n\nif [ ! -f \"tsconfig.json\" ]; then\n    check_pass \"根目录 tsconfig.json 已删除\"\nelse\n    check_warn \"根目录 tsconfig.json 仍然存在（应该已删除）\"\nfi\n\nif [ ! -d \"node_modules\" ]; then\n    check_pass \"根目录 node_modules/ 已删除\"\nelse\n    check_warn \"根目录 node_modules/ 仍然存在（应该已删除）\"\nfi\n\n# 7. 检查 CI 配置\necho \"7. 检查 CI 配置...\"\nif grep -q \"cd frontend\" .github/workflows/ci-test.yml; then\n    check_pass \"CI 配置已更新（包含 'cd frontend'）\"\nelse\n    check_warn \"CI 配置可能未更新\"\nfi\n\nif grep -q \"test_api_full_flow.py\" .github/workflows/ci-test.yml; then\n    check_pass \"CI 配置包含后端 API 测试\"\nelse\n    check_warn \"CI 配置可能缺少后端 API 测试\"\nfi\n\n# 8. 检查 .gitignore\necho \"8. 检查 .gitignore...\"\nif grep -q \"test-results/\" frontend/.gitignore; then\n    check_pass \"frontend/.gitignore 已更新\"\nelse\n    check_warn \"frontend/.gitignore 可能需要更新\"\nfi\n\necho\necho \"======================================\"\necho \"✅ E2E 测试重构验证完成！\"\necho \"======================================\"\necho\necho \"下一步：\"\necho \"1. cd frontend && npm install  # 安装前端依赖（包括 Playwright）\"\necho \"2. cd frontend && npm run test:e2e  # 运行前端 E2E 测试\"\necho \"3. cd backend && uv run pytest tests/integration/ -v  # 运行后端集成测试\"\necho\n\n"
  },
  {
    "path": "scripts/wait-for-health.sh",
    "content": "#!/bin/bash\n# Wait for service health check script\n# Usage: ./wait-for-health.sh <url> [timeout_seconds] [interval_seconds]\n\nset -e\n\nURL=\"${1:-http://localhost:5000/health}\"\nTIMEOUT=\"${2:-60}\"\nINTERVAL=\"${3:-2}\"\n\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m' # No Color\n\necho -e \"${YELLOW}Waiting for ${URL} to be healthy...${NC}\"\necho -e \"${YELLOW}Timeout: ${TIMEOUT}s, Check interval: ${INTERVAL}s${NC}\"\n\nstart_time=$(date +%s)\nelapsed=0\n\nwhile [ $elapsed -lt $TIMEOUT ]; do\n  if curl -f -s \"$URL\" > /dev/null 2>&1; then\n    echo -e \"${GREEN}✓ Service is healthy! (took ${elapsed}s)${NC}\"\n    exit 0\n  fi\n  \n  echo -n \".\"\n  sleep $INTERVAL\n  elapsed=$(($(date +%s) - start_time))\ndone\n\necho \"\"\necho -e \"${RED}✗ Timeout: Service did not become healthy within ${TIMEOUT}s${NC}\"\necho -e \"${RED}  URL: ${URL}${NC}\"\nexit 1\n\n"
  },
  {
    "path": "tests/docker/test_docker_environment.sh",
    "content": "#!/bin/bash\n# Docker环境完整测试脚本\n# 测试项目在Docker环境下的完整功能\n\nset -e  # 遇到错误立即退出\n\n# 颜色定义\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\n# 日志函数\nlog_info() {\n    echo -e \"${BLUE}[INFO]${NC} $1\"\n}\n\nlog_success() {\n    echo -e \"${GREEN}[✓]${NC} $1\"\n}\n\nlog_error() {\n    echo -e \"${RED}[✗]${NC} $1\"\n}\n\nlog_warning() {\n    echo -e \"${YELLOW}[!]${NC} $1\"\n}\n\n# 测试开始\necho \"\"\necho \"=================================\"\necho \"🐳 Docker环境完整测试\"\necho \"=================================\"\necho \"\"\n\n# 检查前置条件\nlog_info \"检查前置条件...\"\n\nif ! command -v docker &> /dev/null; then\n    log_error \"Docker未安装，请先安装Docker\"\n    exit 1\nfi\n\nif ! command -v docker compose &> /dev/null && ! docker compose version &> /dev/null; then\n    log_error \"Docker Compose未安装\"\n    exit 1\nfi\n\nif [ ! -f \".env\" ]; then\n    log_warning \".env文件不存在，从.env.example复制\"\n    cp .env.example .env\nfi\n\nlog_success \"前置条件检查通过\"\n\n# 1. 清理旧环境\nlog_info \"步骤1/10: 清理旧环境...\"\ndocker compose down -v 2>/dev/null || true\ndocker system prune -f >/dev/null 2>&1 || true\nlog_success \"环境清理完成\"\n\n# 2. 构建镜像\nlog_info \"步骤2/10: 构建Docker镜像...\"\nif docker compose build --no-cache; then\n    log_success \"镜像构建成功\"\nelse\n    log_error \"镜像构建失败\"\n    exit 1\nfi\n\n# 3. 启动服务\nlog_info \"步骤3/10: 启动Docker服务...\"\nif docker compose up -d; then\n    log_success \"服务启动成功\"\nelse\n    log_error \"服务启动失败\"\n    docker compose logs\n    exit 1\nfi\n\n# 4. 等待服务就绪\nlog_info \"步骤4/10: 等待服务就绪（最多60秒）...\"\nmax_wait=60\nwaited=0\nbackend_ready=false\nfrontend_ready=false\n\nwhile [ $waited -lt $max_wait ]; do\n    # 检查后端\n    if curl -s http://localhost:5000/health >/dev/null 2>&1; then\n        backend_ready=true\n    fi\n    \n    # 检查前端\n    if curl -s http://localhost:3000 >/dev/null 2>&1; then\n        frontend_ready=true\n    fi\n    \n    if [ \"$backend_ready\" = true ] && [ \"$frontend_ready\" = true ]; then\n        break\n    fi\n    \n    sleep 2\n    waited=$((waited + 2))\n    echo -n \".\"\ndone\necho \"\"\n\nif [ \"$backend_ready\" = false ] || [ \"$frontend_ready\" = false ]; then\n    log_error \"服务启动超时\"\n    log_info \"查看容器状态：\"\n    docker compose ps\n    log_info \"查看后端日志：\"\n    docker compose logs backend\n    log_info \"查看前端日志：\"\n    docker compose logs frontend\n    exit 1\nfi\n\nlog_success \"服务就绪（耗时 ${waited}秒）\"\n\n# 5. 检查容器健康状态\nlog_info \"步骤5/10: 检查容器健康状态...\"\nbackend_status=$(docker compose ps backend | grep -c \"Up\" || echo \"0\")\nfrontend_status=$(docker compose ps frontend | grep -c \"Up\" || echo \"0\")\n\nif [ \"$backend_status\" -eq \"0\" ] || [ \"$frontend_status\" -eq \"0\" ]; then\n    log_error \"容器状态异常\"\n    docker compose ps\n    exit 1\nfi\nlog_success \"容器状态正常\"\n\n# 6. 后端健康检查\nlog_info \"步骤6/10: 后端健康检查...\"\nbackend_health=$(curl -s http://localhost:5000/health)\nif echo \"$backend_health\" | grep -q '\"status\":\"ok\"'; then\n    log_success \"后端健康检查通过\"\n    echo \"    响应: $backend_health\"\nelse\n    log_error \"后端健康检查失败\"\n    echo \"    响应: $backend_health\"\n    exit 1\nfi\n\n# 7. 前端访问测试\nlog_info \"步骤7/10: 前端访问测试...\"\nfrontend_status_code=$(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000)\nif [ \"$frontend_status_code\" = \"200\" ]; then\n    log_success \"前端访问正常 (HTTP $frontend_status_code)\"\nelse\n    log_error \"前端访问失败 (HTTP $frontend_status_code)\"\n    exit 1\nfi\n\n# 8. API功能测试\nlog_info \"步骤8/10: API功能测试...\"\n\n# 8.1 创建项目\nlog_info \"  8.1 创建项目...\"\ncreate_response=$(curl -s -X POST http://localhost:5000/api/projects \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\"creation_type\":\"idea\",\"idea_prompt\":\"Docker测试项目\"}')\n\nif echo \"$create_response\" | grep -q '\"success\":true'; then\n    project_id=$(echo \"$create_response\" | grep -o '\"project_id\":\"[^\"]*\"' | cut -d'\"' -f4)\n    log_success \"  项目创建成功: $project_id\"\nelse\n    log_error \"  项目创建失败\"\n    echo \"    响应: $create_response\"\n    exit 1\nfi\n\n# 8.2 获取项目\nlog_info \"  8.2 获取项目详情...\"\nget_response=$(curl -s http://localhost:5000/api/projects/$project_id)\nif echo \"$get_response\" | grep -q '\"success\":true'; then\n    log_success \"  项目获取成功\"\nelse\n    log_error \"  项目获取失败\"\n    exit 1\nfi\n\n# 8.3 上传模板（如果存在）\nif [ -f \"template_g.png\" ]; then\n    log_info \"  8.3 上传模板文件...\"\n    upload_response=$(curl -s -X POST http://localhost:5000/api/projects/$project_id/template \\\n        -F \"template_image=@template_g.png\")\n    \n    if echo \"$upload_response\" | grep -q '\"success\":true'; then\n        log_success \"  模板上传成功\"\n    else\n        log_warning \"  模板上传失败（非关键）\"\n    fi\nelse\n    log_warning \"  8.3 跳过模板上传（文件不存在）\"\nfi\n\n# 8.4 删除项目（清理）\nlog_info \"  8.4 删除测试项目...\"\ndelete_response=$(curl -s -X DELETE http://localhost:5000/api/projects/$project_id)\nif echo \"$delete_response\" | grep -q '\"success\":true'; then\n    log_success \"  项目删除成功\"\nelse\n    log_warning \"  项目删除失败（非关键）\"\nfi\n\nlog_success \"API功能测试通过\"\n\n# 9. 数据持久化测试\nlog_info \"步骤9/10: 数据持久化测试...\"\n\n# 创建一个项目\ncreate_response=$(curl -s -X POST http://localhost:5000/api/projects \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\"creation_type\":\"idea\",\"idea_prompt\":\"持久化测试\"}')\npersist_project_id=$(echo \"$create_response\" | grep -o '\"project_id\":\"[^\"]*\"' | cut -d'\"' -f4)\n\n# 重启后端容器\nlog_info \"  重启后端容器...\"\ndocker compose restart backend\nsleep 5\n\n# 等待后端恢复\nfor i in {1..30}; do\n    if curl -s http://localhost:5000/health >/dev/null 2>&1; then\n        break\n    fi\n    sleep 1\ndone\n\n# 检查项目是否还存在\npersist_check=$(curl -s http://localhost:5000/api/projects/$persist_project_id)\nif echo \"$persist_check\" | grep -q '\"success\":true'; then\n    log_success \"数据持久化测试通过\"\nelse\n    log_error \"数据持久化测试失败\"\n    exit 1\nfi\n\n# 清理测试数据\ncurl -s -X DELETE http://localhost:5000/api/projects/$persist_project_id >/dev/null\n\n# 10. 日志检查\nlog_info \"步骤10/10: 检查容器日志是否有错误...\"\nbackend_errors=$(docker compose logs backend 2>&1 | grep -i \"error\\|exception\\|traceback\" | grep -v \"DEBUG\" | wc -l)\nfrontend_errors=$(docker compose logs frontend 2>&1 | grep -i \"error\" | grep -v \"warn\" | wc -l)\n\nif [ \"$backend_errors\" -gt 5 ]; then\n    log_warning \"后端日志中发现 $backend_errors 个错误\"\n    docker compose logs backend | grep -i \"error\\|exception\" | tail -10\nelse\n    log_success \"后端日志检查通过（$backend_errors 个错误）\"\nfi\n\nif [ \"$frontend_errors\" -gt 5 ]; then\n    log_warning \"前端日志中发现 $frontend_errors 个错误\"\nelse\n    log_success \"前端日志检查通过（$frontend_errors 个错误）\"\nfi\n\n# 测试总结\necho \"\"\necho \"=================================\"\necho \"✅ Docker环境测试完成\"\necho \"=================================\"\necho \"\"\necho \"📊 测试摘要：\"\necho \"  ✓ 镜像构建\"\necho \"  ✓ 服务启动\"\necho \"  ✓ 健康检查\"\necho \"  ✓ API功能\"\necho \"  ✓ 数据持久化\"\necho \"  ✓ 日志检查\"\necho \"\"\necho \"🎯 下一步：\"\necho \"  1. 运行完整API测试: cd backend && python ../tests/test_e2e.py\"\necho \"  2. 运行E2E测试: npx playwright test\"\necho \"  3. 停止环境: docker compose down\"\necho \"\"\n\n# 询问是否清理环境\nif [ \"${AUTO_CLEANUP}\" != \"false\" ]; then\n    read -p \"是否停止Docker环境？(y/N) \" -n 1 -r\n    echo\n    if [[ $REPLY =~ ^[Yy]$ ]]; then\n        log_info \"停止Docker环境...\"\n        docker compose down\n        log_success \"环境已清理\"\n    else\n        log_info \"保持环境运行，可手动执行: docker compose down\"\n    fi\nfi\n\nexit 0\n\n"
  },
  {
    "path": "v0_demo/demo.py",
    "content": "from typing import Dict\nimport json\nfrom textwrap import dedent\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom pathlib import Path\nimport re\nfrom datetime import datetime\nfrom pptx import Presentation\nfrom pptx.util import Inches\nfrom gemini_genai import gen_image, gen_json_text, gen_text\ndef gen_outline(idea_prompt:str)->list[dict]:\n    \"\"\"generate outline of ppt, including optional parts and pages with title and points\"\"\"\n    outline_prompt = dedent(f\"\"\"\\\n    You are a helpful assistant that generates an outline for a ppt.\n    \n    You can organize the content in two ways:\n    \n    1. Simple format (for short PPTs without major sections):\n    [{{\"title\": \"title1\", \"points\": [\"point1\", \"point2\"]}}, {{\"title\": \"title2\", \"points\": [\"point1\", \"point2\"]}}]\n    \n    2. Part-based format (for longer PPTs with major sections):\n    [\n      {{\n        \"part\": \"Part 1: Introduction\",\n        \"pages\": [\n          {{\"title\": \"Welcome\", \"points\": [\"point1\", \"point2\"]}},\n          {{\"title\": \"Overview\", \"points\": [\"point1\", \"point2\"]}}\n        ]\n      }},\n      {{\n        \"part\": \"Part 2: Main Content\",\n        \"pages\": [\n          {{\"title\": \"Topic 1\", \"points\": [\"point1\", \"point2\"]}},\n          {{\"title\": \"Topic 2\", \"points\": [\"point1\", \"point2\"]}}\n        ]\n      }}\n    ]\n    \n    Choose the format that best fits the content. Use parts when the PPT has clear major sections.\n    \n    The user's request: {idea_prompt}. Now generate the outline, don't include any other text.\n    使用全中文输出。\n    \"\"\")\n    outline = gen_json_text(outline_prompt)\n    outline = json.loads(outline)\n    return outline\n    \n\ndef flatten_outline(outline: list[dict]) -> list[dict]:\n    \"\"\"将可能包含part结构的outline扁平化为页面列表\"\"\"\n    pages = []\n    for item in outline:\n        if \"part\" in item and \"pages\" in item:\n            # 这是一个part，展开其中的页面\n            for page in item[\"pages\"]:\n                # 为每个页面添加part信息\n                page_with_part = page.copy()\n                page_with_part[\"part\"] = item[\"part\"]\n                pages.append(page_with_part)\n        else:\n            # 这是一个直接的页面\n            pages.append(item)\n    return pages\n\ndef gen_desc(idea_prompt, outline: list[Dict])->list[Dict] :\n    \"\"\"generate description for each page, including title, full text content and more (并行生成)\"\"\"\n    # 先将outline扁平化为页面列表\n    pages = flatten_outline(outline)\n    \n    # 为每个页面准备生成任务\n    def generate_page_desc(i, page_outline):\n        part_info = f\"\\nThis page belongs to: {page_outline['part']}\" if 'part' in page_outline else \"\"\n        desc_prompt = dedent(f\"\"\"\\\n        we are generating the text desciption for each ppt page.\n        the original user request is: \\n{idea_prompt}\\n\n        We already have the entire ouline: \\n{outline}\\n{part_info}\n        Now please generate the description for page {i}:\n        {page_outline}\n        The description includes page title, text to render(keep it concise).\n        For example:\n        页面标题：原始社会：与自然共生\n        页面文字：\n        - 狩猎采集文明： 人类活动规模小，对环境影响有限。\n        - 依赖性强： 生活完全依赖于自然资源的直接供给，对自然规律敬畏。\n        - 适应而非改造： 通过观察和模仿学习自然，发展出适应当地环境的生存技能。\n        - 影响特点： 局部、短期、低强度，生态系统有充足的自我恢复能力。\n        \n        使用全中文输出。\n        \"\"\")\n        page_desc = gen_text(desc_prompt)\n        # 清理多余的缩进\n        page_desc = dedent(page_desc)\n        return (i, page_desc)  # 返回索引和描述，以便排序\n    \n    # 使用线程池并行生成所有页面的描述\n    desc_dict = {}\n    with ThreadPoolExecutor(max_workers=5) as executor:\n        # 提交所有任务\n        futures = [executor.submit(generate_page_desc, i, page_outline) \n                   for i, page_outline in enumerate(pages, 1)]\n        \n        # 收集结果\n        for future in as_completed(futures):\n            i, page_desc = future.result()\n            desc_dict[i] = page_desc\n            print(f\"✓ 页面 {i}/{len(pages)} 描述生成完成\")\n    \n    # 按照原始顺序返回结果\n    desc = [desc_dict[i] for i in sorted(desc_dict.keys())]\n    return desc\n\ndef gen_outline_text(outline: list[Dict]) -> str:\n    \"\"\"将outline转换为文本格式，用于提示词\"\"\"\n    text_parts = []\n    for i, item in enumerate(outline, 1):\n        if \"part\" in item and \"pages\" in item:\n            text_parts.append(f\"{i}. {item['part']}\")\n        else:\n            text_parts.append(f\"{i}. {item.get('title', 'Untitled')}\")\n    result = \"\\n\".join(text_parts)\n    # 清理多余的缩进\n    return dedent(result)\n\ndef gen_prompts(outline: list[Dict], desc: list[str]) -> list[str]:\n    \"\"\"为每页描述生成图片提示词\"\"\"\n    pages = flatten_outline(outline)\n    outline_text = gen_outline_text(outline)\n    \n    prompts = []\n    for i, (page, page_desc) in enumerate(zip(pages, desc), 1):\n        # 确定当前所属章节\n        if 'part' in page:\n            current_section = page['part']\n        else:\n            current_section = f\"{page.get('title', 'Untitled')}\"\n        \n        # 构建提示词，参考generate-example.py的格式\n        prompt = dedent(f\"\"\"\\\n        利用专业平面设计知识，根据参考图片的色彩与风格生成一页设计风格相同的ppt页面，作为整个ppt的其中一页，内容是:\n        {page_desc}\n        \n        整个ppt的大纲为：\n        {outline_text}\n        \n        当前位于章节：{current_section}\n        \n        要求文字清晰锐利，画面为4k分辨率 16:9比例.画面风格与配色保持严格一致。ppt使用全中文。\n        \"\"\")\n        print(f\"\\n-----\\n prompt{i}:\\n {prompt}\\n-----\\n\")\n        prompts.append(prompt)\n    \n    return prompts\n\ndef gen_images_parallel(prompts: list[str], ref_image: str, output_dir: str = \"output\") -> list[str]:\n    \"\"\"并行生成所有PPT页面图片\"\"\"\n    # 创建输出目录\n    output_path = Path(output_dir)\n    output_path.mkdir(exist_ok=True)\n    \n    def generate_single_image(i, prompt):\n        \"\"\"生成单张图片\"\"\"\n        try:\n            print(f\"🎨 开始生成页面 {i}/{len(prompts)} 的图片...\")\n            image = gen_image(prompt, ref_image)\n            if image:\n                output_file = output_path / f\"slide_{i:02d}.png\"\n                image.save(str(output_file))\n                print(f\"✓ 页面 {i}/{len(prompts)} 图片生成完成: {output_file}\")\n                return (i, str(output_file))\n            else:\n                print(f\"✗ 页面 {i}/{len(prompts)} 图片生成失败\")\n                return (i, None)\n        except Exception as e:\n            print(f\"✗ 页面 {i}/{len(prompts)} 生成出错: {e}\")\n            return (i, None)\n    \n    # 使用线程池并行生成所有图片\n    image_files = {}\n    with ThreadPoolExecutor(max_workers=8) as executor:  # 限制并发数为3避免API限流\n        # 提交所有任务\n        futures = [executor.submit(generate_single_image, i, prompt) \n                   for i, prompt in enumerate(prompts, 1)]\n        \n        # 收集结果\n        for future in as_completed(futures):\n            i, image_file = future.result()\n            image_files[i] = image_file\n    \n    # 按照原始顺序返回结果\n    return [image_files[i] for i in sorted(image_files.keys())]\n\ndef create_pptx_from_images(input_dir: str = \"output\", output_file: str = \"presentation.pptx\"):\n    \"\"\"\n    将指定目录下的slide_XX.png图片按顺序组合成PPTX文件\n    \n    Args:\n        input_dir: 输入图片所在目录\n        output_file: 输出的PPTX文件名\n    \"\"\"\n    input_path = Path(input_dir)\n    slide_files = list(input_path.glob(\"slide_*.png\"))\n    \n    def extract_number(filename):\n        match = re.search(r'slide_(\\d+)', filename.stem)\n        return int(match.group(1)) if match else 0\n    \n    slide_files.sort(key=extract_number)\n    \n    print(f\"\\n📁 找到 {len(slide_files)} 张幻灯片图片\")\n    print(f\"📝 开始创建 PPTX 文件...\")\n    \n    # 创建演示文稿\n    prs = Presentation()\n    \n    # 设置幻灯片尺寸为16:9 (宽10英寸，高5.625英寸)\n    prs.slide_width = Inches(10)\n    prs.slide_height = Inches(5.625)\n    \n    # 为每张图片创建一页幻灯片\n    for i, image_file in enumerate(slide_files, 1):\n        print(f\"  ✓ 添加第 {i} 页: {image_file.name}\")\n        \n        # 添加空白幻灯片布局（完全空白，没有任何占位符）\n        blank_slide_layout = prs.slide_layouts[6]  # 布局6通常是空白布局\n        slide = prs.slides.add_slide(blank_slide_layout)\n        \n        # 将图片添加到幻灯片，填充整个页面\n        # 左上角位置(0,0)，尺寸为幻灯片的完整宽高\n        slide.shapes.add_picture(\n            str(image_file),\n            left=0,\n            top=0,\n            width=prs.slide_width,\n            height=prs.slide_height\n        )\n    \n    # 保存PPTX文件\n    prs.save(output_file)\n    \n    print(f\"\\n✅ 成功创建 PPTX 文件: {output_file}\")\n    print(f\"📊 总共 {len(slide_files)} 页幻灯片\")\n    return True\n\ndef gen_ppt(idea_prompt, ref_image):\n    # 创建带时间戳的输出目录\n    timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n    output_dir = f\"output_{timestamp}\"\n    pptx_filename = f\"presentation_{timestamp}.pptx\"\n    \n    print(f\"📂 本次运行输出目录: {output_dir}\")\n    print(f\"📄 PPTX文件名: {pptx_filename}\\n\")\n    \n    outline = gen_outline(idea_prompt)\n    \n    # 显示原始outline结构（可能包含parts）\n    print(\"PPT Outline:\")\n    for item in outline:\n        if \"part\" in item and \"pages\" in item:\n            print(f\"\\n【{item['part']}】\")\n            for j, page in enumerate(item[\"pages\"], 1):\n                print(f\"  Page {j}: {page.get('title', 'Untitled')}\")\n                print(f\"    Points: {page.get('points', [])}\")\n        else:\n            print(f\"\\nPage: {item.get('title', 'Untitled')}\")\n            print(f\"  Points: {item.get('points', [])}\")\n    \n    # 生成详细描述\n    desc = gen_desc(idea_prompt, outline)\n    \n    # 显示每页描述\n    pages = flatten_outline(outline)\n    for i, (page, page_desc) in enumerate(zip(pages, desc), 1):\n        part_tag = f\"[{page['part']}] \" if 'part' in page else \"\"\n        print(f\"-----\\nPage {i} {part_tag}- {page.get('title', 'Untitled')}\\n-----\")\n        print(f\"{page_desc}\\n\")\n    \n    # 生成图片提示词\n    print(\"开始生成图片提示词...\")\n    prompts = gen_prompts(outline, desc)\n    print(f\"✓ 已生成 {len(prompts)} 个页面的提示词\\n\")\n    \n    # 并行生成所有页面图片（使用带时间戳的目录）\n    print(\"开始并行生成PPT页面图片...\")\n    image_files = gen_images_parallel(prompts, ref_image, output_dir)\n    \n    # 显示结果汇总\n    print(\"PPT图片生成完成！\")\n    successful = [f for f in image_files if f is not None]\n    print(f\"✓ 成功生成 {len(successful)}/{len(image_files)} 张图片\")\n    for i, image_file in enumerate(image_files, 1):\n        if image_file:\n            print(f\"  页面 {i}: {image_file}\")\n        else:\n            print(f\"  页面 {i}: 生成失败\")\n    \n    # 将所有图片组合成PPTX文件\n    if successful:\n        print(\"正在生成最终的PPTX文件...\")\n        create_pptx_from_images(output_dir, pptx_filename)\n    \n    return image_files\n    \n    \n\nif __name__ == \"__main__\":\n    idea_prompt=\"生成一张关于人类活动对生态环境影响的ppt.只要3页。\"\n    ref_image=\"template_g.png\"\n    gen_ppt(idea_prompt, ref_image)"
  },
  {
    "path": "v0_demo/gemini_genai.py",
    "content": "from google import genai\nfrom google.genai import types\nfrom PIL import Image\nfrom dotenv import load_dotenv\nimport os\nload_dotenv()\n\nclient = genai.Client(\n    http_options=types.HttpOptions(\n        base_url=os.getenv(\"GOOGLE_API_BASE\")\n    ),\n    api_key=os.getenv(\"GOOGLE_API_KEY\")\n)\n\nDEFAULT_ASPECT_RATIO = \"16:9\"  # \"1:1\",\"2:3\",\"3:2\",\"3:4\",\"4:3\",\"4:5\",\"5:4\",\"9:16\",\"16:9\",\"21:9\"\nDEFAULT_RESOLUTION = \"2K\"  # \"1K\", \"2K\", \"4K\"\n\n\ndef gen_image(prompt: str, ref_image_path: str, aspect_ratio: str = DEFAULT_ASPECT_RATIO, \n              resolution: str = DEFAULT_RESOLUTION):\n    response = client.models.generate_content(\n        model=\"gemini-3-pro-image-preview\",\n        contents=[\n            prompt,\n            Image.open(ref_image_path),\n        ],\n        config=types.GenerateContentConfig(\n            response_modalities=['TEXT', 'IMAGE'],\n        )\n    )\n\n    for part in response.parts:\n        if part.text is not None:   \n            print(part.text)\n        else:\n            # Try to get image from part\n            try:\n                image = part.as_image()\n                if image:\n                    return image\n            except Exception:\n                pass\n    \n    return None\n\n\ndef gen_json_text(prompt: str, model: str = \"gemini-3-flash-preview\") -> str:\n    response = client.models.generate_content(\n        model=model, contents=prompt,\n          config=types.GenerateContentConfig(\n             thinking_config=types.ThinkingConfig(thinking_budget=1000),\n         ),\n    )\n    try:\n        return response.text.strip().strip(\"```json\").strip(\"```\").strip()\n    except Exception as err:\n        print(\"text: \", response.text)\n        raise\n\n\ndef gen_text(prompt: str, model: str = \"gemini-3-flash-preview\") -> str:\n    response = client.models.generate_content(\n        model=model, contents=prompt,\n          config=types.GenerateContentConfig(\n             thinking_config=types.ThinkingConfig(thinking_budget=1000),\n         ),\n    )\n    try:\n        return response.text\n    except Exception as err:\n        print(\"text: \", response.text)\n        raise\n\n\nif __name__ == \"__main__\":\n    test_prompt = \"generate a random json\"\n    result = gen_json_text(test_prompt)\n    print(result)\n"
  },
  {
    "path": "v0_demo/lazyllm_genai.py",
    "content": "\"\"\"\nLazyLLM Demo for Image and Text Generation\n\nThis demo module provides simple APIs for image editing/generation and text generation\nusing the LazyLLM framework, mimicking the style of gemini_genai.py.\n\nSupported Image Providers:\n  - qwen (阿里云通义千问)\n  - doubao (火山引擎豆包)\n  - siliconflow (硅基流动)\n\nSupported Text Providers:\n  - deepseek\n  - qwen\n  - doubao\n  - glm\n  - siliconflow\n\nBefore running this demo, you need to configure the providers' api_key in the environment variables based on your choice.\ndefaut source is qwen.\ne.g.:\n    export BANANA_QWEN_API_KEY = \"your-api-key\"\n\n\"\"\"\nimport os\nfrom pathlib import Path\nfrom typing import Optional\nfrom dotenv import load_dotenv\nfrom PIL import Image\nfrom lazyllm.components.formatter import decode_query_with_filepaths\n\n# Load environment variables from project root\n_project_root = Path(__file__).parent.parent\n_env_file = _project_root / '.env'\nload_dotenv(dotenv_path=_env_file, override=True)\n\nimport lazyllm\nfrom lazyllm import LOG\n\n# ===== Configuration =====\nDEFAULT_ASPECT_RATIO = \"16:9\"  # \"1:1\", \"2:3\", \"3:2\", \"3:4\", \"4:3\", \"4:5\", \"5:4\", \"9:16\", \"16:9\", \"21:9\"\nDEFAULT_RESOLUTION = \"2K\"      # \"1K\", \"2K\", \"4K\"\n\n# default sources and models\nDEFAULT_TEXT_SOURCE = 'qwen'\nDEFAULT_TEXT_MODEL = 'deepseek-v3.2'\n\nDEFAULT_IMAGE_SOURCE = 'qwen'\nDEFAULT_IMAGE_MODEL = 'qwen-image-edit-plus'\n\nDEFAULT_VLM_SOURCE = 'qwen'\nDEFAULT_VLM_MODEL = 'qwen-vl-plus'\n\n# ===== Text Generation =====\n\ndef gen_text(prompt: str, \n             source: str = DEFAULT_TEXT_SOURCE,\n             model: str = DEFAULT_TEXT_MODEL,\n            ) -> str:\n    client = lazyllm.namespace('BANANA').OnlineModule(\n        source=source,\n        model=model,\n        type='llm',\n    )\n    result = client(prompt)\n    return result\n\ndef gen_json_text(prompt: str,\n                  source: str = DEFAULT_TEXT_SOURCE,\n                  model: str = DEFAULT_TEXT_MODEL,\n                  ) -> str:\n    text = gen_text(prompt, source=source, model=model)\n    # Clean up JSON formatting (remove markdown code blocks if present)\n    cleaned_text = text.strip().strip(\"```json\").strip(\"```\").strip()\n    return cleaned_text\n\n# ===== Image Generation/Editing =====\n\ndef gen_image(prompt: str,\n              ref_image_path: Optional[str] = None,\n              source: str = DEFAULT_IMAGE_SOURCE,\n              model: str = DEFAULT_IMAGE_MODEL,\n              aspect_ratio: str = DEFAULT_ASPECT_RATIO,\n              resolution: str = DEFAULT_RESOLUTION,\n              ) -> Optional[Image.Image]:\n    # Convert resolution shorthand to actual resolution\n    resolution_map = {\n        \"1K\": \"1920*1080\",\n        \"2K\": \"2048*1080\",\n        \"4K\": \"3840*2160\"\n    }\n    actual_resolution = resolution_map.get(resolution, resolution)\n    client = lazyllm.namespace('BANANA').OnlineModule(\n        source=source,\n        model=model,\n        type='image_editing',\n    )\n    \n    # Prepare file paths if reference image is provided\n    file_paths = None\n    if ref_image_path:\n        if not os.path.exists(ref_image_path):\n            raise FileNotFoundError(f\"Reference image not found: {ref_image_path}\")\n        file_paths = [ref_image_path]\n    response_path = client(prompt, lazyllm_files=file_paths, size=actual_resolution)\n    image_path = decode_query_with_filepaths(response_path)\n    \n    if not image_path:\n        LOG.warning('No images found in response')\n        return None\n    \n    # Extract image path from response\n    if isinstance(image_path, dict):\n        files = image_path.get('files', [])\n        if files and isinstance(files, list) and len(files) > 0:\n            image_path = files[0]\n        else:\n            LOG.warning('No valid image path in response')\n            return None\n    \n    # Load and return image\n    try:\n        image = Image.open(image_path)\n        LOG.info(f'✓ Image loaded successfully from: {image_path}')\n        return image\n    except Exception as e:\n        LOG.error(f'✗ Failed to load image: {e}')\n        return None\n\n\n# ===== Vision/VLM (Image Captioning) =====\n\ndef describe_image(image_path: str,\n                   prompt: Optional[str] = None,\n                   source: str = DEFAULT_VLM_SOURCE,\n                   model: str = DEFAULT_VLM_MODEL,\n                    ) -> str:\n    if not os.path.exists(image_path):\n        raise FileNotFoundError(f\"Image not found: {image_path}\")\n    if not prompt:\n        prompt = \"Please describe this image in detail.\"\n    client = lazyllm.namespace('BANANA').OnlineModule(\n        source=source,\n        model=model,\n        type='vlm',\n    )\n    \n    # Call with image file path\n    result = client(prompt, lazyllm_files=[image_path])\n    LOG.info(f\"✓ Image description generated successfully from {source}\")\n    return result\n\n\n# ===== Demo/Testing =====\n\nif __name__ == \"__main__\":\n    print(\"=\" * 60)\n    print(\"LazyLLM Demo - Text and Image Generation\")\n    print(\"=\" * 60)\n    \n    # Test 1: Text Generation\n    print(\"\\n[Test 1] Text Generation (Deepseek)\")\n    try:\n        text = gen_text(\"中国的首都是哪里?\")\n        print(f\"Result: {text[:100]}...\")\n    except Exception as e:\n        print(f\"Error: {e}\")\n    \n    # Test 2: JSON Text Generation\n    print(\"\\n[Test 2] JSON Text Generation\")\n    try:\n        json_text = gen_json_text(\n            \"随机生成一个JSON文件，包含姓名、年龄、性别三个字段\"\n        )\n        print(f\"Result: {json_text}\")\n    except Exception as e:\n        print(f\"Error: {e}\")\n    \n    # Test 3: Image Generation and Editing\n    print(\"\\n[Test 3] Image Generation (Qwen)\")\n    try:\n        image = gen_image(\n            \"在参考图片中插入 'lazyllm' 这串英文\",\n            ref_image_path='path/to/your/image.png', # depending on your local image path\n            source=\"qwen\",\n            resolution=\"2K\"\n        )\n        if image:\n            print(f\"✓ Image generated: {image.size}\")\n    except Exception as e:\n        print(f\"Error: {e}\")\n    \n    # Test 4: Image Description\n    print(\"\\n[Test 4] Image Description (Qwen VLM)\")\n    try:\n        # Create a test image if it doesn't exist\n        test_image_path = 'path/to/your/image.png' # depending on your local image path\n        if not os.path.exists(test_image_path):\n            print(f\"Please provide a test image at {test_image_path}\")\n        else:\n            caption = describe_image(test_image_path)\n            print(f\"Caption: {caption}\")\n    except Exception as e:\n        print(f\"Error: {e}\")\n    \n    print(\"\\n\" + \"=\" * 60)\n    print(\"Demo Complete!\")\n    print(\"=\" * 60)"
  }
]