Showing preview only (2,462K chars total). Download the full file or copy to clipboard to get everything.
Repository: Anionex/banana-slides
Branch: main
Commit: 2a0b45540be5
Files: 333
Total size: 2.3 MB
Directory structure:
gitextract_oydoioei/
├── .dockerignore
├── .githooks/
│ └── pre-commit.disabled
├── .github/
│ ├── CI_SETUP.md
│ ├── ISSUE_TEMPLATE/
│ │ └── bug_report.yml
│ ├── PULL_REQUEST_TEMPLATE.md
│ └── workflows/
│ ├── build-sha-image.yml
│ ├── ci-test.yml
│ ├── docker-publish.yml
│ ├── nightly.yml
│ ├── pr-quick-check.yml
│ └── translate-readme.yml
├── .gitignore
├── CLA.md
├── CONTRIBUTING.md
├── Dockerfile.allinone
├── LICENSE
├── README.md
├── backend/
│ ├── .gitignore
│ ├── Dockerfile
│ ├── README.md
│ ├── alembic.ini
│ ├── app.py
│ ├── config.py
│ ├── controllers/
│ │ ├── __init__.py
│ │ ├── export_controller.py
│ │ ├── file_controller.py
│ │ ├── material_controller.py
│ │ ├── page_controller.py
│ │ ├── project_controller.py
│ │ ├── reference_file_controller.py
│ │ ├── settings_controller.py
│ │ └── template_controller.py
│ ├── migrations/
│ │ ├── env.py
│ │ ├── script.py.mako
│ │ └── versions/
│ │ ├── 001_baseline_schema.py
│ │ ├── 002_create_settings_table.py
│ │ ├── 003_add_model_and_mineru_settings.py
│ │ ├── 004_add_template_style_to_projects.py
│ │ ├── 005_add_pdf_image_path.py
│ │ ├── 006_add_export_settings_to_projects.py
│ │ ├── 007_add_enable_reasoning_to_settings.py
│ │ ├── 008_add_baidu_ocr_api_key_to_settings.py
│ │ ├── 009_split_reasoning_config.py
│ │ ├── 010_add_cached_image_path.py
│ │ ├── 011_add_user_template_thumb.py
│ │ ├── 012_add_export_allow_partial_to_projects.py
│ │ ├── 013_add_lazyllm_source_fields.py
│ │ ├── 014_add_per_model_provider_config.py
│ │ ├── 015_rename_baidu_ocr_api_key.py
│ │ ├── 38292967f3ca_add_output_language_to_settings_table.py
│ │ ├── 64ecc9f34de0_add_description_generation_mode_to_.py
│ │ ├── 7acf21d5e41d_make_settings_columns_nullable_for_env_.py
│ │ ├── 88054bda1ece_add_outline_and_description_.py
│ │ ├── 9439faddcdd5_add_description_extra_fields_to_settings.py
│ │ ├── 9ad736fec43d_add_image_prompt_extra_fields_to_.py
│ │ ├── a912a64b7a86_add_mineru_token_to_settings_table.py
│ │ └── ee22f1512027_add_image_aspect_ratio_to_project.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── material.py
│ │ ├── page.py
│ │ ├── page_image_version.py
│ │ ├── project.py
│ │ ├── reference_file.py
│ │ ├── settings.py
│ │ ├── task.py
│ │ └── user_template.py
│ ├── run.bat
│ ├── run.sh
│ ├── server.log
│ ├── server_running.log
│ ├── services/
│ │ ├── __init__.py
│ │ ├── ai_providers/
│ │ │ ├── __init__.py
│ │ │ ├── genai_client.py
│ │ │ ├── image/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── baidu_inpainting_provider.py
│ │ │ │ ├── base.py
│ │ │ │ ├── gemini_inpainting_provider.py
│ │ │ │ ├── genai_provider.py
│ │ │ │ ├── lazyllm_provider.py
│ │ │ │ ├── openai_provider.py
│ │ │ │ └── volcengine_inpainting_provider.py
│ │ │ ├── lazyllm_env.py
│ │ │ ├── ocr/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── baidu_accurate_ocr_provider.py
│ │ │ │ └── baidu_table_ocr_provider.py
│ │ │ └── text/
│ │ │ ├── __init__.py
│ │ │ ├── base.py
│ │ │ ├── genai_provider.py
│ │ │ ├── lazyllm_provider.py
│ │ │ └── openai_provider.py
│ │ ├── ai_service.py
│ │ ├── ai_service_manager.py
│ │ ├── export_service.py
│ │ ├── file_parser_service.py
│ │ ├── file_service.py
│ │ ├── image_editability/
│ │ │ ├── __init__.py
│ │ │ ├── coordinate_mapper.py
│ │ │ ├── data_models.py
│ │ │ ├── extractors.py
│ │ │ ├── factories.py
│ │ │ ├── helpers.py
│ │ │ ├── hybrid_extractor.py
│ │ │ ├── inpaint_providers.py
│ │ │ ├── service.py
│ │ │ └── text_attribute_extractors.py
│ │ ├── inpainting_service.py
│ │ ├── pdf_service.py
│ │ ├── prompts.py
│ │ └── task_manager.py
│ ├── tests/
│ │ ├── conftest.py
│ │ ├── integration/
│ │ │ ├── README.md
│ │ │ ├── __init__.py
│ │ │ ├── test_api_full_flow.py
│ │ │ └── test_full_workflow.py
│ │ ├── pytest.ini
│ │ └── unit/
│ │ ├── __init__.py
│ │ ├── test_ai_mock.py
│ │ ├── test_api_health.py
│ │ ├── test_api_material.py
│ │ ├── test_api_project.py
│ │ ├── test_api_settings_provider.py
│ │ ├── test_editable_pptx_style_extraction.py
│ │ ├── test_file_parser_service.py
│ │ ├── test_image_prompt_ratio.py
│ │ ├── test_lazyllm_image_content_type.py
│ │ └── test_smart_merge.py
│ └── utils/
│ ├── __init__.py
│ ├── image_utils.py
│ ├── latex_utils.py
│ ├── mask_utils.py
│ ├── page_utils.py
│ ├── path_utils.py
│ ├── pptx_builder.py
│ ├── response.py
│ └── validators.py
├── create-test-data.mjs
├── create-test-data.sh
├── docker/
│ ├── nginx-allinone.conf
│ ├── start-backend.sh
│ └── supervisord.conf
├── docker-compose.allinone.yml
├── docker-compose.prod.yml
├── docker-compose.yml
├── docs/
│ ├── configuration.mdx
│ ├── docs.json
│ ├── faq.mdx
│ ├── features/
│ │ ├── creation.mdx
│ │ ├── descriptions.mdx
│ │ ├── editing.mdx
│ │ ├── export.mdx
│ │ ├── images.mdx
│ │ ├── import-export.mdx
│ │ ├── materials.mdx
│ │ ├── outline.mdx
│ │ └── overview.mdx
│ ├── history.mdx
│ ├── index.mdx
│ ├── logo/
│ │ └── .gitkeep
│ ├── quickstart.mdx
│ └── zh/
│ ├── configuration.mdx
│ ├── faq.mdx
│ ├── features/
│ │ ├── creation.mdx
│ │ ├── descriptions.mdx
│ │ ├── editing.mdx
│ │ ├── export.mdx
│ │ ├── images.mdx
│ │ ├── import-export.mdx
│ │ ├── materials.mdx
│ │ ├── outline.mdx
│ │ └── overview.mdx
│ ├── history.mdx
│ ├── index.mdx
│ └── quickstart.mdx
├── frontend/
│ ├── .eslintrc.cjs
│ ├── .gitignore
│ ├── Dockerfile
│ ├── README.md
│ ├── e2e/
│ │ ├── README.md
│ │ ├── access-code.spec.ts
│ │ ├── aspect-ratio-lock-integration.spec.ts
│ │ ├── aspect-ratio-lock.spec.ts
│ │ ├── attachment-sort-filter.spec.ts
│ │ ├── badge-status-after-generation.spec.ts
│ │ ├── desc-regeneration-skeleton.spec.ts
│ │ ├── description-detail-level.spec.ts
│ │ ├── description-no-flicker.spec.ts
│ │ ├── editable-export-failure.spec.ts
│ │ ├── export-aspect-ratio.spec.ts
│ │ ├── export-images.spec.ts
│ │ ├── extract-style-caption.spec.ts
│ │ ├── failed-file-reselect.spec.ts
│ │ ├── file-preview-scrollbar.spec.ts
│ │ ├── generation-fail.spec.ts
│ │ ├── generation-requirements.spec.ts
│ │ ├── helpers/
│ │ │ └── seed-project.ts
│ │ ├── history-pagination.spec.ts
│ │ ├── image-prompt-ratio.spec.ts
│ │ ├── image-queued-status.spec.ts
│ │ ├── import-markdown.spec.ts
│ │ ├── lazyllm-global-vendor.spec.ts
│ │ ├── lazyllm-image-content-type.spec.ts
│ │ ├── markdown-card-style.spec.ts
│ │ ├── material-aspect-ratio.spec.ts
│ │ ├── outline-autosave-blur.spec.ts
│ │ ├── outline-null-crash.spec.ts
│ │ ├── parsing-preview-toast.spec.ts
│ │ ├── pdf-export-metadata.spec.ts
│ │ ├── per-model-startup-creds.spec.ts
│ │ ├── preset-capsules.spec.ts
│ │ ├── preview-text-style-template.spec.ts
│ │ ├── renovation-aspect-ratio.spec.ts
│ │ ├── settings-api-clarity.spec.ts
│ │ ├── settings-api-links.spec.ts
│ │ ├── settings-back-to-top.spec.ts
│ │ ├── settings-backfill.spec.ts
│ │ ├── settings-env-fallback.spec.ts
│ │ ├── settings-per-model-provider-integration.spec.ts
│ │ ├── settings-per-model-provider.spec.ts
│ │ ├── settings-read-only.spec.ts
│ │ ├── settings-reset-fallback.spec.ts
│ │ ├── settings-test-vendor-format.spec.ts
│ │ ├── smart-merge.spec.ts
│ │ ├── streaming-descriptions.spec.ts
│ │ ├── streaming-outline.spec.ts
│ │ ├── ui-full-flow-mocked.spec.ts
│ │ ├── ui-full-flow.spec.ts
│ │ ├── upload-folder-path.spec.ts
│ │ ├── ux-polish-i18n.spec.ts
│ │ └── visual-regression.spec.ts
│ ├── index.html
│ ├── nginx.conf
│ ├── package.json
│ ├── playwright.config.ts
│ ├── postcss.config.js
│ ├── src/
│ │ ├── App.tsx
│ │ ├── api/
│ │ │ ├── client.ts
│ │ │ └── endpoints.ts
│ │ ├── components/
│ │ │ ├── history/
│ │ │ │ └── ProjectCard.tsx
│ │ │ ├── outline/
│ │ │ │ └── OutlineCard.tsx
│ │ │ ├── preview/
│ │ │ │ ├── DescriptionCard.tsx
│ │ │ │ └── SlideCard.tsx
│ │ │ └── shared/
│ │ │ ├── AccessCodeGuard.tsx
│ │ │ ├── AiRefineInput.tsx
│ │ │ ├── Button.tsx
│ │ │ ├── Card.tsx
│ │ │ ├── ConfirmDialog.tsx
│ │ │ ├── ContextualStatusBadge.tsx
│ │ │ ├── ExportTasksPanel.tsx
│ │ │ ├── FilePreviewModal.tsx
│ │ │ ├── Footer.tsx
│ │ │ ├── GithubBadge.tsx
│ │ │ ├── GithubRepoCard.tsx
│ │ │ ├── HelpModal.tsx
│ │ │ ├── ImagePreviewList.tsx
│ │ │ ├── Input.tsx
│ │ │ ├── Loading.tsx
│ │ │ ├── Markdown.tsx
│ │ │ ├── MarkdownTextarea.tsx
│ │ │ ├── MaterialCenterModal.tsx
│ │ │ ├── MaterialGeneratorModal.tsx
│ │ │ ├── MaterialSelector.tsx
│ │ │ ├── Modal.tsx
│ │ │ ├── Pagination.tsx
│ │ │ ├── PresetCapsules.tsx
│ │ │ ├── ProjectResourcesList.tsx
│ │ │ ├── ProjectSettingsModal.tsx
│ │ │ ├── ReferenceFileCard.tsx
│ │ │ ├── ReferenceFileList.tsx
│ │ │ ├── ReferenceFileSelector.tsx
│ │ │ ├── ShimmerOverlay.tsx
│ │ │ ├── StatusBadge.tsx
│ │ │ ├── TemplateSelector.tsx
│ │ │ ├── TextStyleSelector.tsx
│ │ │ ├── Textarea.tsx
│ │ │ ├── Toast.tsx
│ │ │ └── index.ts
│ │ ├── config/
│ │ │ ├── aspectRatio.ts
│ │ │ ├── presetStyles.ts
│ │ │ └── presetStylesI18n.ts
│ │ ├── hooks/
│ │ │ ├── useGeneratingState.ts
│ │ │ ├── useImagePaste.ts
│ │ │ ├── usePageStatus.ts
│ │ │ ├── useT.ts
│ │ │ └── useTheme.ts
│ │ ├── i18n.ts
│ │ ├── index.css
│ │ ├── locales/
│ │ │ ├── en.json
│ │ │ └── zh.json
│ │ ├── main.tsx
│ │ ├── pages/
│ │ │ ├── DetailEditor.tsx
│ │ │ ├── History.tsx
│ │ │ ├── Home.tsx
│ │ │ ├── Landing.tsx
│ │ │ ├── OutlineEditor.tsx
│ │ │ ├── Settings.tsx
│ │ │ └── SlidePreview.tsx
│ │ ├── store/
│ │ │ ├── useExportTasksStore.ts
│ │ │ └── useProjectStore.ts
│ │ ├── tests/
│ │ │ ├── components/
│ │ │ │ ├── Button.test.tsx
│ │ │ │ ├── DescriptionCard.test.tsx
│ │ │ │ └── Markdown.test.tsx
│ │ │ ├── setup.ts
│ │ │ ├── store/
│ │ │ │ ├── useProjectStore.initializeProject.test.ts
│ │ │ │ └── useProjectStore.test.ts
│ │ │ └── utils.normalizeErrorMessage.test.ts
│ │ ├── types/
│ │ │ └── index.ts
│ │ ├── utils/
│ │ │ ├── i18nHelper.ts
│ │ │ ├── index.ts
│ │ │ ├── logger.ts
│ │ │ └── projectUtils.ts
│ │ └── vite-env.d.ts
│ ├── start.bat
│ ├── start.sh
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── package.json
├── pyproject.toml
├── scripts/
│ ├── export_editable_pptx.py
│ ├── pre-push-check.sh
│ ├── run-local-ci.sh
│ ├── setup-env-from-secrets.sh
│ ├── setup_git_hooks.sh
│ ├── test_docker_environment.sh
│ ├── translate_readme.py
│ ├── translate_readme_incremental.py
│ ├── verify-e2e-refactoring.sh
│ └── wait-for-health.sh
├── tests/
│ └── docker/
│ └── test_docker_environment.sh
└── v0_demo/
├── demo.py
├── gemini_genai.py
└── lazyllm_genai.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
*.egg
.venv/
venv/
env/
ENV/
# Node
**/node_modules/
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.npm
.eslintcache
frontend/node_modules/
frontend/dist/
frontend/.vite/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Project specific
instance/*.db
instance/*.db-journal
uploads/
*.log
server.log
server_running.log
# Test files
test_*.py
*_test.py
tests/
# Docs and examples
*.md
!README.md
docs/
demo.py
gemini_genai.py
generate-example.py
!assets/**
*.png
*.jpg
*.jpeg
*.pptx
*.pdf
output/
res.png
template*.png
page*.png
# Git
.git/
.gitignore
# Docker
Dockerfile*
docker-compose*.yml
.dockerignore
# Environment variables
.env
.env.local
.env.*.local
# Others
*.lock
uv.lock
LICENSE
PRD.md
*.md
# Windows
Thumbs.db
Desktop.ini
*.lnk
# Build output
dist/
build/
*.pptx
*.pdf
================================================
FILE: .githooks/pre-commit.disabled
================================================
#!/bin/bash
# Pre-commit hook: 自动翻译README.md到README_EN.md
# 只有在README.md被修改时才会触发
set -e
# 检查README.md是否在本次提交中被修改
if git diff --cached --name-only | grep -q "^README\.md$"; then
echo "检测到README.md变更,正在自动翻译到README_EN.md..."
# 检查是否在项目根目录
if [ ! -f "README.md" ]; then
echo "错误: 未找到README.md"
exit 1
fi
# 检查.env文件
if [ ! -f ".env" ]; then
echo "警告: 未找到.env文件,跳过翻译"
echo "如需自动翻译,请确保.env文件包含必要的API密钥配置"
exit 0
fi
# 加载环境变量
set -a
source .env 2>/dev/null || true
set +a
# 检查必要的环境变量
if [ -z "$GOOGLE_API_KEY" ]; then
echo "警告: GOOGLE_API_KEY未设置,跳过翻译"
echo "如需自动翻译,请在.env文件中设置GOOGLE_API_KEY"
exit 0
fi
# 检查uv是否可用
if ! command -v uv &> /dev/null; then
echo "警告: uv未安装,跳过翻译"
exit 0
fi
# 运行翻译脚本
echo "开始翻译..."
if uv run python scripts/translate_readme.py; then
echo "✅ 翻译成功!"
# 将翻译后的README_EN.md添加到本次提交
git add README_EN.md
echo "README_EN.md已自动添加到本次提交"
else
echo "❌ 翻译失败,但不阻止提交"
echo "你可以稍后手动运行: ./scripts/test_translation.sh"
# 不阻止提交,允许继续
exit 0
fi
else
# README.md未修改,无需翻译
exit 0
fi
================================================
FILE: .github/CI_SETUP.md
================================================
# CI/CD 配置说明
本项目使用GitHub Actions实现自动化CI/CD,包含**Light检查**和**Full测试**两个层级。
## 📋 CI架构概览
### 🚀 Light检查 - PR快速反馈
**触发时机**: 提交PR时自动运行
**耗时**: 2-5分钟
**工作流**: `.github/workflows/pr-quick-check.yml`
包含:
- ✅ 代码语法检查(flake8, ESLint)
- ✅ 代码格式检查(black, prettier)
- ✅ TypeScript构建检查
- ✅ 后端冒烟测试(健康检查)
- ✅ PR自动评论
### 🎯 Full测试 - 完整验证
**触发时机**:
1. **PR添加`ready-for-test`标签时** 👈 推荐方式
2. 直接Push到`main`或`develop`分支(不通过PR)
**注意**:PR合并后**不会**再次运行完整测试,避免重复浪费资源
**耗时**: 15-30分钟
**工作流**: `.github/workflows/ci-test.yml`
包含:
- ✅ 后端单元测试(pytest + coverage)
- ✅ 后端集成测试(使用 mock AI)
- ✅ 前端测试(Vitest + coverage)
- ✅ Docker 环境测试(容器构建、启动、健康检查)
- ✅ **E2E 测试(从创建到导出 PPT)**
- 需要真实 Google Gemini API key
- 测试完整的 AI 生成流程
- 如果未配置 API key,会自动跳过并显示说明
- ✅ 安全扫描(依赖漏洞检查)
---
## 🔧 配置步骤
### 1. 配置GitHub Secrets(必需)
为了运行完整的E2E测试(包含真实AI生成),需要配置以下Secrets:
#### 步骤:
1. 进入GitHub仓库页面
2. 点击 `Settings` → `Secrets and variables` → `Actions`
3. 点击 `New repository secret`
4. 添加以下Secret:
| Secret名称 | 必需 | 说明 | 获取方式 |
|-----------|------|------|---------|
| `GOOGLE_API_KEY` | ✅ 必需 | Google Gemini API密钥(用于完整E2E测试) | [https://aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey) |
| `OPENAI_API_KEY` | ⚪ 可选 | OpenAI API密钥(用于集成测试验证兼容性) | [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys) |
| `SECRET_KEY` | ⚪ 可选 | Flask应用密钥(生产环境建议配置) | 随机生成,建议使用:`python -c "import secrets; print(secrets.token_hex(32))"` |
| `MINERU_TOKEN` | ⚪ 可选 | MinerU服务Token(如果使用MinerU解析) | 从MinerU服务获取 |
**关于 E2E 测试策略**:
- 💡 **单一 E2E 测试**:使用 Gemini 格式测试完整流程(创建→大纲→描述→图片→导出)
- 💰 **成本优化**:只运行一次完整 E2E,避免重复测试
- ⚠️ **条件运行**:只在配置了真实 `GOOGLE_API_KEY` 时运行
**注意**:
- ⚠️ **没有配置 `GOOGLE_API_KEY` 时,E2E 测试会被跳过**
- ✅ 其他测试(单元、集成、Docker)仍会运行,覆盖大部分功能
- 💰 真实 API 调用会消耗配额(约 $0.01-0.05/次),建议使用测试专用账号
- 🔧 CI 会自动将 Secrets 替换到 `.env` 文件中对应的占位符
**CI如何处理Secrets**:
CI配置会自动处理以下逻辑:
1. **复制`.env.example`到`.env`**(保持所有默认配置)
2. **自动检测并替换Secrets**:
- 如果GitHub Secrets中配置了某个Secret → 自动替换`.env`中对应的占位符
- 如果没有配置 → 保持`.env.example`中的默认值
**支持的Secrets列表**:
CI配置会自动检测并替换以下Secrets(如果配置了的话):
- ✅ `GOOGLE_API_KEY` - 必需,如果没有配置则使用`mock-api-key`
- ⚪ `OPENAI_API_KEY` - 可选,如果配置了则替换
- ⚪ `SECRET_KEY` - 可选,生产环境建议配置
- ⚪ `MINERU_TOKEN` - 可选,如果使用MinerU服务则配置
**添加新的Secret支持**:
如果需要支持其他配置项的Secret替换,只需在`.github/workflows/ci-test.yml`中添加对应的检查逻辑:
```yaml
# 在"设置环境变量"步骤中添加
if [ -n "${{ secrets.YOUR_NEW_SECRET }}" ]; then
sed -i '/^YOUR_ENV_VAR=/s/placeholder/${{ secrets.YOUR_NEW_SECRET }}/' .env
echo "✓ 已替换 YOUR_ENV_VAR"
fi
```
### 2. (可选)配置CodeCov
如果需要代码覆盖率报告和徽章:
1. 访问 [codecov.io](https://codecov.io)
2. 关联GitHub账号并授权仓库
3. 获取Upload Token(通常不需要,公开仓库自动识别)
4. 如需手动配置,添加Secret:`CODECOV_TOKEN`
---
## 🏷️ 如何触发Full测试
### 方法1:PR添加标签触发(✅ 推荐)
当你认为PR已经准备好进行完整测试时:
```bash
# 在PR页面右侧,点击 "Labels"
# 添加 "ready-for-test" 标签
```
这会立即触发完整测试套件,包括:
- ✅ 所有单元和集成测试
- ✅ Docker 环境测试
- ✅ **E2E 测试(如果配置了真实 API key)**
**测试通过后,直接合并即可!合并后不会重复运行测试。**
**E2E 测试说明**:
- 如果配置了 `GOOGLE_API_KEY`:运行完整 E2E(额外 10-15 分钟)
- 如果未配置:跳过 E2E,显示友好说明(其他测试已覆盖大部分功能)
### 方法2:手动触发(✅ 新增)
在GitHub Actions页面手动运行Full Test:
1. 进入仓库页面
2. 点击 **Actions** 标签
3. 在左侧选择 **Full Test Suite**
4. 点击右侧的 **Run workflow** 按钮
5. 选择分支(通常是`main`或`develop`)
6. 点击 **Run workflow**
**适用场景**:
- ✅ 想在任何时候验证代码
- ✅ 调试CI问题
- ✅ 验证main分支的当前状态
### 方法3:直接Push到main
如果你直接push到`main`或`develop`分支(不通过PR),会自动运行完整测试。
**注意**:
- ⚠️ **PR合并不会触发Full测试**(避免重复)
- ✅ 请确保PR在合并前已通过`ready-for-test`测试
- 🔒 建议在仓库设置中启用分支保护,要求`ready-for-test`状态通过才能合并
---
## 🔒 建议:启用分支保护规则
为了确保所有PR在合并前都经过完整测试,建议配置GitHub分支保护:
### 配置步骤
1. 进入仓库 → `Settings` → `Branches`
2. 在 `Branch protection rules` 下点击 `Add rule`
3. 配置如下:
- **Branch name pattern**: `main`
- ✅ **Require status checks to pass before merging**
- 搜索并勾选 `Backend Unit Tests`(或其他关键测试)
- ✅ **Require branches to be up to date before merging**
- 可选:**Require pull request reviews before merging**
### 效果
配置后,PR只有在以下条件满足时才能合并:
- ✅ Light检查通过(自动运行)
- ✅ Full测试通过(通过`ready-for-test`标签触发)
- ✅ 代码review通过(如果启用)
这样可以完全避免未测试代码进入`main`分支!
---
## 🧪 测试文件说明
### Light检查测试
- **前端Lint**: `frontend/src/**/*.{ts,tsx}`
- **后端语法**: `backend/**/*.py`
- **冒烟测试**: 启动后端并检查`/health`端点
### Full测试文件
```
backend/tests/
├── unit/ # 后端单元测试
│ ├── test_ai_service.py
│ ├── test_file_service.py
│ └── ...
├── integration/ # 后端集成测试
│ ├── test_api.py
│ └── ...
frontend/src/
├── **/*.test.tsx # 前端组件测试
└── **/*.spec.tsx # 前端功能测试
e2e/
├── home.spec.ts # 首页UI测试
├── create-ppt.spec.ts # PPT创建基础测试
└── full-flow.spec.ts # 🎯 完整流程测试(创建→大纲→描述→图片→导出)
```
---
## 📊 测试结果查看
### CI状态检查
- PR页面底部会显示所有检查状态
- 点击 `Details` 查看详细日志
- Light检查会在PR评论中自动发布结果
### 测试报告和覆盖率
- **代码覆盖率**: 自动上传到CodeCov(如果配置)
- **E2E测试报告**: 失败时会上传Playwright报告和截图
- 在Actions页面 → 对应的workflow run → `Artifacts` 下载
- `playwright-report`: HTML测试报告
- `playwright-screenshots`: 失败时的截图和视频
### 查看日志
```bash
# 本地查看Actions日志
gh run list
gh run view <run-id> --log
```
---
## 🚨 常见问题
### Q1: E2E测试超时失败
**原因**: AI生成需要较长时间
**解决**:
- 检查API key是否有效
- 检查API配额是否用尽
- 本地运行测试验证:`npx playwright test full-flow.spec.ts`
### Q2: Docker测试失败
**原因**: 容器启动超时或端口冲突
**解决**:
- 检查`docker-compose.yml`配置
- 查看容器日志(CI会在失败时自动显示)
- 本地测试:`./scripts/test_docker_environment.sh`
### Q3: 前端构建失败
**原因**: TypeScript类型错误或依赖问题
**解决**:
- 本地运行:`cd frontend && npm run build:check`
- 检查`frontend/package.json`依赖版本
- 确保`package-lock.json`已提交
### Q4: "ready-for-test"标签不触发测试
**原因**: Workflow权限或配置问题
**解决**:
- 确认标签名称完全匹配(小写,带连字符)
- 检查仓库Settings → Actions → General → Workflow permissions
- 查看Actions页面确认workflow是否被触发
---
## 📝 本地测试
### 🚀 快速开始
```bash
# Light检查(2-3分钟)- 提交前快速检查
./scripts/run-local-ci.sh light
# Full测试(10-20分钟)- PR合并前完整测试
./scripts/run-local-ci.sh full
```
### 🔧 前置依赖
```bash
# Python环境 (>= 3.10)
python3 --version
# Node.js环境 (>= 18)
node --version
# UV包管理器
curl -LsSf https://astral.sh/uv/install.sh | sh
# Docker
docker --version
docker compose --version
# 安装依赖
uv sync --extra test
cd frontend && npm ci
npx playwright install --with-deps chromium
```
### 🧪 运行特定测试
```bash
# 后端单元测试
cd backend
uv run pytest tests/unit -v --cov=. --cov-report=html
# 前端测试
cd frontend
npm test -- --coverage
# E2E测试(需要真实API key)
cp .env.example .env # 编辑.env填入真实API密钥
docker compose up -d
npx playwright test full-flow.spec.ts
# Docker环境测试
./scripts/test_docker_environment.sh
```
### 🐛 调试失败的测试
```bash
# E2E UI模式调试
npx playwright test --ui
# 后端调试模式
cd backend
uv run pytest tests/unit/test_xxx.py --pdb
# 查看Docker日志
docker compose logs backend
docker compose logs frontend
```
---
## 🎯 最佳实践
### 开发流程建议
1. **开发阶段**:
- 频繁提交小改动
- 依赖Light检查快速反馈
- 修复lint和构建错误
2. **功能完成后**:
- 自测主要功能
- 运行本地测试套件
- 提交PR
3. **准备合并前**:
- 添加`ready-for-test`标签 👈 **关键步骤**
- 等待Full测试通过
- Code review通过后合并
- 合并后**不会重复运行测试**,节省资源 ✅
4. **合并后**:
- 代码直接进入`main`分支
- 无需等待额外的CI运行
- 节省时间和成本
### CI优化建议
- ✅ 保持测试快速(单元测试 < 5分钟)
- ✅ E2E测试只验证关键流程
- ✅ 使用缓存加速依赖安装
- ✅ 并行运行独立测试
- ✅ 失败快速反馈(fail-fast)
---
## 📚 相关文档
- [GitHub Actions文档](https://docs.github.com/en/actions)
- [Playwright测试文档](https://playwright.dev)
- [pytest文档](https://docs.pytest.org)
- [Vitest文档](https://vitest.dev)
---
## 🆘 需要帮助?
如果遇到CI问题:
1. 查看Actions日志详细错误信息
2. 参考本文档常见问题部分
3. 在issue中提问并附上错误日志
4. 联系维护者
---
**最后更新**: 2025-01-20
**维护者**: Banana Slides Team
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.yml
================================================
name: Bug Report / 问题反馈
description: Report a bug or issue / 报告错误或问题
labels: ["bug"]
body:
- type: dropdown
id: deployment
attributes:
label: Deployment Method / 部署方式
description: Where did you encounter this issue? / 你在哪里遇到了这个问题?
options:
- Demo Website / 在线 Demo (bananaslides.online)
- Docker Compose (docker-compose.yml)
- Docker Compose with Pre-built Images (docker-compose.prod.yml)
- Local Development / 本地开发 (uv + npm)
- Cloud Platform / 云平台部署 (雨云等)
- Other / 其他
validations:
required: true
- type: textarea
id: description
attributes:
label: Issue Description / 问题描述
description: Describe the issue you encountered / 描述你遇到的问题
placeholder: What happened? / 发生了什么?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce / 复现步骤
description: How can we reproduce this issue? / 如何复现这个问题?
placeholder: |
1. Go to... / 进入...
2. Click on... / 点击...
3. See error / 看到错误
validations:
required: false
- type: textarea
id: expected
attributes:
label: Expected Behavior / 期望行为
description: What did you expect to happen? / 你期望发生什么?
validations:
required: false
- type: textarea
id: logs
attributes:
label: Logs / 日志
description: If applicable, paste relevant logs (docker logs, browser console, etc.) / 如果适用,粘贴相关日志
render: shell
validations:
required: false
- type: input
id: version
attributes:
label: Version / 版本
description: Which version are you using? / 你使用的是哪个版本?
placeholder: v0.4.0 or commit hash / v0.4.0 或 commit hash
validations:
required: false
================================================
FILE: .github/PULL_REQUEST_TEMPLATE.md
================================================
## Summary
<!-- Briefly describe the changes in this PR -->
## Changed Files
<!-- List the key files changed and what was modified -->
## Test Plan
<!-- Describe how you tested the changes -->
## CLA
- [ ] I have read the [Contributor License Agreement](CLA.md) and [Contributing Guidelines](CONTRIBUTING.md), and I agree to the CLA
================================================
FILE: .github/workflows/build-sha-image.yml
================================================
name: Build SHA Image
on:
workflow_dispatch:
inputs:
sha:
description: 'Git SHA to build (full or short)'
required: true
type: string
image_type:
description: 'Which images to build'
required: true
type: choice
options:
- allinone
- split
- both
default: allinone
tag:
description: 'Additional Docker tag (e.g. latest). Leave empty to only tag with SHA.'
required: false
type: string
run_tests:
description: 'Run unit tests before building'
required: false
type: boolean
default: false
concurrency:
group: build-sha-${{ inputs.sha }}
cancel-in-progress: false
jobs:
build-and-push:
name: Build ${{ inputs.image_type }} image(s) at SHA
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Checkout target SHA
run: git checkout ${{ inputs.sha }}
- name: Resolve SHA
id: resolve
run: |
echo "full_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Overlay Dockerfiles from main
run: |
git fetch origin main
if [[ "${{ inputs.image_type }}" != "split" ]]; then
git checkout origin/main -- Dockerfile.allinone docker/
fi
if [[ "${{ inputs.image_type }}" != "allinone" ]]; then
git checkout origin/main -- backend/Dockerfile frontend/Dockerfile frontend/nginx.conf
fi
- name: Install uv
if: ${{ inputs.run_tests }}
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Run backend unit tests
if: ${{ inputs.run_tests }}
run: |
uv sync --extra test
uv run pytest backend/tests/unit -v
- name: Run frontend lint + tests
if: ${{ inputs.run_tests }}
run: |
cd frontend && npm ci
npm run lint
npm test -- --run
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute tags
id: tags
env:
USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
SHORT_SHA: ${{ steps.resolve.outputs.short_sha }}
CUSTOM_TAG: ${{ inputs.tag }}
IMAGE_TYPE: ${{ inputs.image_type }}
run: |
compute_tags() {
local image="$1"
local tags="${USERNAME}/${image}:sha-${SHORT_SHA}"
if [ -n "$CUSTOM_TAG" ]; then
tags="${tags},${USERNAME}/${image}:${CUSTOM_TAG}"
fi
echo "$tags"
}
if [[ "$IMAGE_TYPE" != "split" ]]; then
ALLINONE_TAGS=$(compute_tags "banana-slides")
echo "allinone=$ALLINONE_TAGS" >> $GITHUB_OUTPUT
echo "All-in-one tags: $ALLINONE_TAGS"
fi
if [[ "$IMAGE_TYPE" != "allinone" ]]; then
BACKEND_TAGS=$(compute_tags "banana-slides-backend")
FRONTEND_TAGS=$(compute_tags "banana-slides-frontend")
echo "backend=$BACKEND_TAGS" >> $GITHUB_OUTPUT
echo "frontend=$FRONTEND_TAGS" >> $GITHUB_OUTPUT
echo "Backend tags: $BACKEND_TAGS"
echo "Frontend tags: $FRONTEND_TAGS"
fi
- name: Build and push all-in-one image
if: inputs.image_type == 'allinone' || inputs.image_type == 'both'
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.allinone
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.tags.outputs.allinone }}
cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides:buildcache,mode=max
build-args: |
DOCKER_REGISTRY=${{ secrets.DOCKER_REGISTRY }}
GHCR_REGISTRY=${{ secrets.GHCR_REGISTRY || 'ghcr.io/' }}
APT_MIRROR=${{ secrets.APT_MIRROR }}
PYPI_INDEX_URL=${{ secrets.PYPI_INDEX_URL }}
NPM_REGISTRY=${{ secrets.NPM_REGISTRY }}
- name: Build and push backend image
if: inputs.image_type == 'split' || inputs.image_type == 'both'
uses: docker/build-push-action@v5
with:
context: .
file: ./backend/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.tags.outputs.backend }}
cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-backend:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-backend:buildcache,mode=max
build-args: |
DOCKER_REGISTRY=${{ secrets.DOCKER_REGISTRY }}
GHCR_REGISTRY=${{ secrets.GHCR_REGISTRY || 'ghcr.io/' }}
APT_MIRROR=${{ secrets.APT_MIRROR }}
PYPI_INDEX_URL=${{ secrets.PYPI_INDEX_URL }}
- name: Build and push frontend image
if: inputs.image_type == 'split' || inputs.image_type == 'both'
uses: docker/build-push-action@v5
with:
context: .
file: ./frontend/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.tags.outputs.frontend }}
cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-frontend:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-frontend:buildcache,mode=max
build-args: |
DOCKER_BUILDKIT=1
DOCKER_REGISTRY=${{ secrets.DOCKER_REGISTRY }}
NPM_REGISTRY=${{ secrets.NPM_REGISTRY }}
================================================
FILE: .github/workflows/ci-test.yml
================================================
name: CI Tests
# Push to main/develop (excluding docs-only changes) or manual trigger
on:
push:
branches: [ main, develop ]
paths-ignore:
- '**/*.md'
- 'docs/**'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
UV_INDEX_URL: https://pypi.org/simple
jobs:
backend-test:
name: Backend Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install dependencies
run: uv sync --extra test
- name: Unit tests
run: uv run pytest backend/tests/unit -v --cov=backend --cov-report=xml
- name: Integration tests
run: uv run pytest backend/tests/integration -v -m "not requires_service"
env:
TESTING: true
GOOGLE_API_KEY: mock-api-key-for-testing
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./backend/coverage.xml
flags: backend
frontend-test:
name: Frontend Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: cd frontend && npm ci
- name: Lint
run: cd frontend && npm run lint
- name: Unit tests
run: cd frontend && npm test -- --run --coverage
- name: Build check
run: cd frontend && npm run build
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./frontend/coverage/coverage-final.json
flags: frontend
================================================
FILE: .github/workflows/docker-publish.yml
================================================
name: Release
# 手动触发,输入版本号
on:
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g. v0.3.1)'
required: true
type: string
permissions:
contents: write
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
env:
UV_INDEX_URL: https://pypi.org/simple
jobs:
validate:
name: Validate Version Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check version format
env:
VERSION: ${{ inputs.version }}
run: |
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Version must match vX.Y.Z format (got: $VERSION)"
exit 1
fi
- name: Check tag does not exist
env:
VERSION: ${{ inputs.version }}
run: |
if git rev-parse "$VERSION" >/dev/null 2>&1; then
echo "::error::Tag $VERSION already exists. Aborting to prevent duplicate release."
exit 1
fi
test:
name: Pre-release Tests
needs: validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install backend dependencies
run: uv sync --extra test
- name: Backend unit tests
run: uv run pytest backend/tests/unit -v
- name: Backend integration tests
run: uv run pytest backend/tests/integration -v -m "not requires_service"
env:
TESTING: true
GOOGLE_API_KEY: mock-api-key-for-testing
- uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Frontend lint
run: cd frontend && npm run lint
- name: Frontend tests
run: cd frontend && npm test -- --run
- name: Frontend build check
run: cd frontend && npm run build
e2e-test:
name: E2E Tests
needs: test
runs-on: ubuntu-latest
env:
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
steps:
- uses: actions/checkout@v4
- name: Setup environment
run: |
chmod +x scripts/setup-env-from-secrets.sh
./scripts/setup-env-from-secrets.sh
sed -i 's/^AI_PROVIDER_FORMAT=.*/AI_PROVIDER_FORMAT=gemini/' .env || echo "AI_PROVIDER_FORMAT=gemini" >> .env
env:
AI_PROVIDER_FORMAT: gemini
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GOOGLE_API_BASE: ${{ secrets.GOOGLE_API_BASE }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
OPENAI_TIMEOUT: ${{ secrets.OPENAI_TIMEOUT }}
OPENAI_MAX_RETRIES: ${{ secrets.OPENAI_MAX_RETRIES }}
TEXT_MODEL: ${{ secrets.TEXT_MODEL }}
IMAGE_MODEL: ${{ secrets.IMAGE_MODEL }}
LOG_LEVEL: ${{ secrets.LOG_LEVEL }}
FLASK_ENV: ${{ secrets.FLASK_ENV }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
CORS_ORIGINS: ${{ secrets.CORS_ORIGINS }}
MAX_DESCRIPTION_WORKERS: ${{ secrets.MAX_DESCRIPTION_WORKERS }}
MAX_IMAGE_WORKERS: ${{ secrets.MAX_IMAGE_WORKERS }}
MINERU_TOKEN: ${{ secrets.MINERU_TOKEN }}
MINERU_API_BASE: ${{ secrets.MINERU_API_BASE }}
IMAGE_CAPTION_MODEL: ${{ secrets.IMAGE_CAPTION_MODEL }}
OUTPUT_LANGUAGE: ${{ secrets.OUTPUT_LANGUAGE }}
- name: Build and start Docker services
run: |
docker compose build --no-cache
docker compose up -d
- name: Wait for services
run: |
chmod +x scripts/wait-for-health.sh
./scripts/wait-for-health.sh http://localhost:5000/health 60 2
./scripts/wait-for-health.sh http://localhost:3000 60 2
- name: Docker environment tests
run: |
chmod +x scripts/test_docker_environment.sh
AUTO_CLEANUP=false ./scripts/test_docker_environment.sh
- name: Setup Node.js
if: env.GOOGLE_API_KEY != '' && env.GOOGLE_API_KEY != 'mock-api-key-for-testing'
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install Playwright
if: env.GOOGLE_API_KEY != '' && env.GOOGLE_API_KEY != 'mock-api-key-for-testing'
run: |
cd frontend
npm ci
npx playwright install --with-deps chromium
- name: Run E2E tests
if: env.GOOGLE_API_KEY != '' && env.GOOGLE_API_KEY != 'mock-api-key-for-testing'
run: cd frontend && npx playwright test ui-full-flow.spec.ts --workers=1
env:
CI: true
timeout-minutes: 25
- name: Upload E2E reports
if: always()
uses: actions/upload-artifact@v4
with:
name: release-e2e-report
path: frontend/playwright-report/
retention-days: 7
- name: View logs on failure
if: failure()
run: |
docker compose logs backend
docker compose logs frontend
- name: Cleanup
if: always()
run: |
docker compose down -v
docker system prune -f
sync-version:
name: Sync Version to Source Files
needs: e2e-test
runs-on: ubuntu-latest
environment: release
steps:
- uses: actions/checkout@v4
with:
ref: main
- name: Update version files
env:
VERSION: ${{ inputs.version }}
run: |
V="${VERSION#v}"
jq --arg v "$V" '.version = $v' package.json > tmp.json && mv tmp.json package.json
sed -i "s/^version = .*/version = \"$V\"/" pyproject.toml
- name: Commit and push
env:
VERSION: ${{ inputs.version }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json pyproject.toml
git diff --cached --quiet && exit 0
git commit -m "chore: bump version to $VERSION"
git pull --rebase
git push
create-release:
name: Create GitHub Release
needs: sync-version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: main
- name: Pull latest (includes version bump)
run: git pull origin main
- name: Create tag and release
env:
VERSION: ${{ inputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git tag "$VERSION"
git push origin "$VERSION"
gh release create "$VERSION" --generate-notes
build-and-push:
name: Build and Push Docker Images
needs: create-release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.version }}
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-backend
tags: |
type=raw,value=${{ inputs.version }}
type=raw,value=latest
type=sha
- name: Extract metadata for frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-frontend
tags: |
type=raw,value=${{ inputs.version }}
type=raw,value=latest
type=sha
- name: Build and push backend image
uses: docker/build-push-action@v5
with:
context: .
file: ./backend/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-backend:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-backend:buildcache,mode=max
build-args: |
DOCKER_REGISTRY=${{ secrets.DOCKER_REGISTRY }}
GHCR_REGISTRY=${{ secrets.GHCR_REGISTRY || 'ghcr.io/' }}
APT_MIRROR=${{ secrets.APT_MIRROR }}
PYPI_INDEX_URL=${{ secrets.PYPI_INDEX_URL }}
- name: Build and push frontend image
uses: docker/build-push-action@v5
with:
context: .
file: ./frontend/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-frontend:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-frontend:buildcache,mode=max
build-args: |
DOCKER_BUILDKIT=1
DOCKER_REGISTRY=${{ secrets.DOCKER_REGISTRY }}
NPM_REGISTRY=${{ secrets.NPM_REGISTRY }}
# build-binaries:
# name: Build Binary Artifacts
# needs: create-release
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - name: Build
# run: echo "TODO: add build steps"
# - name: Upload to Release
# env:
# VERSION: ${{ inputs.version }}
# GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# run: gh release upload "$VERSION" ./dist/*
================================================
FILE: .github/workflows/nightly.yml
================================================
name: Nightly
# Every day at 03:00 UTC, or manual trigger
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
e2e-test:
name: Docker E2E Tests
runs-on: ubuntu-latest
env:
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
steps:
- uses: actions/checkout@v4
- name: Setup environment
run: |
chmod +x scripts/setup-env-from-secrets.sh
./scripts/setup-env-from-secrets.sh
sed -i 's/^AI_PROVIDER_FORMAT=.*/AI_PROVIDER_FORMAT=gemini/' .env || echo "AI_PROVIDER_FORMAT=gemini" >> .env
env:
AI_PROVIDER_FORMAT: gemini
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GOOGLE_API_BASE: ${{ secrets.GOOGLE_API_BASE }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
OPENAI_TIMEOUT: ${{ secrets.OPENAI_TIMEOUT }}
OPENAI_MAX_RETRIES: ${{ secrets.OPENAI_MAX_RETRIES }}
TEXT_MODEL: ${{ secrets.TEXT_MODEL }}
IMAGE_MODEL: ${{ secrets.IMAGE_MODEL }}
LOG_LEVEL: ${{ secrets.LOG_LEVEL }}
FLASK_ENV: ${{ secrets.FLASK_ENV }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
CORS_ORIGINS: ${{ secrets.CORS_ORIGINS }}
MAX_DESCRIPTION_WORKERS: ${{ secrets.MAX_DESCRIPTION_WORKERS }}
MAX_IMAGE_WORKERS: ${{ secrets.MAX_IMAGE_WORKERS }}
MINERU_TOKEN: ${{ secrets.MINERU_TOKEN }}
MINERU_API_BASE: ${{ secrets.MINERU_API_BASE }}
IMAGE_CAPTION_MODEL: ${{ secrets.IMAGE_CAPTION_MODEL }}
OUTPUT_LANGUAGE: ${{ secrets.OUTPUT_LANGUAGE }}
- name: Build Docker images
run: docker compose build --no-cache
- name: Start services
run: docker compose up -d
- name: Wait for services
run: |
chmod +x scripts/wait-for-health.sh
./scripts/wait-for-health.sh http://localhost:5000/health 60 2
./scripts/wait-for-health.sh http://localhost:3000 60 2
- name: Docker environment tests
run: |
chmod +x scripts/test_docker_environment.sh
AUTO_CLEANUP=false ./scripts/test_docker_environment.sh
- name: Setup Node.js
if: env.GOOGLE_API_KEY != '' && env.GOOGLE_API_KEY != 'mock-api-key-for-testing'
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install Playwright
if: env.GOOGLE_API_KEY != '' && env.GOOGLE_API_KEY != 'mock-api-key-for-testing'
run: |
cd frontend
npm ci
npx playwright install --with-deps chromium
- name: Run E2E tests
if: env.GOOGLE_API_KEY != '' && env.GOOGLE_API_KEY != 'mock-api-key-for-testing'
run: cd frontend && npx playwright test ui-full-flow.spec.ts --workers=1
env:
CI: true
timeout-minutes: 25
- name: Upload E2E reports
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: frontend/playwright-report/
retention-days: 7
- name: View logs on failure
if: failure()
run: |
docker compose logs backend
docker compose logs frontend
- name: Cleanup
if: always()
run: |
docker compose down -v
docker system prune -f
security-scan:
name: Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Backend dependency scan
run: |
uv pip install safety
uv run safety check --json || echo "Vulnerabilities found (warning)"
continue-on-error: true
- name: Frontend dependency scan
run: cd frontend && npm audit --audit-level=moderate || true
continue-on-error: true
- name: Dockerfile scan
uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: backend/Dockerfile
continue-on-error: true
push-nightly:
name: Push Nightly Images
needs: e2e-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push backend
uses: docker/build-push-action@v5
with:
context: .
file: ./backend/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-backend:nightly
cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-backend:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-backend:buildcache,mode=max
build-args: |
DOCKER_REGISTRY=${{ secrets.DOCKER_REGISTRY }}
GHCR_REGISTRY=${{ secrets.GHCR_REGISTRY || 'ghcr.io/' }}
APT_MIRROR=${{ secrets.APT_MIRROR }}
PYPI_INDEX_URL=${{ secrets.PYPI_INDEX_URL }}
- name: Build and push frontend
uses: docker/build-push-action@v5
with:
context: .
file: ./frontend/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-frontend:nightly
cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-frontend:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/banana-slides-frontend:buildcache,mode=max
build-args: |
DOCKER_BUILDKIT=1
DOCKER_REGISTRY=${{ secrets.DOCKER_REGISTRY }}
NPM_REGISTRY=${{ secrets.NPM_REGISTRY }}
================================================
FILE: .github/workflows/pr-quick-check.yml
================================================
name: PR Quick Check
on:
pull_request:
branches: [ main, develop ]
# Quick check: lint + unit tests + build + smoke
env:
UV_INDEX_URL: https://pypi.org/simple
jobs:
quick-check:
name: Quick Check (Lint + Unit Tests + Build)
runs-on: ubuntu-latest
timeout-minutes: 8
steps:
- name: Checkout code
uses: actions/checkout@v4
# Backend checks
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install uv package manager
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install backend dependencies
run: uv sync --extra test
- name: Backend syntax check
run: |
cd backend
uv run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics || true
continue-on-error: true
- name: Backend unit tests
run: uv run pytest backend/tests/unit -v
# Frontend checks
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Frontend lint check
run: cd frontend && npm run lint
- name: Frontend unit tests
run: cd frontend && npm test -- --run
- name: Frontend build check
run: cd frontend && npm run build
docs-check:
name: Docs Link Check
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '18'
- name: Check docs links
run: cd docs && npx mintlify@latest broken-links
# Simple API smoke test
smoke-test:
name: Smoke Test
runs-on: ubuntu-latest
timeout-minutes: 3
needs: quick-check
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install uv package manager
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install dependencies
run: |
uv sync
- name: Start backend and test
run: |
cp .env.example .env
cd backend
# Start in background
uv run python app.py &
SERVER_PID=$!
# Poll until backend is ready (up to 30s)
echo "Waiting for backend to start..."
for i in $(seq 1 30); do
if curl -sf http://localhost:5000/health; then
echo ""
echo "Backend smoke test passed (ready after ${i}s)"
kill $SERVER_PID
exit 0
fi
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "Backend process exited unexpectedly"
cat ../instance/app.log 2>/dev/null || echo "No log file found"
exit 1
fi
sleep 1
done
echo "Backend failed to start within 30s"
cat ../instance/app.log 2>/dev/null || echo "No log file found"
kill $SERVER_PID 2>/dev/null
exit 1
env:
GOOGLE_API_KEY: mock-key-for-testing
TESTING: true
================================================
FILE: .github/workflows/translate-readme.yml
================================================
name: Auto Translate README
# 当主分支的 README.md 改动时自动翻译到 README_EN.md
on:
push:
branches:
- main
paths:
- 'README.md'
workflow_dispatch: # 允许手动触发
# 防止多个翻译任务同时运行
concurrency:
group: translate-readme
cancel-in-progress: true
# 授予工作流推送权限
permissions:
contents: write
jobs:
translate:
name: Translate README to English
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
# 使用 GitHub Token 推送改动
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install dependencies
run: |
uv sync
- name: Translate README
env:
# 从仓库 secrets 读取 API 密钥
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GOOGLE_API_BASE: ${{ secrets.GOOGLE_API_BASE }}
AI_PROVIDER_FORMAT: gemini
TEXT_MODEL: gemini-3-flash-preview
run: |
echo "开始增量翻译 README.md(仅翻译修改的部分)..."
uv run python scripts/translate_readme_incremental.py
echo "翻译完成!"
- name: Check for changes
id: check_changes
run: |
if git diff --quiet README_EN.md; then
echo "changed=false" >> $GITHUB_OUTPUT
echo "README_EN.md 无变化,跳过提交"
else
echo "changed=true" >> $GITHUB_OUTPUT
echo "README_EN.md 已更新"
fi
- name: Commit and push changes
if: steps.check_changes.outputs.changed == 'true'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add README_EN.md
git commit -m "docs: auto translate README to English [skip ci]"
git push
- name: Summary
run: |
if [ "${{ steps.check_changes.outputs.changed }}" == "true" ]; then
echo "✅ README_EN.md 已自动更新并推送"
else
echo "ℹ️ README_EN.md 无变化"
fi
================================================
FILE: .gitignore
================================================
*.png
*.jpg
*.jpeg
*.gif
*.bmp
*.svg
*.webp
*.ico
*.tiff
*.tif
*.heic
*.heif
*.avif
!frontend/public/**/*.png
!frontend/public/**/*.jpg
!frontend/public/**/*.jpeg
!frontend/public/**/*.gif
!frontend/public/**/*.bmp
!frontend/public/**/*.svg
!frontend/public/**/*.webp
!frontend/public/**/*.ico
!frontend/public/**/*.tiff
!frontend/public/**/*.tif
!frontend/public/**/*.heic
!frontend/public/**/*.heif
!frontend/public/**/*.avif
# 保留测试fixtures
!e2e/fixtures/*.png
# 保留项目模板图片
!template*.png
# 忽略临时文档,但保留项目文档
*.md
!README.md
# !docs/**/*.md
!.github/**/*.md
*.pyc
.env
# GCP service-account key (sensitive — never commit)
gcp-service-account.json
*.ppt
*.pptx
generate-example.py
*.mdc
*.pdf
.cursor/worktrees.json
uploads/
!assets/*
# 镜像源配置脚本的备份文件
*.orig
# 本地备份目录(保存测试文件等)
_local_backup/
!CLA.md
!CONTRIBUTING.md
.venv
================================================
FILE: CLA.md
================================================
# Banana-slides Contributor License Agreement
Thank you for your interest in contributing to Banana-slides ("Project").
By 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.
## 0. Acceptance (How this CLA becomes effective)
By 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.
If you do not agree to these terms, do not submit any Contribution.
## 1. Definitions
- **"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.
- **"You" (or "Your")** means the individual or legal entity on behalf of whom a Contribution is submitted.
- **"Project Maintainer"** means the original author and primary maintainer of the Project (Anionex).
## 2. Grant of Copyright License
You 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:
- Reproduce, prepare derivative works of, publicly display, publicly perform, and distribute your Contributions and such derivative works.
- Sublicense and relicense your Contributions under any license, **including for commercial purposes**.
## 3. Grant of Patent License
You 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.
## 4. Representations and Warranties
You represent and warrant that:
- You are legally entitled to grant the above licenses.
- Each of your Contributions is your original creation.
- 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.
- Your Contribution does not violate any third-party's intellectual property rights.
- If you are employed, your employer has waived any rights to your Contributions, or you have obtained permission from your employer to submit Contributions.
## 5. No Support Obligation
You 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.
## 6. Open Source Availability
The Project Maintainer will continue to make an open-source edition of the Project publicly available, under an OSI-approved open-source license.
For clarity:
- The Maintainer may also distribute separate commercial/proprietary editions and/or offer alternative licenses for Contributions.
- The code will be published in a publicly accessible source repository (not necessarily GitHub).
## 7. No Warranty
Your Contributions are provided "AS IS" without warranty of any kind, express or implied.
---
## How to Agree
When 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.
Your agreement will remain publicly visible in the Pull Request description and be associated with your GitHub account.
---
*Last updated: February 2026*
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to Banana-slides
Thank you for your interest in contributing to Banana-slides! We welcome contributions from the community.
## Before You Start
### Contributor License Agreement (CLA)
By 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).
If you do not agree to the CLA, please do not submit a Pull Request.
**Why do we need a CLA?**
- To ensure we have the necessary rights to use, modify, and distribute contributions
- To allow the project to explore sustainable commercial models while keeping the open-source edition available
- To protect both contributors and the project legally
**How to agree:**
When 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.
## How to Contribute
### Reporting Bugs
1. Check if the bug has already been reported in [Issues](https://github.com/Anionex/banana-slides/issues)
2. If not, create a new issue with:
- A clear, descriptive title
- Steps to reproduce the bug
- Expected behavior vs actual behavior
- Screenshots if applicable
- Your environment (OS, browser, etc.)
### Suggesting Features
1. Check existing issues for similar suggestions
2. Create a new issue with the "feature request" label
3. Describe the feature and its use case
### Submitting Code
1. Fork the repository
2. Create a new branch for your feature/fix: `git checkout -b feature/your-feature-name`
3. Make your changes
4. Test your changes thoroughly
5. Commit with clear, descriptive messages
6. Push to your fork
7. Open a Pull Request with:
- A clear description of the changes
- Reference to any related issues
- **CLA checkbox checked** (PRs without this may be delayed/closed) (PRs without this statement may be delayed/closed)
## Development Setup
### 环境要求 / Requirements
- Python 3.10+
- [uv](https://github.com/astral-sh/uv) - Python 包管理器
- Node.js 16+ 和 npm
- 有效的 API 密钥(详见 `.env.example`)
### 安装步骤 / Installation
```bash
# 克隆代码仓库
git clone https://github.com/Anionex/banana-slides.git
cd banana-slides
# 安装 uv(如果尚未安装)
curl -LsSf https://astral.sh/uv/install.sh | sh
# 安装后端依赖(在项目根目录运行)
uv sync
# 配置环境变量
cp .env.example .env
# 编辑 .env 文件,配置你的 API 密钥
# 安装前端依赖
cd frontend
npm install
```
### 启动开发服务器 / Start Development Server
```bash
# 启动后端(在项目根目录)
cd backend
uv run alembic upgrade head && uv run python app.py
# 后端运行在 http://localhost:5000
# 启动前端(新开一个终端)
cd frontend
npm run dev
# 前端运行在 http://localhost:3000
```
## Code Style
- Follow the existing code style in the project
- Write clear, self-documenting code
- Add comments for complex logic
- Include tests for new features when applicable
## Questions?
If you have questions, feel free to open an issue or reach out to the maintainers.
---
Thank you for contributing to Banana-slides! 🍌
================================================
FILE: Dockerfile.allinone
================================================
# 镜像源配置参数(可通过 build args 覆盖)
ARG DOCKER_REGISTRY=
ARG GHCR_REGISTRY=ghcr.io/
ARG NPM_REGISTRY=
ARG APT_MIRROR=
ARG PYPI_INDEX_URL=
# ── Stage 1: 构建前端 ──────────────────────────────────────────
FROM ${DOCKER_REGISTRY:-}node:18-alpine AS frontend-builder
ARG NPM_REGISTRY=
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json* ./
RUN if [ -n "$NPM_REGISTRY" ]; then \
npm config set registry "$NPM_REGISTRY"; \
fi && \
(npm install --frozen-lockfile || npm install)
COPY frontend/ ./
RUN npm run build
# ── Stage 2: 获取 uv ──────────────────────────────────────────
FROM ${GHCR_REGISTRY}astral-sh/uv:latest AS uv
# ── Stage 3: 最终一体镜像 ──────────────────────────────────────
FROM ${DOCKER_REGISTRY:-}python:3.10-slim
ARG APT_MIRROR=
ARG PYPI_INDEX_URL=
WORKDIR /app
# 安装系统依赖:nginx + supervisor + curl
RUN if [ -n "$APT_MIRROR" ]; then \
if [ -f /etc/apt/sources.list.d/debian.sources ]; then \
sed -i "s@deb.debian.org@$APT_MIRROR@g" /etc/apt/sources.list.d/debian.sources; \
fi; \
fi && \
apt-get update && apt-get install -y \
curl \
nginx \
supervisor \
&& rm -rf /var/lib/apt/lists/*
# 复制 uv
COPY --from=uv /uv /usr/local/bin/uv
RUN chmod +x /usr/local/bin/uv
# 安装 Python 依赖
COPY pyproject.toml uv.lock* ./
ENV UV_INDEX_URL=${PYPI_INDEX_URL}
ENV UV_HTTP_TIMEOUT=300
RUN if [ -f uv.lock ]; then uv sync --frozen; else uv sync; fi
# 复制后端代码和资源
COPY backend/ ./backend/
COPY assets/ ./assets/
COPY docker/ ./docker/
# 复制前端构建产物
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
# 配置 nginx
COPY docker/nginx-allinone.conf /etc/nginx/conf.d/default.conf
RUN rm -f /etc/nginx/sites-enabled/default
# 启动脚本可执行
RUN chmod +x /app/docker/start-backend.sh
# 创建必要目录
RUN mkdir -p /app/backend/instance /app/uploads
ENV PYTHONPATH=/app
ENV FLASK_APP=backend/app.py
ENV IN_DOCKER=1
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD curl -f http://localhost/health || exit 1
CMD ["/usr/bin/supervisord", "-c", "/app/docker/supervisord.conf"]
================================================
FILE: LICENSE
================================================
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
================================================
FILE: README.md
================================================
<div align="center">
<img src="https://github.com/user-attachments/assets/81fe6816-44cc-4c61-97c7-f3c099650966" alt="banana-slides">
<a href="https://trendshift.io/repositories/22056" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/22056" alt="Anionex%2Fbanana-slides | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
<br>
<b>一个基于nano banana pro🍌的原生AI PPT生成应用<br></b>
<b> 在几分钟内从想法到演示文稿,无需繁琐排版、口头提出修改,迈向真正的"Vibe PPT" </b>
<p>
<a href="https://bananaslides.online/"><b>🚀 在线 Demo</b></a>
•
<a href="https://docs.bananaslides.online/"><b>📚 文档</b></a>
•
<a href="README_EN.md"><b>English</b></a>
</p>
[](https://github.com/Anionex/banana-slides/stargazers)
[](https://github.com/Anionex/banana-slides/network)
[](https://github.com/Anionex/banana-slides/watchers)
[](https://github.com/Anionex/banana-slides)

[](https://github.com/Anionex/banana-slides/blob/main/LICENSE)
<p>
如果该项目对你有用,欢迎 <b>Star 🌟</b> & <b>Fork 🍴</b>
</p>
</div>
## ✨ 项目缘起
你是否也曾陷入这样的困境:明天就要汇报,但PPT还是一片空白;脑中有无数精彩的想法,却被繁琐的排版和设计消磨掉所有热情?
我(们)渴望能快速创作出既专业又具设计感的演示文稿,传统的AI PPT生成app,虽然大体满足“快”这一需求,却还存在以下问题:
- 1️⃣只能选择预设模版,无法灵活调整风格
- 2️⃣自由度低,多轮改动难以进行
- 3️⃣成品观感相似,同质化严重
- 4️⃣素材质量较低,缺乏针对性
- 5️⃣图文排版割裂,设计感差
以上这些缺陷,让传统的AI ppt生成器难以同时满足我们“快”和“美”的两大PPT制作需求。即使自称Vibe PPT,但是在我的眼中还远不够“Vibe”。
但是,nano banana🍌模型的出现让一切有了转机。我尝试使用🍌pro进行ppt页面生成,发现生成的结果无论是质量、美感还是一致性,都做的非常好,且几乎能精确渲染prompt要求的所有文字+遵循参考图的风格。那为什么不基于🍌pro,做一个原生的"Vibe PPT"应用呢?
## 👨💻 适用场景
1. **小白**:零门槛快速生成美观PPT,无需设计经验,减少模板选择烦恼
2. **PPT专业人士**:参考AI生成的布局和图文元素组合,快速获取设计灵感
3. **教育工作者**:将教学内容快速转换为配图教案PPT,提升课堂效果
4. **学生**:快速完成作业Pre,把精力专注于内容而非排版美化
5. **职场人士**:商业提案、产品介绍快速可视化,多场景快速适配
<p>
<b>🎯目标: 降低 PPT 制作门槛,让每个人都能快速创作出美观专业的演示文稿</b>
</p>
## 🎨 结果案例
<div align="center">
| | |
|:---:|:---:|
| <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"> |
| **软件开发最佳实践** | **DeepSeek-V3.2技术展示** |
| <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"> |
| **预制菜智能产线装备研发和产业化** | **钱的演变:从贝壳到纸币的旅程** |
</div>
更多可见<a href="https://github.com/Anionex/banana-slides/issues/2" > 使用案例 </a>
## 🎯 功能介绍
### 1. 灵活多样的创作路径
支持**想法**、**大纲**、**页面描述**三种起步方式,满足不同创作习惯。
- **一句话生成**:输入一个主题,AI 自动生成结构清晰的大纲和逐页内容描述。
- **自然语言编辑**:支持以 Vibe 形式口头修改大纲或描述(如"把第三页改成案例分析"),AI 实时响应调整。
- **大纲/描述模式**:既可一键批量生成,也可手动调整细节。
<img width="2000" height="1125" alt="image" src="https://github.com/user-attachments/assets/7fc1ecc6-433d-4157-b4ca-95fcebac66ba" />
### 2. 强大的素材解析能力
- **多格式支持**:上传 PDF/Docx/MD/Txt 等文件,后台自动解析内容。
- **智能提取**:自动识别文本中的关键点、图片链接和图表信息,为生成提供丰富素材。
- **风格参考**:支持上传参考图片或模板,定制 PPT 风格。
<img width="1920" height="1080" alt="文件解析与素材处理" src="https://github.com/user-attachments/assets/8cda1fd2-2369-4028-b310-ea6604183936" />
### 3. "Vibe" 式自然语言修改
不再受限于复杂的菜单按钮,直接通过**自然语言**下达修改指令。
- **局部重绘**:对不满意的区域进行口头式修改(如"把这个图换成饼图")。
- **整页优化**:基于 nano banana pro🍌 生成高清、风格统一的页面。
<img width="2000" height="1125" alt="image" src="https://github.com/user-attachments/assets/929ba24a-996c-4f6d-9ec6-818be6b08ea3" />
### 4. 开箱即用的格式导出
- **多格式支持**:一键导出标准 **PPTX** 或 **PDF** 文件。
- **完美适配**:默认 16:9 比例,排版无需二次调整,直接演示。
<img width="1000" alt="image" src="https://github.com/user-attachments/assets/3e54bbba-88be-4f69-90a1-02e875c25420" />
<img width="1748" height="538" alt="PPT与PDF导出" src="https://github.com/user-attachments/assets/647eb9b1-d0b6-42cb-a898-378ebe06c984" />
### 5. 可自由编辑的pptx导出(Beta迭代中)
- **导出图像为高还原度、背景干净的、可自由编辑图像和文字的PPT页面**
- 相关更新见 https://github.com/Anionex/banana-slides/issues/121
<img width="1000" alt="image" src="https://github.com/user-attachments/assets/a85d2d48-1966-4800-a4bf-73d17f914062" />
<br>
**🌟和notebooklm slide deck功能对比**
| 功能 | notebooklm | 本项目 |
| --- | --- | --- |
| 页数上限 | 15页 | **无限制** |
| 二次编辑 | 提示词修改 | **框选编辑+口头编辑** |
| 素材添加 | 生成后无法添加 | **生成后自由添加** |
| 导出格式 | 支持导出为 PDF、(不可编辑图片)pptx | **导出为PDF、(图片or可编辑)pptx** |
| 水印 | 免费版有水印 | **无水印,自由增删元素** |
> 注:随着新功能添加,对比可能过时
## 🔥 近期更新
- 【2-9】:
* 新功能
* 支持在首页、大纲、描述卡片里面粘贴图片并立即识别,并提供更好的交互体验
* 大纲章节手动编辑:支持手动调整页面所属章节(part)。
* Docker 多架构:镜像支持 amd64 / arm64 构建。
* 国际化 + 暗黑模式:新增中英文切换;支持亮色/暗色/跟随系统主题;全组件适配暗黑模式。
* 修复与体验优化
* 修复导出相关 500、参考文件关联时序、outline/page 数据错位、任务轮询错误项目、描述生成无限轮询、图片预览内存泄漏、批量删除部分失败处理。
* 优化格式示例提示、HTTP 错误提示文案、Modal 关闭体验、清理旧项目 localStorage、移除首次创建项目冗余提示。
* 若干其他优化和修复
- 【1-4】 : v0.4.0发布:可编辑pptx导出全面升级:
* 支持最大程度还原图片中文字的字号、颜色、加粗等样式;
* 支持了识别表格中的文字内容;
* 更精确的文字大小和文字位置还原逻辑
* 优化导出工作流,大大减少了导出后背景图残留文字的现象;
* 支持页面多选逻辑,灵活选择需要生成和导出的具体页面。
* **详细效果和使用方法见 https://github.com/Anionex/banana-slides/issues/121**
- 【12-27】: 加入了对无图片模板模式的支持和较高质量的文字预设,现在可以通过纯文字描述的方式来控制ppt页面风格
## 🗺️ 开发计划
| 状态 | 里程碑 |
| --- | --- |
| ✅ 已完成 | 从想法、大纲、页面描述三种路径创建 PPT |
| ✅ 已完成 | 解析文本中的 Markdown 格式图片 |
| ✅ 已完成 | PPT 单页添加更多素材 |
| ✅ 已完成 | PPT 单页框选区域Vibe口头编辑 |
| ✅ 已完成 | 素材模块: 素材生成、上传等 |
| ✅ 已完成 | 支持多种文件的上传+解析 |
| ✅ 已完成 | 支持Vibe口头调整大纲和描述 |
| ✅ 已完成 | 初步支持可编辑版本pptx文件导出 |
| 🔄 进行中 | 支持多层次、精确抠图的可编辑pptx导出 |
| 🔄 进行中 | 网络搜索 |
| 🔄 进行中 | Agent 模式 |
| 🚍 部分 | 优化前端加载速度 |
| 🧭 规划中 | 在线播放功能 |
| 🧭 规划中 | 简单的动画和页面切换效果 |
| 🚍 部分 | 多语种支持 |
| 🏢商业版功能 | 用户系统 |
## 📦 使用方法
### (新)使用应用模板一键部署
这是最简单的方式,无需安装docker或下载项目,创建后可直接进入应用
1. 通过雨云一键部署和启动本应用 (新用户有15天免费使用+首充双倍政策)
[](https://app.rainyun.com/apps/rca/store/7549/anionex_)
2. 敬请期待
### 使用 Docker Compose🐳
通过docker compose快速启动前后端服务。
<details>
<summary>📒 Windows/Mac用户说明</summary>
如果你使用 **Windows 或 macOS**,请先安装 **Docker Desktop**,并确保 Docker 正在运行(Windows 可检查系统托盘图标;macOS 可检查菜单栏图标),然后按文档中的相同步骤操作。
> **提示**:如果遇到问题,Windows 用户请在 Docker Desktop 设置中启用 **WSL 2 后端**(推荐);同时确保端口 **3000** 和 **5000** 未被占用。
</details>
0. **克隆代码仓库**
```bash
git clone https://github.com/Anionex/banana-slides
cd banana-slides
```
1. **配置环境变量**
创建 `.env` 文件(参考 `.env.example`):
```bash
cp .env.example .env
```
编辑 `.env` 文件,配置必要的环境变量:
> **项目中大模型接口以AIHubMix平台格式为标准,推荐使用 [AIHubMix(点击此处可直接访问)](https://aihubmix.com/?aff=17EC) 获取API密钥,减小迁移成本**<br>
> **友情提示:谷歌nano banana pro模型接口费用较高,请注意调用成本**
```env
# AI Provider格式配置 (gemini / openai / vertex)
AI_PROVIDER_FORMAT=gemini
# Gemini 格式配置(当 AI_PROVIDER_FORMAT=gemini 时使用)
GOOGLE_API_KEY=your-api-key-here
GOOGLE_API_BASE=https://generativelanguage.googleapis.com
# 代理示例: https://aihubmix.com/gemini
# OpenAI 格式配置(当 AI_PROVIDER_FORMAT=openai 时使用)
OPENAI_API_KEY=your-api-key-here
OPENAI_API_BASE=https://api.openai.com/v1
# 代理示例: https://aihubmix.com/v1
# Vertex AI 配置(AI_PROVIDER_FORMAT=vertex)
# 需要 GCP 项目和服务账户密钥
# VERTEX_PROJECT_ID=your-gcp-project-id
# VERTEX_LOCATION=global
# GOOGLE_APPLICATION_CREDENTIALS=./gcp-service-account.json
# Lazyllm 格式配置(当 AI_PROVIDER_FORMAT=lazyllm 时使用)
# 选择文本生成和图片生成使用的厂商
TEXT_MODEL_SOURCE=deepseek # 文本生成模型厂商
IMAGE_MODEL_SOURCE=doubao # 图片编辑模型厂商
IMAGE_CAPTION_MODEL_SOURCE=qwen # 图片描述模型厂商
# 各厂商 API Key(只需配置你要使用的厂商)
DOUBAO_API_KEY=your-doubao-api-key # 火山引擎/豆包
DEEPSEEK_API_KEY=your-deepseek-api-key # DeepSeek
QWEN_API_KEY=your-qwen-api-key # 阿里云/通义千问
GLM_API_KEY=your-glm-api-key # 智谱 GLM
SILICONFLOW_API_KEY=your-siliconflow-api-key # 硅基流动
SENSENOVA_API_KEY=your-sensenova-api-key # 商汤日日新
MINIMAX_API_KEY=your-minimax-api-key # MiniMax
...
```
**使用新版可编辑导出配置方法,获得更好的可编辑导出效果**: 需在[百度智能云平台](https://console.bce.baidu.com/iam/#/iam/apikey/list)(点击此处进入)中获取API KEY,填写在.env文件中的BAIDU_API_KEY字段(有充足的免费使用额度)。详见https://github.com/Anionex/banana-slides/issues/121 中的说明
<details>
<summary>📒 Vertex AI 配置指南(适用于 GCP 用户)</summary>
Google Cloud Vertex AI 允许通过 GCP 服务账户调用 Gemini 模型,新用户可使用赠金额度。配置步骤:
1. 前往 [GCP Console](https://console.cloud.google.com/),创建一个服务账户并下载 JSON 格式的密钥文件
2. 将密钥文件保存为项目根目录下的 `gcp-service-account.json`
3. 在 `.env` 中设置:
```env
AI_PROVIDER_FORMAT=vertex
VERTEX_PROJECT_ID=your-gcp-project-id
VERTEX_LOCATION=global
```
4. 如果使用 Docker 部署,还需要在 `docker-compose.yml` 中取消相关注释,将密钥文件挂载到容器内并设置 `GOOGLE_APPLICATION_CREDENTIALS` 环境变量。
> `gemini-3-*` 系列模型要求 `VERTEX_LOCATION=global`
</details>
2. **启动服务**
**⚡ 使用预构建镜像(推荐)**
项目在 Docker Hub 提供了构建好的前端和后端镜像(同步主分支最新版本),可以跳过本地构建步骤,实现快速部署:
```bash
# 使用预构建镜像启动(无需从头构建)
docker compose -f docker-compose.prod.yml up -d
```
镜像名称:
- `anoinex/banana-slides-frontend:latest`
- `anoinex/banana-slides-backend:latest`
**从头构建镜像**
```bash
docker compose up -d
```
> [!TIP]
> 如遇网络问题,可在 `.env` 文件中取消镜像源配置的注释, 再重新运行启动命令:
> ```env
> # 在 .env 文件中取消以下注释即可使用国内镜像源
> DOCKER_REGISTRY=docker.1ms.run/
> GHCR_REGISTRY=ghcr.nju.edu.cn/
> APT_MIRROR=mirrors.aliyun.com
> PYPI_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple
> NPM_REGISTRY=https://registry.npmmirror.com/
> ```
3. **访问应用**
- 前端:http://localhost:3000
- 后端 API:http://localhost:5000
4. **查看日志**
```bash
# 查看后端日志(最后 200 行)
docker logs --tail 200 banana-slides-backend
# 实时查看后端日志(最后 100 行)
docker logs -f --tail 100 banana-slides-backend
# 查看前端日志(最后 100 行)
docker logs --tail 100 banana-slides-frontend
```
5. **停止服务**
```bash
docker compose down
```
6. **更新项目**
**使用预构建镜像(docker-compose.prod.yml)**
```bash
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
```
**使用本地构建(docker-compose.yml)**
```bash
git pull
docker compose down
docker compose build --no-cache
docker compose up -d
```
**注:感谢优秀开发者朋友 [@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)查看。**
### 从源码部署
#### 环境要求
- Python 3.10 或更高版本
- [uv](https://github.com/astral-sh/uv) - Python 包管理器
- Node.js 16+ 和 npm
- 有效的 Google Gemini API 密钥
- (可选)[LibreOffice](https://www.libreoffice.org/) - 使用「PPT 翻新」功能上传 PPTX 文件时需要,用于将 PPTX 转换为 PDF。**推荐先在本地将 PPTX 转为 PDF 后再上传**,原因:LibreOffice 在服务端渲染时可能因缺少字体(如微软雅黑、Calibri 等)导致排版错位,且无法完整还原部分特效。上传 PDF 文件则不需要 LibreOffice。Docker 用户如仍需在容器内支持 PPTX 上传,可执行:
```bash
docker exec -it banana-slides-backend bash -c "apt-get update && apt-get install -y libreoffice-impress && rm -rf /var/lib/apt/lists/*"
```
> 注意:此方式安装的 LibreOffice 在容器重建后会丢失,需重新安装。
#### 后端安装
0. **克隆代码仓库**
```bash
git clone https://github.com/Anionex/banana-slides
cd banana-slides
```
1. **安装 uv(如果尚未安装)**
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
2. **安装依赖**
在项目根目录下运行:
```bash
uv sync
```
这将根据 `pyproject.toml` 自动安装所有依赖。
3. **配置环境变量**
复制环境变量模板:
```bash
cp .env.example .env
```
编辑 `.env` 文件,配置你的 API 密钥:
> **项目中大模型接口以AIHubMix平台格式为标准,推荐使用 [AIHubMix](https://aihubmix.com/?aff=17EC) 获取API密钥,减小迁移成本**
```env
# AI Provider格式配置 (gemini / openai / vertex)
AI_PROVIDER_FORMAT=gemini
# Gemini 格式配置(当 AI_PROVIDER_FORMAT=gemini 时使用)
GOOGLE_API_KEY=your-api-key-here
GOOGLE_API_BASE=https://generativelanguage.googleapis.com
# 代理示例: https://aihubmix.com/gemini
# OpenAI 格式配置(当 AI_PROVIDER_FORMAT=openai 时使用)
OPENAI_API_KEY=your-api-key-here
OPENAI_API_BASE=https://api.openai.com/v1
# 代理示例: https://aihubmix.com/v1
# Vertex AI 配置(AI_PROVIDER_FORMAT=vertex)
# 需要 GCP 项目和服务账户密钥
# VERTEX_PROJECT_ID=your-gcp-project-id
# VERTEX_LOCATION=global
# GOOGLE_APPLICATION_CREDENTIALS=./gcp-service-account.json
# 可修改此变量来控制后端服务端口
BACKEND_PORT=5000
...
```
#### 前端安装
1. **进入前端目录**
```bash
cd frontend
```
2. **安装依赖**
```bash
npm install
```
3. **配置API地址**
前端会自动连接到 `http://localhost:5000` 的后端服务。如需修改,请编辑 `src/api/client.ts`。
#### 启动后端服务
> (可选)如果本地已有重要数据,升级前建议先备份数据库:
> `cp backend/instance/database.db backend/instance/database.db.bak`
```bash
cd backend
uv run alembic upgrade head && uv run python app.py
```
后端服务将在 `http://localhost:5000` 启动。
访问 `http://localhost:5000/health` 验证服务是否正常运行。
#### 启动前端开发服务器
```bash
cd frontend
npm run dev
```
前端开发服务器将在 `http://localhost:3000` 启动。
打开浏览器访问即可使用应用。
## 🛠️ 技术架构
### 前端技术栈
- **框架**:React 18 + TypeScript
- **构建工具**:Vite 5
- **状态管理**:Zustand
- **路由**:React Router v6
- **UI组件**:Tailwind CSS
- **拖拽功能**:@dnd-kit
- **图标**:Lucide React
- **HTTP客户端**:Axios
### 后端技术栈
- **语言**:Python 3.10+
- **框架**:Flask 3.0
- **包管理**:uv
- **数据库**:SQLite + Flask-SQLAlchemy
- **AI能力**:Google Gemini API
- **PPT处理**:python-pptx
- **图片处理**:Pillow
- **并发处理**:ThreadPoolExecutor
- **跨域支持**:Flask-CORS
## 📁 项目结构
```
banana-slides/
├── frontend/ # React前端应用
│ ├── src/
│ │ ├── pages/ # 页面组件
│ │ │ ├── Home.tsx # 首页(创建项目)
│ │ │ ├── OutlineEditor.tsx # 大纲编辑页
│ │ │ ├── DetailEditor.tsx # 详细描述编辑页
│ │ │ ├── SlidePreview.tsx # 幻灯片预览页
│ │ │ └── History.tsx # 历史版本管理页
│ │ ├── components/ # UI组件
│ │ │ ├── outline/ # 大纲相关组件
│ │ │ │ └── OutlineCard.tsx
│ │ │ ├── preview/ # 预览相关组件
│ │ │ │ ├── SlideCard.tsx
│ │ │ │ └── DescriptionCard.tsx
│ │ │ ├── shared/ # 共享组件
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── Card.tsx
│ │ │ │ ├── Input.tsx
│ │ │ │ ├── Textarea.tsx
│ │ │ │ ├── Modal.tsx
│ │ │ │ ├── Loading.tsx
│ │ │ │ ├── Toast.tsx
│ │ │ │ ├── Markdown.tsx
│ │ │ │ ├── MaterialSelector.tsx
│ │ │ │ ├── MaterialGeneratorModal.tsx
│ │ │ │ ├── TemplateSelector.tsx
│ │ │ │ ├── ReferenceFileSelector.tsx
│ │ │ │ └── ...
│ │ │ ├── layout/ # 布局组件
│ │ │ └── history/ # 历史版本组件
│ │ ├── store/ # Zustand状态管理
│ │ │ └── useProjectStore.ts
│ │ ├── api/ # API接口
│ │ │ ├── client.ts # Axios客户端配置
│ │ │ └── endpoints.ts # API端点定义
│ │ ├── types/ # TypeScript类型定义
│ │ ├── utils/ # 工具函数
│ │ ├── constants/ # 常量定义
│ │ └── styles/ # 样式文件
│ ├── public/ # 静态资源
│ ├── package.json
│ ├── vite.config.ts
│ ├── tailwind.config.js # Tailwind CSS配置
│ ├── Dockerfile
│ └── nginx.conf # Nginx配置
│
├── backend/ # Flask后端应用
│ ├── app.py # Flask应用入口
│ ├── config.py # 配置文件
│ ├── models/ # 数据库模型
│ │ ├── project.py # Project模型
│ │ ├── page.py # Page模型(幻灯片页)
│ │ ├── task.py # Task模型(异步任务)
│ │ ├── material.py # Material模型(参考素材)
│ │ ├── user_template.py # UserTemplate模型(用户模板)
│ │ ├── reference_file.py # ReferenceFile模型(参考文件)
│ │ ├── page_image_version.py # PageImageVersion模型(页面版本)
│ ├── services/ # 服务层
│ │ ├── ai_service.py # AI生成服务(Gemini集成)
│ │ ├── file_service.py # 文件管理服务
│ │ ├── file_parser_service.py # 文件解析服务
│ │ ├── export_service.py # PPTX/PDF导出服务
│ │ ├── task_manager.py # 异步任务管理
│ │ ├── prompts.py # AI提示词模板
│ ├── controllers/ # API控制器
│ │ ├── project_controller.py # 项目管理
│ │ ├── page_controller.py # 页面管理
│ │ ├── material_controller.py # 素材管理
│ │ ├── template_controller.py # 模板管理
│ │ ├── reference_file_controller.py # 参考文件管理
│ │ ├── export_controller.py # 导出功能
│ │ └── file_controller.py # 文件上传
│ ├── utils/ # 工具函数
│ │ ├── response.py # 统一响应格式
│ │ ├── validators.py # 数据验证
│ │ └── path_utils.py # 路径处理
│ ├── instance/ # SQLite数据库(自动生成)
│ ├── exports/ # 导出文件目录
│ ├── Dockerfile
│ └── README.md
│
├── tests/ # 测试文件目录
├── v0_demo/ # 早期演示版本
├── output/ # 输出文件目录
│
├── pyproject.toml # Python项目配置(uv管理)
├── uv.lock # uv依赖锁定文件
├── docker-compose.yml # Docker Compose配置
├── .env.example # 环境变量示例
├── LICENSE # 许可证
└── README.md # 本文件
```
## 交流群
为了方便大家沟通互助,建此微信交流群.
欢迎提出新功能建议或反馈,本人也会~~佛系~~回答大家问题
<img width="302" src="https://private-user-images.githubusercontent.com/123177548/563407714-01f84824-a8f5-4858-97c2-a7e65d70755e.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NzM0NjQ5MTksIm5iZiI6MTc3MzQ2NDYxOSwicGF0aCI6Ii8xMjMxNzc1NDgvNTYzNDA3NzE0LTAxZjg0ODI0LWE4ZjUtNDg1OC05N2MyLWE3ZTY1ZDcwNzU1ZS5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjYwMzE0JTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI2MDMxNFQwNTAzMzlaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT1jYjQxMzQ4ZjIxNjQ2MjIwMTI2NGUzMDFmMDQ2M2Q1Y2MwNDE4OGVkMTdmYTlmMzk1NzcxYmZkMDEzMTBhNzY2JlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.RMmYdJClH6y7tdMuET73o7UzJ-bCONxmF6IjpvUBmLw">
## **🔧 常见问题**
1. **生成页面文字有乱码,文字不清晰**
- 可选择更高分辨率的输出(openai 格式可能不支持调高分辨率,建议使用gemini格式)。根据测试,生成页面前将 1k 分辨率调整至 2k 后,文字渲染质量会显著提升。
- 请确保在页面描述中包含具体要渲染的文字内容。
2. **导出可编辑 ppt 效果不佳,如文字重叠、无样式等**
- 90% 情况为 API 配置出现问题。可以参考 [issue 121](https://github.com/Anionex/banana-slides/issues/121) 中的排查与解决方案。
3. **支持免费层级的 Gemini API Key 吗?**
- 免费层级只支持文本生成,不支持图片生成。
4. **生成内容时提示 503 错误或 Retry Error**
- 可以根据 README 中的命令查看 Docker 后端日志,定位 503 问题的详细报错,一般是模型配置不正确导致。
5. **.env 中设置了 API Key 之后,为什么不生效?**
- 运行时编辑 `.env` 后需要重启 Docker 容器以应用更改。
- 如果曾在网页设置页中配置参数,会覆盖 `.env` 中的参数,可通过"还原默认设置"恢复为 `.env` 设置。
## 🤝 贡献指南
欢迎通过
[Issue](https://github.com/Anionex/banana-slides/issues)
和
[Pull Request](https://github.com/Anionex/banana-slides/pulls)
为本项目贡献力量!
> **重要:** 贡献前请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)
## 📄 许可证
本项目采用 **GNU Affero General Public License v3.0(AGPL-3.0)** 开源,
可自由用于个人学习、研究、试验、教育或非营利科研活动等非商业用途;
<details>
<summary> 详情 </summary>
需要商业许可证(Commercial License)(例如:希望闭源使用、私有化部署交付、将本项目集成进闭源产品,或在不公开对应源代码的前提下提供服务),请联系作者:anionex@qq.com
- 联系方式:anionex@qq.com
</details>
<h2>🚀 Sponsor / 赞助 </h2>
<br>
<div align="center">
<a href="https://aihubmix.com/?aff=17EC">
<img src="./assets/logo_aihubmix.png" alt="AIHubMix" style="height:48px;">
</a>
<p>感谢AIHubMix对本项目的赞助</p>
</div>
<div align="center">
<br>
<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" />
</a>
<details>
<summary>感谢<a href="https://api.chatfire.site/login?inviteCode=A15CD6A0">AI火宝</a>对本项目的赞助</summary>
“聚合全球多模型API服务商。更低价格享受安全、稳定且72小时链接全球最新模型的服务。”
</details>
</div>
## 致谢
- 项目贡献者们:
[](https://github.com/Anionex/banana-slides/graphs/contributors)
- [Linux.do](https://linux.do/): 新的理想型社区
## 赞赏
开源不易🙏如果本项目对你有价值,欢迎请开发者喝杯咖啡☕️
<img width="240" alt="image" src="https://github.com/user-attachments/assets/fd7a286d-711b-445e-aecf-43e3fe356473" />
感谢以下朋友对项目的无偿赞助支持:
> @雅俗共赏、@曹峥、@以年观日、@John、@胡yun星Ethan, @azazo1、@刘聪NLP、@🍟、@苍何、@万瑾、@biubiu、@law、@方源、@寒松Falcon
> 如对赞助列表有疑问,可<a href="mailto:anionex@qq.com">联系作者</a>
## 📈 项目统计
<a href="https://www.star-history.com/#Anionex/banana-slides&type=Timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Anionex/banana-slides&type=Timeline&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Anionex/banana-slides&type=Timeline&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Anionex/banana-slides&type=Timeline&legend=top-left" />
</picture>
</a>
<br>
================================================
FILE: backend/.gitignore
================================================
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Flask
instance/
.webassets-cache
# Environment
.env
.env.local
# Uploads
uploads/
*.db
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
================================================
FILE: backend/Dockerfile
================================================
# 镜像源配置参数(可通过 build args 覆盖)
ARG DOCKER_REGISTRY=
ARG GHCR_REGISTRY=ghcr.io/
ARG APT_MIRROR=
ARG PYPI_INDEX_URL=
# 安装 uv(使用中间阶段避免 COPY --from 的变量展开问题)
FROM ${GHCR_REGISTRY}astral-sh/uv:latest AS uv
# 使用 Python 3.10 作为基础镜像
# 如果指定了 DOCKER_REGISTRY,使用镜像源;否则使用官方源
FROM ${DOCKER_REGISTRY:-}python:3.10-slim
# 重新声明ARG(FROM之后ARG作用域失效,需要重新声明)
ARG APT_MIRROR=
ARG PYPI_INDEX_URL=
# 设置工作目录
WORKDIR /app
# 安装系统依赖(如果配置了 APT_MIRROR,先替换镜像源)
RUN if [ -n "$APT_MIRROR" ]; then \
if [ -f /etc/apt/sources.list.d/debian.sources ]; then \
sed -i "s@deb.debian.org@$APT_MIRROR@g" /etc/apt/sources.list.d/debian.sources; \
else \
echo "Warning: /etc/apt/sources.list.d/debian.sources not found, skipping mirror setup." >&2; \
fi; \
fi && \
apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# 从 uv 阶段复制二进制文件
COPY --from=uv /uv /usr/local/bin/uv
RUN chmod +x /usr/local/bin/uv
# 复制项目配置文件
COPY pyproject.toml ./
COPY uv.lock* ./
# 配置 PyPI 镜像源(如果指定)
ENV UV_INDEX_URL=${PYPI_INDEX_URL}
# 配置 uv 网络超时
ENV UV_HTTP_TIMEOUT=300
# 安装 Python 依赖
# 如果有 uv.lock 文件则使用 --frozen,否则生成新的锁定文件
RUN if [ -f uv.lock ]; then \
uv sync --frozen; \
else \
uv sync; \
fi
# 复制后端代码
COPY backend/ ./backend/
# 复制测试资源
COPY assets/ ./assets/
# 创建必要的目录
RUN mkdir -p /app/backend/instance /app/uploads
ENV PYTHONPATH=/app
ENV FLASK_APP=backend/app.py
# 容器内固定监听 5000;宿主机端口由 docker-compose 的 BACKEND_PORT 控制
ENV IN_DOCKER=1
# 暴露端口
EXPOSE 5000
# 容器内部固定使用 5000 端口
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["sh", "-c", "curl -f http://localhost:5000/health || exit 1"]
# 启动应用
CMD ["sh", "-c", "uv run --directory backend alembic upgrade head && uv run --directory backend python app.py"]
================================================
FILE: backend/README.md
================================================
# Banana Slides Backend
蕉幻(Banana Slides)后端服务 - AI驱动的PPT生成系统
## 技术栈
- **框架**: Flask 3.0
- **数据库**: SQLite + SQLAlchemy ORM
- **AI服务**: Google Gemini API
- **PPT处理**: python-pptx
- **并发处理**: ThreadPoolExecutor
- **包管理**: uv
## 项目结构
```
backend/
├── app.py # Flask应用入口
├── config.py # 配置文件
├── models/ # 数据库模型
│ ├── __init__.py
│ ├── project.py # Project模型
│ ├── page.py # Page模型
│ └── task.py # Task模型
├── services/ # 服务层
│ ├── __init__.py
│ ├── ai_service.py # AI相关服务
│ ├── file_service.py # 文件管理服务
│ ├── export_service.py # 导出服务
│ └── task_manager.py # 异步任务管理
├── controllers/ # 控制器层
│ ├── __init__.py
│ ├── project_controller.py
│ ├── page_controller.py
│ ├── template_controller.py
│ ├── export_controller.py
│ └── file_controller.py
├── utils/ # 工具函数
│ ├── __init__.py
│ ├── response.py # 统一响应格式
│ └── validators.py # 数据验证
├── instance/ # 数据库文件目录(自动创建)
├── uploads/ # 文件上传目录(自动创建)
├── .env.example # 环境变量示例
└── README.md # 本文件
```
## 快速开始
### 1. 安装依赖
本项目使用 [uv](https://github.com/astral-sh/uv) 管理 Python 依赖。所有依赖定义在项目根目录的 `pyproject.toml` 文件中。
在项目根目录下运行:
```bash
uv sync
```
这将自动安装所有必需的依赖包。
### 2. 配置环境变量
复制 `.env.example` 为 `.env` 并填写配置:
```bash
cp .env.example .env
```
编辑 `.env` 文件:
```env
GOOGLE_API_KEY=your-google-api-key
GOOGLE_API_BASE=https://generativelanguage.googleapis.com
# 火山引擎配置(可选,用于 Inpainting 图像消除功能)
VOLCENGINE_ACCESS_KEY=your-volcengine-access-key
VOLCENGINE_SECRET_KEY=your-volcengine-secret-key
VOLCENGINE_INPAINTING_TIMEOUT=60
VOLCENGINE_INPAINTING_MAX_RETRIES=3
```
### 3. 初始化 / 升级数据库结构(Alembic 迁移)
从当前版本开始,后端使用 Alembic 管理数据库结构变更。
```bash
cd backend
uv run alembic upgrade head
```
> 注意:
> - 首次运行时会自动创建 `alembic_version` 表并将数据库迁移到最新结构;
> - 后续新增模型字段时,只需要更新 `models/`,然后使用 `alembic revision --autogenerate` 生成迁移,再执行 `alembic upgrade head`。
### 4. 运行服务
使用 uv 运行:
```bash
cd backend
uv run python app.py
```
服务将在 `http://localhost:5000` 启动。
## API文档
完整的API文档请参考项目根目录的 `API设计文档.md`。
### 主要端点
#### 项目管理
- `POST /api/projects` - 创建项目
- `GET /api/projects/{project_id}` - 获取项目详情
- `PUT /api/projects/{project_id}` - 更新项目
- `DELETE /api/projects/{project_id}` - 删除项目
#### 大纲生成
- `POST /api/projects/{project_id}/generate/outline` - 生成大纲
#### 描述生成
- `POST /api/projects/{project_id}/generate/descriptions` - 批量生成描述(异步)
- `POST /api/projects/{project_id}/pages/{page_id}/generate/description` - 单页生成
#### 图片生成
- `POST /api/projects/{project_id}/generate/images` - 批量生成图片(异步)
- `POST /api/projects/{project_id}/pages/{page_id}/generate/image` - 单页生成
- `POST /api/projects/{project_id}/pages/{page_id}/edit/image` - 编辑图片
#### 模板管理
- `POST /api/projects/{project_id}/template` - 上传模板
- `DELETE /api/projects/{project_id}/template` - 删除模板
#### 导出
- `GET /api/projects/{project_id}/export/pptx` - 导出PPTX
- `GET /api/projects/{project_id}/export/pdf` - 导出PDF
#### 静态文件
- `GET /files/{project_id}/{type}/{filename}` - 获取文件
## 核心功能
### 1. AI驱动的内容生成
基于 Google Gemini API,支持:
- 自动生成PPT大纲
- 并行生成页面描述
- 根据参考模板生成图片
- 自然语言编辑图片
### 2. 异步任务处理
使用 `ThreadPoolExecutor` 实现简单但高效的异步任务处理:
- 并行生成多个页面描述
- 并行生成多个页面图片
- 实时任务进度跟踪
### 3. 文件管理
完整的文件管理系统:
- 项目级文件隔离
- 模板图片管理
- 生成图片管理
- 自动清理机制
### 4. Inpainting 图像消除(可选)
基于火山引擎的 Inpainting 服务,支持:
- 根据边界框(bbox)精确消除图像区域
- 自动生成掩码图像
- 重新生成背景(保留前景,消除其他区域)
- 支持批量处理和重试机制
使用方法:
```python
from services.inpainting_service import InpaintingService, remove_regions
from PIL import Image
# 方式1:使用服务类
service = InpaintingService()
image = Image.open('original.png')
bboxes = [(100, 100, 200, 200), (300, 150, 400, 250)] # 要消除的区域
result = service.remove_regions_by_bboxes(image, bboxes)
# 方式2:使用便捷函数
result = remove_regions(image, bboxes, expand_pixels=5)
```
### 5. 数据持久化
使用 SQLite + SQLAlchemy:
- 轻量级,无需额外配置
- 支持关系型数据操作
- 事务保证数据一致性
## 开发说明
### 数据模型
#### Project(项目)
- 项目基本信息
- 模板图片路径
- 项目状态
- 关联的页面和任务
#### Page(页面)
- 页面顺序
- 大纲内容(JSON)
- 描述内容(JSON)
- 生成的图片路径
- 页面状态
#### Task(任务)
- 任务类型(生成描述/生成图片)
- 任务状态
- 进度信息(JSON)
- 错误信息
### 状态机
#### 项目状态
```
DRAFT → OUTLINE_GENERATED → DESCRIPTIONS_GENERATED → GENERATING_IMAGES → COMPLETED
```
#### 页面状态
```
DRAFT → DESCRIPTION_GENERATED → GENERATING → COMPLETED | FAILED
```
#### 任务状态
```
PENDING → PROCESSING → COMPLETED | FAILED
```
### 扩展开发
#### 添加新的AI模型
在 `services/ai_service.py` 中添加新的模型支持:
```python
class AIService:
def __init__(self, api_key: str, model_type: str = 'gemini'):
if model_type == 'gemini':
# Gemini implementation
elif model_type == 'openai':
# OpenAI implementation
# ...
```
#### 自定义提示词模板
修改 `services/ai_service.py` 中的提示词生成逻辑:
```python
def generate_image_prompt(self, ...):
prompt = dedent(f"""
# 自定义提示词模板
...
""")
return prompt
```
#### 添加新的导出格式
在 `services/export_service.py` 中添加新的导出方法:
```python
class ExportService:
@staticmethod
def create_custom_format(image_paths, output_file):
# 实现自定义格式导出
pass
```
## 测试
### 健康检查
```bash
curl http://localhost:5000/health
```
### 创建项目
```bash
curl -X POST http://localhost:5000/api/projects \
-H "Content-Type: application/json" \
-d '{"creation_type":"idea","idea_prompt":"生成环保主题ppt"}'
```
### 上传模板
```bash
curl -X POST http://localhost:5000/api/projects/{project_id}/template \
-F "template_image=@template.png"
```
### 生成大纲
```bash
curl -X POST http://localhost:5000/api/projects/{project_id}/generate/outline \
-H "Content-Type: application/json" \
-d '{"idea_prompt":"生成环保主题ppt"}'
```
## 常见问题
### Q: 数据库文件在哪里?
A: 在 `backend/instance/database.db`,会自动创建。
### Q: 上传的文件存在哪里?
A: 在 `uploads/{project_id}/` 目录下,按项目隔离。
### Q: 如何修改并发数?
A: 推荐通过前端设置页修改(会同步到数据库并覆盖 `.env` 值);也可以在 `.env` 文件中修改 `MAX_DESCRIPTION_WORKERS` 和 `MAX_IMAGE_WORKERS` 作为默认值,然后在设置页点击“重置为默认值”同步到 DB。
### Q: 如何切换到其他AI模型 / 修改 MinerU 地址?
A: 从当前版本开始,推荐通过前端“系统设置”页面修改:
- 大模型提供商格式 / API Base / API Key
- 文本模型 (`TEXT_MODEL`) / 图片模型 (`IMAGE_MODEL`)
- MinerU 地址 (`MINERU_API_BASE`) / 图片识别模型 (`IMAGE_CAPTION_MODEL`)
这些值会保存到 `settings` 表并覆盖 `.env` 中对应配置,点击“重置为默认值”会回到 `.env` 的默认值。
### Q: 支持哪些图片格式?
A: PNG, JPG, JPEG, GIF, WEBP。在 `config.py` 中的 `ALLOWED_EXTENSIONS` 配置。
## 开源字体说明
本项目包含 **Noto Sans CJK SC**(思源黑体简体中文)字体文件,用于 PPT 导出时的精确文本测量。
- **字体文件**: `fonts/NotoSansSC-Regular.ttf`
- **来源**: [Google Noto CJK Fonts](https://github.com/googlefonts/noto-cjk)
- **许可证**: [SIL Open Font License 1.1 (OFL)](https://scripts.sil.org/OFL)
OFL 许可证允许自由使用、修改和分发该字体。
## 联系方式
如有问题或建议,请通过 GitHub Issues 反馈。
================================================
FILE: backend/alembic.ini
================================================
[alembic]
script_location = migrations
sqlalchemy.url = sqlite:///placeholder.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
================================================
FILE: backend/app.py
================================================
"""
Simplified Flask Application Entry Point
"""
import os
import sys
import hmac
import logging
from pathlib import Path
from dotenv import load_dotenv
from sqlalchemy import event
from sqlalchemy.engine import Engine
import sqlite3
from sqlalchemy.exc import SQLAlchemyError
from flask_migrate import Migrate
# Load environment variables from project root .env file
_project_root = Path(__file__).parent.parent
_env_file = _project_root / '.env'
load_dotenv(dotenv_path=_env_file, override=True)
from flask import Flask
from flask_cors import CORS
from models import db
from config import Config
from controllers.material_controller import material_bp, material_global_bp
from controllers.reference_file_controller import reference_file_bp
from controllers.settings_controller import settings_bp
from controllers import project_bp, page_bp, template_bp, user_template_bp, export_bp, file_bp, style_bp
# Enable SQLite WAL mode for all connections
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_conn, connection_record):
"""
Enable WAL mode and related PRAGMAs for each SQLite connection.
Registered once at import time to avoid duplicate handlers when
create_app() is called multiple times.
"""
# Only apply to SQLite connections
if not isinstance(dbapi_conn, sqlite3.Connection):
return
cursor = dbapi_conn.cursor()
try:
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA busy_timeout=30000") # 30 seconds timeout
finally:
cursor.close()
def create_app():
"""Application factory"""
app = Flask(__name__)
# Load configuration from Config class
app.config.from_object(Config)
# Override with environment-specific paths (use absolute path)
backend_dir = os.path.dirname(os.path.abspath(__file__))
instance_dir = os.path.join(backend_dir, 'instance')
os.makedirs(instance_dir, exist_ok=True)
db_path = os.path.join(instance_dir, 'database.db')
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
# Ensure upload folder exists
project_root = os.path.dirname(backend_dir)
upload_folder = os.path.join(project_root, 'uploads')
os.makedirs(upload_folder, exist_ok=True)
app.config['UPLOAD_FOLDER'] = upload_folder
# CORS configuration (parse from environment)
raw_cors = os.getenv('CORS_ORIGINS', 'http://localhost:3000')
if raw_cors.strip() == '*':
cors_origins = '*'
else:
cors_origins = [o.strip() for o in raw_cors.split(',') if o.strip()]
app.config['CORS_ORIGINS'] = cors_origins
# Initialize logging (log to stdout so Docker can capture it)
log_level = getattr(logging, app.config['LOG_LEVEL'], logging.INFO)
logging.basicConfig(
level=log_level,
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
# 设置第三方库的日志级别,避免过多的DEBUG日志
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
logging.getLogger('httpcore').setLevel(logging.WARNING)
logging.getLogger('httpx').setLevel(logging.WARNING)
logging.getLogger('urllib3').setLevel(logging.WARNING)
logging.getLogger('werkzeug').setLevel(logging.INFO) # Flask开发服务器日志保持INFO
logging.getLogger('volcenginesdkarkruntime').setLevel(logging.WARNING)
# Initialize extensions
db.init_app(app)
CORS(app, origins=cors_origins)
# Database migrations (Alembic via Flask-Migrate)
Migrate(app, db)
# Register blueprints
app.register_blueprint(project_bp)
app.register_blueprint(page_bp)
app.register_blueprint(template_bp)
app.register_blueprint(user_template_bp)
app.register_blueprint(export_bp)
app.register_blueprint(file_bp)
app.register_blueprint(material_bp)
app.register_blueprint(material_global_bp)
app.register_blueprint(reference_file_bp, url_prefix='/api/reference-files')
app.register_blueprint(settings_bp)
app.register_blueprint(style_bp)
with app.app_context():
# Load settings from database and sync to app.config
_load_settings_to_config(app)
# Access code enforcement on all /api/ routes
@app.before_request
def _enforce_access_code():
from flask import request, jsonify
expected = os.getenv('ACCESS_CODE', '').strip()
if not expected:
return # not enabled
if not request.path.startswith('/api/'):
return # non-API routes (health, static, etc.)
if request.path.startswith('/api/access-code/'):
return # allow check/verify endpoints
code = request.headers.get('X-Access-Code', '')
if hmac.compare_digest(code, expected):
return
return jsonify({'error': 'Access code required'}), 403
# Health check endpoint
@app.route('/health')
def health_check():
return {'status': 'ok', 'message': 'Banana Slides API is running'}
# Access code verification
@app.route('/api/access-code/check', methods=['GET'])
def check_access_code():
"""Check if access code protection is enabled"""
enabled = bool(os.getenv('ACCESS_CODE', '').strip())
return {'data': {'enabled': enabled}}
@app.route('/api/access-code/verify', methods=['POST'])
def verify_access_code():
"""Verify the provided access code"""
from flask import request, jsonify
expected = os.getenv('ACCESS_CODE', '').strip()
if not expected:
return {'data': {'valid': True}}
code = (request.json or {}).get('code', '')
if hmac.compare_digest(code, expected):
return {'data': {'valid': True}}
return jsonify({'error': 'Invalid access code'}), 403
# Output language endpoint
@app.route('/api/output-language', methods=['GET'])
def get_output_language():
"""
获取用户的输出语言偏好(从数据库 Settings 读取)
返回: zh, ja, en, auto
"""
from models import Settings
try:
settings = Settings.get_settings()
return {'data': {'language': settings.output_language or Config.OUTPUT_LANGUAGE}}
except SQLAlchemyError as db_error:
logging.warning(f"Failed to load output language from settings: {db_error}")
return {'data': {'language': Config.OUTPUT_LANGUAGE}} # 默认中文
# Root endpoint
@app.route('/')
def index():
return {
'name': 'Banana Slides API',
'version': '1.0.0',
'description': 'AI-powered PPT generation service',
'endpoints': {
'health': '/health',
'api_docs': '/api',
'projects': '/api/projects'
}
}
return app
def _load_settings_to_config(app):
"""Load settings from database and apply to app.config on startup"""
from models import Settings
try:
settings = Settings.get_settings()
# Load AI provider format (always sync, has default value)
if settings.ai_provider_format:
app.config['AI_PROVIDER_FORMAT'] = settings.ai_provider_format
logging.info(f"Loaded AI_PROVIDER_FORMAT from settings: {settings.ai_provider_format}")
# Load API configuration
# Note: We load even if value is None/empty to allow clearing settings
# But we only log if there's an actual value
if settings.api_base_url is not None:
# 将数据库中的统一 API Base 同步到 Google/OpenAI 两个配置,确保覆盖环境变量
app.config['GOOGLE_API_BASE'] = settings.api_base_url
app.config['OPENAI_API_BASE'] = settings.api_base_url
if settings.api_base_url:
logging.info(f"Loaded API_BASE from settings: {settings.api_base_url}")
else:
logging.info("API_BASE is empty in settings, using env var or default")
if settings.api_key is not None:
# 同步到两个提供商的 key,数据库优先于环境变量
app.config['GOOGLE_API_KEY'] = settings.api_key
app.config['OPENAI_API_KEY'] = settings.api_key
if settings.api_key:
logging.info("Loaded API key from settings")
else:
logging.info("API key is empty in settings, using env var or default")
# Load image generation settings (fall back to .env/Config when NULL)
resolution = settings.image_resolution or Config.DEFAULT_RESOLUTION
aspect_ratio = settings.image_aspect_ratio or Config.DEFAULT_ASPECT_RATIO
app.config['DEFAULT_RESOLUTION'] = resolution
app.config['DEFAULT_ASPECT_RATIO'] = aspect_ratio
logging.info(f"Loaded image settings: {resolution}, {aspect_ratio}")
# Load worker settings (fall back to .env/Config when NULL)
desc_workers = settings.max_description_workers or Config.MAX_DESCRIPTION_WORKERS
img_workers = settings.max_image_workers or Config.MAX_IMAGE_WORKERS
app.config['MAX_DESCRIPTION_WORKERS'] = desc_workers
app.config['MAX_IMAGE_WORKERS'] = img_workers
logging.info(f"Loaded worker settings: desc={desc_workers}, img={img_workers}")
# Load model settings (FIX for Issue #136: these were missing before)
if settings.text_model:
app.config['TEXT_MODEL'] = settings.text_model
logging.info(f"Loaded TEXT_MODEL from settings: {settings.text_model}")
if settings.image_model:
app.config['IMAGE_MODEL'] = settings.image_model
logging.info(f"Loaded IMAGE_MODEL from settings: {settings.image_model}")
# Load MinerU settings
if settings.mineru_api_base:
app.config['MINERU_API_BASE'] = settings.mineru_api_base
logging.info(f"Loaded MINERU_API_BASE from settings: {settings.mineru_api_base}")
if settings.mineru_token:
app.config['MINERU_TOKEN'] = settings.mineru_token
logging.info("Loaded MINERU_TOKEN from settings")
# Load image caption model
if settings.image_caption_model:
app.config['IMAGE_CAPTION_MODEL'] = settings.image_caption_model
logging.info(f"Loaded IMAGE_CAPTION_MODEL from settings: {settings.image_caption_model}")
# Load output language
if settings.output_language:
app.config['OUTPUT_LANGUAGE'] = settings.output_language
logging.info(f"Loaded OUTPUT_LANGUAGE from settings: {settings.output_language}")
# Load reasoning mode settings (separate for text and image)
app.config['ENABLE_TEXT_REASONING'] = settings.enable_text_reasoning
app.config['TEXT_THINKING_BUDGET'] = settings.text_thinking_budget
app.config['ENABLE_IMAGE_REASONING'] = settings.enable_image_reasoning
app.config['IMAGE_THINKING_BUDGET'] = settings.image_thinking_budget
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})")
# Load Baidu API settings
if settings.baidu_api_key:
app.config['BAIDU_API_KEY'] = settings.baidu_api_key
logging.info("Loaded BAIDU_API_KEY from settings")
# Load LazyLLM source settings
if settings.text_model_source:
app.config['TEXT_MODEL_SOURCE'] = settings.text_model_source
logging.info(f"Loaded TEXT_MODEL_SOURCE from settings: {settings.text_model_source}")
if settings.image_model_source:
app.config['IMAGE_MODEL_SOURCE'] = settings.image_model_source
logging.info(f"Loaded IMAGE_MODEL_SOURCE from settings: {settings.image_model_source}")
if settings.image_caption_model_source:
app.config['IMAGE_CAPTION_MODEL_SOURCE'] = settings.image_caption_model_source
logging.info(f"Loaded IMAGE_CAPTION_MODEL_SOURCE from settings: {settings.image_caption_model_source}")
# Load per-model API credentials (for gemini/openai per-model overrides)
for model_type in ('text', 'image', 'image_caption'):
prefix = model_type.upper()
for suffix, setting_suffix in [('_API_KEY', '_api_key'), ('_API_BASE', '_api_base_url')]:
config_key = f'{prefix}{suffix}'
val = getattr(settings, f'{model_type}{setting_suffix}', None)
if val:
app.config[config_key] = val
if suffix == '_API_BASE':
logging.info(f"Loaded {config_key} from settings: {val}")
else:
logging.info(f"Loaded {config_key} from settings")
# Sync LazyLLM vendor API keys to environment variables
# Only allow known vendor names to prevent environment variable injection
from services.ai_providers.lazyllm_env import ALLOWED_LAZYLLM_VENDORS
if settings.lazyllm_api_keys:
import json
try:
keys = json.loads(settings.lazyllm_api_keys)
for vendor, key in keys.items():
if key and vendor.lower() in ALLOWED_LAZYLLM_VENDORS:
os.environ[f"{vendor.upper()}_API_KEY"] = key
elif key:
logging.warning(f"Ignoring unknown lazyllm vendor: {vendor}")
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]}")
except (json.JSONDecodeError, TypeError):
logging.warning("Failed to parse lazyllm_api_keys from settings")
except Exception as e:
if isinstance(e, SQLAlchemyError) and "no such table: settings" in str(e):
logging.debug(f"Settings table not yet created (expected on first boot): {e}")
else:
logging.warning(f"Could not load settings from database: {e}")
# Create app instance
app = create_app()
def _compute_worktree_port(base_port: int) -> int:
"""Compute a deterministic port from the worktree directory name.
Uses MD5 of the project root basename so each worktree gets a unique,
stable port pair (backend 5xxx, frontend 3xxx) without manual config.
"""
import hashlib
basename = _project_root.name
offset = int(hashlib.md5(basename.encode()).hexdigest()[:8], 16) % 500
return base_port + offset
if __name__ == '__main__':
# Run development server
if os.getenv("IN_DOCKER", "0") == "1":
port = 5000 # Docker 容器内部固定使用 5000 端口
elif os.getenv('BACKEND_PORT'):
port = int(os.getenv('BACKEND_PORT'))
else:
port = _compute_worktree_port(5000)
debug = os.getenv('FLASK_ENV', 'development') == 'development'
logging.info(
"\n"
"╔══════════════════════════════════════╗\n"
"║ 🍌 Banana Slides API Server 🍌 ║\n"
"╚══════════════════════════════════════╝\n"
f"Server starting on: http://localhost:{port}\n"
f"Output Language: {Config.OUTPUT_LANGUAGE}\n"
f"Environment: {os.getenv('FLASK_ENV', 'development')}\n"
f"Debug mode: {debug}\n"
f"API Base URL: http://localhost:{port}/api\n"
f"Database: {app.config['SQLALCHEMY_DATABASE_URI']}\n"
f"Uploads: {app.config['UPLOAD_FOLDER']}"
)
# Using absolute paths for database, so WSL path issues should not occur
app.run(host='0.0.0.0', port=port, debug=debug, use_reloader=debug)
================================================
FILE: backend/config.py
================================================
"""
Backend configuration file
"""
import os
import sys
from datetime import timedelta
# 基础配置 - 使用更可靠的路径计算方式
# 在模块加载时立即计算并固定路径
_current_file = os.path.realpath(__file__) # 使用realpath解析所有符号链接
BASE_DIR = os.path.dirname(_current_file)
PROJECT_ROOT = os.path.dirname(BASE_DIR)
# Flask配置
class Config:
"""Base configuration"""
SECRET_KEY = os.getenv('SECRET_KEY', 'your-secret-key-change-this')
# 数据库配置
# Use absolute path to avoid WSL path issues
db_path = os.path.join(BASE_DIR, 'instance', 'database.db')
SQLALCHEMY_DATABASE_URI = os.getenv(
'DATABASE_URL',
f'sqlite:///{db_path}'
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
# SQLite线程安全配置 - 关键修复
SQLALCHEMY_ENGINE_OPTIONS = {
'connect_args': {
'check_same_thread': False, # 允许跨线程使用(仅SQLite)
'timeout': 30 # 增加超时时间
},
'pool_pre_ping': True, # 连接前检查
'pool_recycle': 3600, # 1小时回收连接
}
# 文件存储配置
UPLOAD_FOLDER = os.path.join(PROJECT_ROOT, 'uploads')
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200MB max file size
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
ALLOWED_REFERENCE_FILE_EXTENSIONS = {'pdf', 'docx', 'pptx', 'doc', 'ppt', 'xlsx', 'xls', 'csv', 'txt', 'md'}
# AI服务配置
GOOGLE_API_KEY = os.getenv('GOOGLE_API_KEY', '')
GOOGLE_API_BASE = os.getenv('GOOGLE_API_BASE', '')
# Provider format: gemini | openai | vertex | lazyllm
AI_PROVIDER_FORMAT = os.getenv('AI_PROVIDER_FORMAT', 'gemini')
# Google Cloud Vertex AI (requires AI_PROVIDER_FORMAT=vertex)
VERTEX_PROJECT_ID = os.getenv('VERTEX_PROJECT_ID', '')
VERTEX_LOCATION = os.getenv('VERTEX_LOCATION', 'us-central1')
# GenAI (Gemini) 格式专用配置
GENAI_TIMEOUT = float(os.getenv('GENAI_TIMEOUT', '300.0')) # Gemini 超时时间(秒)
GENAI_MAX_RETRIES = int(os.getenv('GENAI_MAX_RETRIES', '2')) # Gemini 最大重试次数(应用层实现)
# OpenAI 格式专用配置(当 AI_PROVIDER_FORMAT=openai 时使用)
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY', '') # 当 AI_PROVIDER_FORMAT=openai 时必须设置
OPENAI_API_BASE = os.getenv('OPENAI_API_BASE', 'https://aihubmix.com/v1')
OPENAI_TIMEOUT = float(os.getenv('OPENAI_TIMEOUT', '300.0')) # 增加到 5 分钟(生成清洁背景图需要很长时间)
OPENAI_MAX_RETRIES = int(os.getenv('OPENAI_MAX_RETRIES', '2')) # 减少重试次数,避免过多重试导致累积超时
# Lazyllm 格式专用配置(当 AI_PROVIDER_FORMAT=lazyllm 时使用)
TEXT_MODEL_SOURCE = os.getenv('TEXT_MODEL_SOURCE', '') # 文本生成模型厂商(留空则跟随全局 AI_PROVIDER_FORMAT)
IMAGE_MODEL_SOURCE = os.getenv('IMAGE_MODEL_SOURCE', '') # 图片生成模型厂商(留空则跟随全局 AI_PROVIDER_FORMAT)
IMAGE_CAPTION_MODEL_SOURCE = os.getenv('IMAGE_CAPTION_MODEL_SOURCE', '') # 图片识别模型厂商(留空则跟随全局 AI_PROVIDER_FORMAT)
# AI 模型配置
TEXT_MODEL = os.getenv('TEXT_MODEL', 'gemini-3-flash-preview')
IMAGE_MODEL = os.getenv('IMAGE_MODEL', 'gemini-3-pro-image-preview')
# MinerU 文件解析服务配置
MINERU_TOKEN = os.getenv('MINERU_TOKEN', '')
MINERU_API_BASE = os.getenv('MINERU_API_BASE', 'https://mineru.net')
# 图片识别模型配置
IMAGE_CAPTION_MODEL = os.getenv('IMAGE_CAPTION_MODEL', 'gemini-3-flash-preview')
# 并发配置
MAX_DESCRIPTION_WORKERS = int(os.getenv('MAX_DESCRIPTION_WORKERS', '5'))
MAX_IMAGE_WORKERS = int(os.getenv('MAX_IMAGE_WORKERS', '8'))
# 图片生成配置
DEFAULT_ASPECT_RATIO = "16:9"
DEFAULT_RESOLUTION = "2K"
# 日志配置
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
# CORS配置
CORS_ORIGINS = os.getenv('CORS_ORIGINS', 'http://localhost:3000').split(',')
# 输出语言配置
# 可选值: 'zh' (中文), 'ja' (日本語), 'en' (English), 'auto' (自动)
OUTPUT_LANGUAGE = os.getenv('OUTPUT_LANGUAGE', 'zh')
# 火山引擎配置
VOLCENGINE_ACCESS_KEY = os.getenv('VOLCENGINE_ACCESS_KEY', '')
VOLCENGINE_SECRET_KEY = os.getenv('VOLCENGINE_SECRET_KEY', '')
VOLCENGINE_INPAINTING_TIMEOUT = int(os.getenv('VOLCENGINE_INPAINTING_TIMEOUT', '60')) # Inpainting 超时时间(秒)
VOLCENGINE_INPAINTING_MAX_RETRIES = int(os.getenv('VOLCENGINE_INPAINTING_MAX_RETRIES', '3')) # 最大重试次数
# Inpainting Provider 配置(用于 InpaintingService 的单张图片修复)
# 可选值: 'volcengine' (火山引擎), 'gemini' (Google Gemini)
# 注意: 可编辑PPTX导出功能使用 ImageEditabilityService,其中 HybridInpaintProvider 会结合百度重绘和生成式质量增强
INPAINTING_PROVIDER = os.getenv('INPAINTING_PROVIDER', 'gemini') # 默认使用 Gemini
# 百度 API 配置(用于 OCR 和图像修复)
BAIDU_API_KEY = os.getenv('BAIDU_API_KEY', '') or os.getenv('BAIDU_OCR_API_KEY', '')
class DevelopmentConfig(Config):
"""Development configuration"""
DEBUG = True
class ProductionConfig(Config):
"""Production configuration"""
DEBUG = False
# 根据环境变量选择配置
config_map = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}
def get_config():
"""Get configuration based on environment"""
env = os.getenv('FLASK_ENV', 'development')
return config_map.get(env, DevelopmentConfig)
================================================
FILE: backend/controllers/__init__.py
================================================
"""Controllers package"""
from .project_controller import project_bp, style_bp
from .page_controller import page_bp
from .template_controller import template_bp, user_template_bp
from .export_controller import export_bp
from .file_controller import file_bp
from .material_controller import material_bp
from .settings_controller import settings_bp
__all__ = ['project_bp', 'style_bp', 'page_bp', 'template_bp', 'user_template_bp', 'export_bp', 'file_bp', 'material_bp', 'settings_bp']
================================================
FILE: backend/controllers/export_controller.py
================================================
"""
Export Controller - handles file export endpoints
"""
import logging
import os
import io
import shutil
import time
import zipfile
from flask import Blueprint, request, current_app
from werkzeug.utils import secure_filename
from models import db, Project, Page, Task
from utils import (
error_response, not_found, bad_request, success_response,
parse_page_ids_from_query, parse_page_ids_from_body, get_filtered_pages
)
from services import ExportService, FileService
from services.ai_service_manager import get_ai_service
logger = logging.getLogger(__name__)
export_bp = Blueprint('export', __name__, url_prefix='/api/projects')
@export_bp.route('/<project_id>/export/pptx', methods=['GET'])
def export_pptx(project_id):
"""
GET /api/projects/{project_id}/export/pptx?filename=...&page_ids=id1,id2,id3 - Export PPTX
Query params:
- filename: optional custom filename
- page_ids: optional comma-separated page IDs to export (if not provided, exports all pages)
Returns:
JSON with download URL, e.g.
{
"success": true,
"data": {
"download_url": "/files/{project_id}/exports/xxx.pptx",
"download_url_absolute": "http://host:port/files/{project_id}/exports/xxx.pptx"
}
}
"""
try:
project = Project.query.get(project_id)
if not project:
return not_found('Project')
# Get page_ids from query params and fetch filtered pages
selected_page_ids = parse_page_ids_from_query(request)
logger.debug(f"[export_pptx] selected_page_ids: {selected_page_ids}")
pages = get_filtered_pages(project_id, selected_page_ids if selected_page_ids else None)
logger.debug(f"[export_pptx] Exporting {len(pages)} pages")
if not pages:
return bad_request("No pages found for project")
# Get image paths
file_service = FileService(current_app.config['UPLOAD_FOLDER'])
image_paths = []
for page in pages:
if page.generated_image_path:
abs_path = file_service.get_absolute_path(page.generated_image_path)
image_paths.append(abs_path)
if not image_paths:
return bad_request("No generated images found for project")
# Determine export directory and filename
exports_dir = file_service._get_exports_dir(project_id)
# Get filename from query params or use default
filename = secure_filename(request.args.get('filename', f'presentation_{project_id}.pptx'))
if not filename.endswith('.pptx'):
filename += '.pptx'
output_path = os.path.join(exports_dir, filename)
# Generate PPTX file on disk
ExportService.create_pptx_from_images(image_paths, output_file=output_path, aspect_ratio=project.image_aspect_ratio)
# Build download URLs
download_path = f"/files/{project_id}/exports/{filename}"
base_url = request.url_root.rstrip("/")
download_url_absolute = f"{base_url}{download_path}"
return success_response(
data={
"download_url": download_path,
"download_url_absolute": download_url_absolute,
},
message="Export PPTX task created"
)
except Exception as e:
return error_response('SERVER_ERROR', str(e), 500)
@export_bp.route('/<project_id>/export/pdf', methods=['GET'])
def export_pdf(project_id):
"""
GET /api/projects/{project_id}/export/pdf?filename=...&page_ids=id1,id2,id3 - Export PDF
Query params:
- filename: optional custom filename
- page_ids: optional comma-separated page IDs to export (if not provided, exports all pages)
Returns:
JSON with download URL, e.g.
{
"success": true,
"data": {
"download_url": "/files/{project_id}/exports/xxx.pdf",
"download_url_absolute": "http://host:port/files/{project_id}/exports/xxx.pdf"
}
}
"""
try:
project = Project.query.get(project_id)
if not project:
return not_found('Project')
# Get page_ids from query params and fetch filtered pages
selected_page_ids = parse_page_ids_from_query(request)
pages = get_filtered_pages(project_id, selected_page_ids if selected_page_ids else None)
if not pages:
return bad_request("No pages found for project")
# Get image paths
file_service = FileService(current_app.config['UPLOAD_FOLDER'])
image_paths = []
for page in pages:
if page.generated_image_path:
abs_path = file_service.get_absolute_path(page.generated_image_path)
image_paths.append(abs_path)
if not image_paths:
return bad_request("No generated images found for project")
# Determine export directory and filename
exports_dir = file_service._get_exports_dir(project_id)
# Get filename from query params or use default
filename = secure_filename(request.args.get('filename', f'presentation_{project_id}.pdf'))
if not filename.endswith('.pdf'):
filename += '.pdf'
output_path = os.path.join(exports_dir, filename)
# Generate PDF file on disk
ExportService.create_pdf_from_images(image_paths, output_file=output_path, aspect_ratio=project.image_aspect_ratio)
# Build download URLs
download_path = f"/files/{project_id}/exports/{filename}"
base_url = request.url_root.rstrip("/")
download_url_absolute = f"{base_url}{download_path}"
return success_response(
data={
"download_url": download_path,
"download_url_absolute": download_url_absolute,
},
message="Export PDF task created"
)
except Exception as e:
return error_response('SERVER_ERROR', str(e), 500)
@export_bp.route('/<project_id>/export/images', methods=['GET'])
def export_images(project_id):
"""
GET /api/projects/{project_id}/export/images?page_ids=id1,id2,id3 - Export images
Single image: copies to exports dir and returns download URL.
Multiple images: creates a ZIP archive and returns download URL.
"""
try:
if '..' in project_id or '/' in project_id or '\\' in project_id:
return bad_request('Invalid project ID')
s_project_id = secure_filename(project_id)
if s_project_id != project_id:
return bad_request('Invalid project ID')
project = Project.query.get(s_project_id)
if not project:
return not_found('Project')
selected_page_ids = parse_page_ids_from_query(request)
pages = get_filtered_pages(s_project_id, selected_page_ids if selected_page_ids else None)
if not pages:
return bad_request("No pages found for project")
file_service = FileService(current_app.config['UPLOAD_FOLDER'])
image_items = []
for page in pages:
if page.generated_image_path:
abs_path = file_service.get_absolute_path(page.generated_image_path)
if os.path.exists(abs_path):
image_items.append((page, abs_path))
if not image_items:
return bad_request("No generated images found for project")
exports_dir = file_service._get_exports_dir(s_project_id)
timestamp = int(time.time())
if len(image_items) == 1:
page, path = image_items[0]
ext = os.path.splitext(path)[1] or '.png'
filename = f'slide_{page.id}_{timestamp}{ext}'
output_path = os.path.join(exports_dir, filename)
shutil.copy2(path, output_path)
else:
filename = f'slides_{s_project_id}_{timestamp}.zip'
output_path = os.path.join(exports_dir, filename)
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for page, path in image_items:
ext = os.path.splitext(path)[1] or '.png'
zf.write(path, f'slide_{page.order_index + 1:03d}{ext}')
download_path = f"/files/{s_project_id}/exports/{filename}"
base_url = request.url_root.rstrip("/")
return success_response(
data={
"download_url": download_path,
"download_url_absolute": f"{base_url}{download_path}",
},
message="Export images completed"
)
except Exception as e:
return error_response('SERVER_ERROR', str(e), 500)
@export_bp.route('/<project_id>/export/editable-pptx', methods=['POST'])
def export_editable_pptx(project_id):
"""
POST /api/projects/{project_id}/export/editable-pptx - 导出可编辑PPTX(异步)
使用递归分析方法(支持任意尺寸、递归子图分析)
这个端点创建一个异步任务来执行以下操作:
1. 递归分析图片(支持任意尺寸和分辨率)
2. 转换为PDF并上传MinerU识别
3. 提取元素bbox和生成clean background(inpainting)
4. 递归处理图片/图表中的子元素
5. 创建可编辑PPTX
Request body (JSON):
{
"filename": "optional_custom_name.pptx",
"page_ids": ["id1", "id2"], // 可选,要导出的页面ID列表(不提供则导出所有)
"max_depth": 1, // 可选,递归深度(默认1=不递归,2=递归一层)
"max_workers": 4 // 可选,并发数(默认4)
}
Returns:
JSON with task_id, e.g.
{
"success": true,
"data": {
"task_id": "uuid-here",
"method": "recursive_analysis",
"max_depth": 2,
"max_workers": 4
},
"message": "Export task created"
}
轮询 /api/projects/{project_id}/tasks/{task_id} 获取进度和下载链接
"""
try:
project = Project.query.get(project_id)
if not project:
return not_found('Project')
# Get parameters from request body
data = request.get_json() or {}
# Get page_ids from request body and fetch filtered pages
selected_page_ids = parse_page_ids_from_body(data)
pages = get_filtered_pages(project_id, selected_page_ids if selected_page_ids else None)
if not pages:
return bad_request("No pages found for project")
# Check if pages have generated images
has_images = any(page.generated_image_path for page in pages)
if not has_images:
return bad_request("No generated images found for project")
# Get parameters from request body
data = request.get_json() or {}
filename = data.get('filename', f'presentation_editable_{project_id}.pptx')
if not filename.endswith('.pptx'):
filename += '.pptx'
# 递归分析参数
# max_depth 语义:1=只处理表层不递归,2=递归一层(处理图片/图表中的子元素)
max_depth = data.get('max_depth', 1) # 默认不递归,与测试脚本一致
max_workers = data.get('max_workers', 4)
# Validate parameters
# max_depth >= 1: 至少处理表层元素
if not isinstance(max_depth, int) or max_depth < 1 or max_depth > 5:
return bad_request("max_depth must be an integer between 1 and 5")
if not isinstance(max_workers, int) or max_workers < 1 or max_workers > 16:
return bad_request("max_workers must be an integer between 1 and 16")
# Create task record
task = Task(
project_id=project_id,
task_type='EXPORT_EDITABLE_PPTX',
status='PENDING'
)
db.session.add(task)
db.session.commit()
logger.info(f"Created export task {task.id} for project {project_id} (recursive analysis: depth={max_depth}, workers={max_workers})")
# Get services
from services.file_service import FileService
from services.task_manager import task_manager, export_editable_pptx_with_recursive_analysis_task
file_service = FileService(current_app.config['UPLOAD_FOLDER'])
# Get Flask app instance for background task
app = current_app._get_current_object()
# 读取项目的导出设置
export_extractor_method = project.export_extractor_method or 'hybrid'
export_inpaint_method = project.export_inpaint_method or 'hybrid'
logger.info(f"Export settings: extractor={export_extractor_method}, inpaint={export_inpaint_method}")
# 使用递归分析任务(不需要 ai_service,使用 ImageEditabilityService)
task_manager.submit_task(
task.id,
export_editable_pptx_with_recursive_analysis_task,
project_id=project_id,
filename=filename,
file_service=file_service,
page_ids=selected_page_ids if selected_page_ids else None,
max_depth=max_depth,
max_workers=max_workers,
export_extractor_method=export_extractor_method,
export_inpaint_method=export_inpaint_method,
app=app
)
logger.info(f"Submitted recursive export task {task.id} to task manager")
return success_response(
data={
"task_id": task.id,
"method": "recursive_analysis",
"max_depth": max_depth,
"max_workers": max_workers
},
message="Export task created (using recursive analysis)"
)
except Exception as e:
logger.exception("Error creating export task")
return error_response('SERVER_ERROR', str(e), 500)
================================================
FILE: backend/controllers/file_controller.py
================================================
"""
File Controller - handles static file serving
"""
from flask import Blueprint, send_from_directory, current_app
from utils import error_response, not_found
from utils.path_utils import find_file_with_prefix
import os
from pathlib import Path
from werkzeug.utils import secure_filename
file_bp = Blueprint('files', __name__, url_prefix='/files')
@file_bp.route('/<project_id>/<file_type>/<filename>', methods=['GET'])
def serve_file(project_id, file_type, filename):
"""
GET /files/{project_id}/{type}/{filename} - Serve static files
Args:
project_id: Project UUID
file_type: 'template' or 'pages'
filename: File name
"""
try:
if file_type not in ['template', 'pages', 'materials', 'exports']:
return not_found('File')
# Construct file path
file_dir = os.path.join(
current_app.config['UPLOAD_FOLDER'],
project_id,
file_type
)
# Check if directory exists
if not os.path.exists(file_dir):
return not_found('File')
# Check if file exists
file_path = os.path.join(file_dir, filename)
if not os.path.exists(file_path):
return not_found('File')
# Serve file
return send_from_directory(file_dir, filename)
except Exception as e:
return error_response('SERVER_ERROR', str(e), 500)
@file_bp.route('/user-templates/<template_id>/<filename>', methods=['GET'])
def serve_user_template(template_id, filename):
"""
GET /files/user-templates/{template_id}/{filename} - Serve user template files
Args:
template_id: Template UUID
filename: File name
"""
try:
# Construct file path
file_dir = os.path.join(
current_app.config['UPLOAD_FOLDER'],
'user-templates',
template_id
)
# Check if directory exists
if not os.path.exists(file_dir):
return not_found('File')
# Check if file exists
file_path = os.path.join(file_dir, filename)
if not os.path.exists(file_path):
return not_found('File')
# Serve file
return send_from_directory(file_dir, filename)
except Exception as e:
return error_response('SERVER_ERROR', str(e), 500)
@file_bp.route('/materials/<filename>', methods=['GET'])
def serve_global_material(filename):
"""
GET /files/materials/{filename} - Serve global material files (not bound to a project)
Args:
filename: File name
"""
try:
safe_filename = secure_filename(filename)
# Construct file path
file_dir = os.path.join(
current_app.config['UPLOAD_FOLDER'],
'materials'
)
# Check if directory exists
if not os.path.exists(file_dir):
return not_found('File')
# Check if file exists
file_path = os.path.join(file_dir, safe_filename)
if not os.path.exists(file_path):
return not_found('File')
# Serve file
return send_from_directory(file_dir, safe_filename)
except Exception as e:
return error_response('SERVER_ERROR', str(e), 500)
@file_bp.route('/mineru/<extract_id>/<path:filepath>', methods=['GET'])
def serve_mineru_file(extract_id, filepath):
"""
GET /files/mineru/{extract_id}/{filepath} - Serve MinerU extracted files.
Args:
extract_id: Extract UUID
filepath: Relative file path within the extract
"""
try:
root_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'mineru_files', extract_id)
full_path = Path(root_dir) / filepath
# This prevents path traversal attacks
resolved_root_dir = Path(root_dir).resolve()
try:
# Check if the path is trying to escape the root directory
resolved_full_path = full_path.resolve()
if not str(resolved_full_path).startswith(str(resolved_root_dir)):
return error_response('INVALID_PATH', 'Invalid file path', 403)
except Exception:
# If we can't resolve the path at all, it's invalid
return error_response('INVALID_PATH', 'Invalid file path', 403)
# Try to find file with prefix matching
matched_path = find_file_with_prefix(full_path)
if matched_path is not None:
# Additional security check for matched path
try:
resolved_matched_path = matched_path.resolve(strict=True)
# Verify the matched file is still within the root directory
if not str(resolved_matched_path).startswith(str(resolved_root_dir)):
return error_response('INVALID_PATH', 'Invalid file path', 403)
except FileNotFoundError:
return not_found('File')
except Exception:
return error_response('INVALID_PATH', 'Invalid file path', 403)
return send_from_directory(str(matched_path.parent), matched_path.name)
return not_found('File')
except Exception as e:
return error_response('SERVER_ERROR', str(e), 500)
================================================
FILE: backend/controllers/material_controller.py
================================================
"""
Material Controller - handles standalone material image generation
"""
from flask import Blueprint, request, current_app, send_file
from models import db, Project, Material, Task
from utils import success_response, error_response, not_found, bad_request
from services import FileService
from services.ai_service_manager import get_ai_service
from services.task_manager import task_manager, generate_material_image_task
from pathlib import Path
from werkzeug.utils import secure_filename
from typing import Optional
import tempfile
import shutil
import time
import zipfile
import io
import base64
import logging
logger = logging.getLogger(__name__)
material_bp = Blueprint('materials', __name__, url_prefix='/api/projects')
material_global_bp = Blueprint('materials_global', __name__, url_prefix='/api/materials')
ALLOWED_MATERIAL_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'}
ALLOWED_ASPECT_RATIOS = frozenset({'16:9', '21:9', '4:3', '3:2', '5:4', '1:1', '4:5', '2:3', '3:4', '9:16'})
def _generate_image_caption(filepath: str) -> str:
"""Generate AI caption for an uploaded image. Returns empty string on failure."""
if filepath.lower().endswith('.svg'):
return ""
try:
from PIL import Image
image = Image.open(filepath)
image.thumbnail((1024, 1024), Image.Resampling.LANCZOS)
output_lang = current_app.config.get('OUTPUT_LANGUAGE', 'zh')
if output_lang == 'en':
prompt = "Please provide a short description of the main content of this image. Return only the description text without any other explanation."
else:
prompt = "请用一句简短的中文描述这张图片的主要内容。只返回描述文字,不要其他解释。"
provider_format = (current_app.config.get('AI_PROVIDER_FORMAT') or 'gemini').lower()
caption_model = current_app.config.get('IMAGE_CAPTION_MODEL', 'gemini-3-flash-preview')
if provider_format == 'openai':
from openai import OpenAI
api_key = current_app.config.get('OPENAI_API_KEY', '')
if not api_key:
return ""
client = OpenAI(
api_key=api_key,
base_url=current_app.config.get('OPENAI_API_BASE') or None
)
buffered = io.BytesIO()
if image.mode in ('RGBA', 'LA', 'P'):
background = Image.new('RGB', image.size, (255, 255, 255))
background.paste(image, mask=image.split()[-1] if image.mode in ('RGBA', 'LA') else None)
image = background
image.save(buffered, format="JPEG", quality=95)
base64_image = base64.b64encode(buffered.getvalue()).decode('utf-8')
response = client.chat.completions.create(
model=caption_model,
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}},
{"type": "text", "text": prompt}
]
}],
temperature=0.3
)
return response.choices[0].message.content.strip()
else:
# Gemini (default)
from google import genai
from google.genai import types
api_key = current_app.config.get('GOOGLE_API_KEY', '')
if not api_key:
return ""
api_base = current_app.config.get('GOOGLE_API_BASE', '')
client = genai.Client(
http_options=types.HttpOptions(base_url=api_base) if api_base else None,
api_key=api_key
)
result = client.models.generate_content(
model=caption_model,
contents=[image, prompt],
config=types.GenerateContentConfig(temperature=0.3)
)
return result.text.strip()
except Exception as e:
logger.warning(f"Failed to generate caption for {filepath}: {e}")
return ""
def _build_material_query(filter_project_id: str):
"""Build common material query with project validation."""
query = Material.query
if filter_project_id == 'all':
return query, None
if filter_project_id == 'none':
return query.filter(Material.project_id.is_(None)), None
project = Project.query.get(filter_project_id)
if not project:
return None, not_found('Project')
return query.filter(Material.project_id == filter_project_id), None
def _get_materials_list(filter_project_id: str):
"""
Common logic to get materials list.
Returns (materials_list, error_response)
"""
query, error = _build_material_query(filter_project_id)
if error:
return None, error
materials = query.order_by(Material.created_at.desc()).all()
materials_list = [material.to_dict() for material in materials]
return materials_list, None
def _handle_material_upload(default_project_id: Optional[str] = None):
"""
Common logic to handle material upload.
Returns Flask response object.
"""
try:
raw_project_id = request.args.get('project_id', default_project_id)
target_project_id, error = _resolve_target_project_id(raw_project_id)
if error:
return error
file = request.files.get('file')
material, error = _save_material_file(file, target_project_id)
if error:
return error
result = material.to_dict()
# Generate AI caption if requested
generate_caption = request.args.get('generate_caption', '').lower() in ('true', '1', 'yes')
if generate_caption:
file_service = FileService(current_app.config['UPLOAD_FOLDER'])
filepath = file_service.get_absolute_path(material.relative_path)
caption = _generate_image_caption(filepath)
result['caption'] = caption
return success_response(result, status_code=201)
except Exception as e:
db.session.rollback()
return error_response('SERVER_ERROR', str(e), 500)
def _resolve_target_project_id(raw_project_id: Optional[str], allow_none: bool = True):
"""
Normalize project_id from request.
Returns (project_id | None, error_response | None)
"""
if allow_none and (raw_project_id is None or raw_project_id == 'none'):
return None, None
if raw_project_id == 'all':
return None, bad_request("project_id cannot be 'all' when uploading materials")
if raw_project_id:
project = Project.query.get(raw_project_id)
if not project:
return None, not_found('Project')
return raw_project_id, None
def _save_material_file(file, target_project_id: Optional[str]):
"""Shared logic for saving uploaded material files to disk and DB."""
if not file or not file.filename:
return None, bad_request("file is required")
filename = secure_filename(file.filename)
file_ext = Path(filename).suffix.lower()
if file_ext not in ALLOWED_MATERIAL_EXTENSIONS:
return None, bad_request(f"Unsupported file type. Allowed: {', '.join(sorted(ALLOWED_MATERIAL_EXTENSIONS))}")
file_service = FileService(current_app.config['UPLOAD_FOLDER'])
if target_project_id:
materials_dir = file_service.upload_folder / file_service._get_materials_dir(target_project_id)
else:
materials_dir = file_service.upload_folder / "materials"
materials_dir.mkdir(exist_ok=True, parents=True)
timestamp = int(time.time() * 1000)
base_name = Path(filename).stem
unique_filename = f"{base_name}_{timestamp}{file_ext}"
filepath = materials_dir / unique_filename
file.save(str(filepath))
relative_path = str(filepath.relative_to(file_service.upload_folder))
if target_project_id:
image_url = file_service.get_file_url(target_project_id, 'materials', unique_filename)
else:
image_url = f"/files/materials/{unique_filename}"
material = Material(
project_id=target_project_id,
filename=unique_filename,
relative_path=relative_path,
url=image_url
)
try:
db.session.add(material)
db.session.commit()
return material, None
except Exception:
db.session.rollback()
raise
@material_bp.route('/<project_id>/materials/generate', methods=['POST'])
def generate_material_image(project_id):
"""
POST /api/projects/{project_id}/materials/generate - Generate a standalone material image
Supports multipart/form-data:
- prompt: Text-to-image prompt (passed directly to the model without modification)
- ref_image: Main reference image (optional)
- extra_images: Additional reference images (multiple files, optional)
Note: project_id can be 'none' to generate global materials (not associated with any project)
"""
try:
# 支持 'none' 作为特殊值,表示生成全局素材
if project_id != 'none':
project = Project.query.get(project_id)
if not project:
return not_found('Project')
else:
project = None
project_id = None # 设置为None表示全局素材
# Parse request data (prioritize multipart for file uploads)
if request.is_json:
data = request.get_json() or {}
prompt = data.get('prompt', '').strip()
ref_file = None
extra_files = []
else:
data = request.form.to_dict()
prompt = (data.get('prompt') or '').strip()
ref_file = request.files.get('ref_image')
extra_files = request.files.getlist('extra_images') or []
aspect_ratio = (data.get('aspect_ratio') or '').strip() or None
if aspect_ratio and aspect_ratio not in ALLOWED_ASPECT_RATIOS:
return bad_request(f"Invalid aspect ratio. Allowed values: {', '.join(sorted(ALLOWED_ASPECT_RATIOS))}")
if not prompt:
return bad_request("prompt is required")
# 处理project_id:对于全局素材,使用'global'作为Task的project_id
# Task模型要求project_id不能为null,但Material可以
task_project_id = project_id if project_id is not None else 'global'
# 验证project_id(如果不是'global')
if task_project_id != 'global':
project = Project.query.get(task_project_id)
if not project:
return not_found('Project')
# Initialize services
ai_service = get_ai_service()
file_service = FileService(current_app.config['UPLOAD_FOLDER'])
# 创建临时目录保存参考图片(后台任务会清理)
temp_dir = Path(tempfile.mkdtemp(dir=current_app.config['UPLOAD_FOLDER']))
temp_dir_str = str(temp_dir)
try:
ref_path = None
# Save main reference image to temp directory if provided
if ref_file and ref_file.filename:
ref_filename = secure_filename(ref_file.filename or 'ref.png')
ref_path = temp_dir / ref_filename
ref_file.save(str(ref_path))
ref_path_str = str(ref_path)
else:
ref_path_str = None
# Save additional reference images to temp directory
additional_ref_images = []
for extra in extra_files:
if not extra or not extra.filename:
continue
extra_filename = secure_filename(extra.filename)
extra_path = temp_dir / extra_filename
extra.save(str(extra_path))
additional_ref_images.append(str(extra_path))
# Create async task for material generation
task = Task(
project_id=task_project_id,
task_type='GENERATE_MATERIAL',
status='PENDING'
)
task.set_progress({
'total': 1,
'completed': 0,
'failed': 0
})
db.session.add(task)
db.session.commit()
# Get app instance for background task
app = current_app._get_current_object()
# Submit background task
task_manager.submit_task(
task.id,
generate_material_image_task,
task_project_id, # 传递给任务函数,它会处理'global'的情况
prompt,
ai_service,
file_service,
ref_path_str,
additional_ref_images if additional_ref_images else None,
aspect_ratio or (project.image_aspect_ratio if project else None) or current_app.config.get('DEFAULT_ASPECT_RATIO', '16:9'),
current_app.config['DEFAULT_RESOLUTION'],
temp_dir_str,
app
)
# Return task_id immediately (不再清理temp_dir,由后台任务清理)
return success_response({
'task_id': task.id,
'status': 'PENDING'
}, status_code=202)
except Exception as e:
# Clean up temp directory on error
if temp_dir.exists():
shutil.rmtree(temp_dir, ignore_errors=True)
raise
except Exception as e:
db.session.rollback()
return error_response('AI_SERVICE_ERROR', str(e), 503)
@material_bp.route('/<project_id>/materials', methods=['GET'])
def list_materials(project_id):
"""
GET /api/projects/{project_id}/materials - List materials for a specific project
Returns:
List of material images with filename, url, and metadata for the specified project
"""
try:
materials_list, error = _get_materials_list(project_id)
if error:
return error
return success_response({
"materials": materials_list,
"count": len(materials_list)
})
except Exception as e:
return error_response('SERVER_ERROR', str(e), 500)
@material_bp.route('/<project_id>/materials/upload', methods=['POST'])
def upload_material(project_id):
"""
POST /api/projects/{project_id}/materials/upload - Upload a material image
Supports multipart/form-data:
- file: Image file (required)
- project_id: Optional query parameter, defaults to path parameter if not provided
Returns:
Material info with filename, url, and metadata
"""
return _handle_material_upload(default_project_id=project_id)
@material_global_bp.route('', methods=['GET'])
def list_all_materials():
"""
GET /api/materials - Global materials endpoint for complex queries
Query params:
- project_id: Filter by project_id
* 'all' (default): Get all materials regardless of project
* 'none': Get only materials without a project (global materials)
* <project_id>: Get materials for specific project
Returns:
List of material images with filename, url, and metadata
"""
try:
filter_project_id = request.args.get('project_id', 'all')
materials_list, error = _get_materials_list(filter_project_id)
if error:
return error
return success_response({
"materials": materials_list,
"count": len(materials_list)
})
except Exception as e:
return error_response('SERVER_ERROR', str(e), 500)
@material_global_bp.route('/upload', methods=['POST'])
def upload_material_global():
"""
POST /api/materials/upload - Upload a material image (global, not bound to a project)
Supports multipart/form-data:
- file: Image file (required)
- project_id: Optional query parameter to associate with a project
Returns:
Material info with filename, url, and metadata
"""
return _handle_material_upload(default_project_id=None)
@material_global_bp.route('/<material_id>', methods=['DELETE'])
def delete_material(material_id):
"""
DELETE /api/materials/{material_id} - Delete a material and its file
"""
try:
material = Material.query.get(material_id)
if not material:
return not_found('Material')
file_service = FileService(current_app.config['UPLOAD_FOLDER'])
material_path = Path(file_service.get_absolute_path(material.relative_path))
# First, delete the database record to ensure data consistency
db.session.delete(material)
db.session.commit()
# Then, attempt to delete the file. If this fails, log the error
# but still return a success response. This leaves an orphan file,
try:
if material_path.exists():
material_path.unlink(missing_ok=True)
except OSError as e:
current_app.logger.warning(f"Failed to delete file for material {material_id} at {material_path}: {e}")
return success_response({"id": material_id})
except Exception as e:
db.session.rollback()
return error_response('SERVER_ERROR', str(e), 500)
@material_global_bp.route('/associate', methods=['POST'])
def associate_materials_to_project():
"""
POST /api/materials/associate - Associate materials to a project by URLs
Request body (JSON):
{
"project_id": "project_id",
"material_urls": ["url1", "url2", ...]
}
Returns:
List of associated material IDs and count
"""
try:
data = request.get_json() or {}
project_id = data.get('project_id')
material_urls = data.get('material_urls', [])
if not project_id:
return bad_request("project_id is required")
if not material_urls or not isinstance(material_urls, list):
return bad_request("material_urls must be a non-empty array")
# Validate project exists
project = Project.query.get(project_id)
if not project:
return not_found('Project')
# Find materials by URLs and update their project_id
updated_ids = []
materials_to_update = Material.query.filter(
Material.url.in_(material_urls),
Material.project_id.is_(None)
).all()
for material in materials_to_update:
material.project_id = project_id
updated_ids.append(material.id)
db.session.commit()
return success_response({
"updated_ids": updated_ids,
"count": len(updated_ids)
})
except Exception as e:
db.session.rollback()
return error_response('SERVER_ERROR', str(e), 500)
@material_global_bp.route('/download', methods=['POST'])
def download_materials_zip():
"""Bundle requested materials into a ZIP and stream it back."""
body = request.get_json(silent=True) or {}
ids = body.get('material_ids')
if not ids or not isinstance(ids, list):
return bad_request("material_ids must be a non-empty list")
MAX_BATCH = 200
if len(ids) > MAX_BATCH:
return bad_request(f"Too many materials requested (max {MAX_BATCH})")
rows = Material.query.filter(Material.id.in_(ids)).all()
if not rows:
return not_found('Materials')
tmp = tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024)
try:
fs = FileService(current_app.config['UPLOAD_FOLDER'])
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zf:
for row in rows:
abs_path = Path(fs.get_absolute_path(row.relative_path))
if not abs_path.is_file():
current_app.logger.warning("Skipping missing file for material %s", row.id)
continue
zf.write(str(abs_path), row.filename)
tmp.seek(0)
fname = f"materials_{int(time.time())}.zip"
return send_file(tmp, mimetype='application/zip',
as_attachment=True, download_name=fname)
except Exception:
tmp.close()
current_app.logger.exception("Failed to build materials zip")
return error_response('SERVER_ERROR', 'Failed to create zip archive', 500)
================================================
FILE: backend/controllers/page_controller.py
================================================
"""
Page Controller - handles page-related endpoints
"""
import logging
from flask import Blueprint, request, current_app
from models import db, Project, Page, PageImageVersion, Task
from utils import success_response, error_response, not_found, bad_request
from services import FileService, ProjectContext
from services.ai_service_manager import get_ai_service
from services.task_manager import task_manager, generate_single_page_image_task, edit_page_image_task
from datetime import datetime
from pathlib import Path
from werkzeug.utils import secure_filename
import shutil
import tempfile
import json
logger = logging.getLogger(__name__)
page_bp = Blueprint('pages', __name__, url_prefix='/api/projects')
@page_bp.route('/<project_id>/pages', methods=['POST'])
def create_page(project_id):
"""
POST /api/projects/{project_id}/pages - Add new page
Request body:
{
"order_index": 2,
"part": "optional",
"outline_content": {"title": "...", "points": [...]}
}
"""
try:
project = Project.query.get(project_id)
if not project:
return not_found('Project')
data = request.get_json()
if not data or 'order_index' not in data:
return bad_request("order_index is required")
# Create new page
page = Page(
project_id=project_id,
order_index=data['order_index'],
part=data.get('part'),
status='DRAFT'
)
if 'outline_content' in data:
page.set_outline_content(data['outline_content'])
if 'description_content' in data:
page.set_description_content(data['description_content'])
page.status = 'DESCRIPTION_GENERATED'
db.session.add(page)
# Update other pages' order_index if necessary
other_pages = Page.query.filter(
Page.project_id == project_id,
Page.order_index >= data['order_index']
).all()
for p in other_pages:
if p.id != page.id:
p.order_index += 1
project.updated_at = datetime.utcnow()
db.session.commit()
return success_response(page.to_dict(), status_code=201)
except Exception as e:
db.session.rollback()
return error_response('SERVER_ERROR', str(e), 500)
@page_bp.route('/<project_id>/pages/<page_id>', methods=['DELETE'])
def delete_page(project_id, page_id):
"""
DELETE /api/projects/{project_id}/pages/{page_id} - Delete page
"""
try:
page = Page.query.get(page_id)
if not page or page.project_id != project_id:
return not_found('Page')
# Delete page image if exists
file_service = FileService(current_app.config['UPLOAD_FOLDER'])
file_service.delete_page_image(project_id, page_id)
# Delete page
db.session.delete(page)
# Update project
project = Project.query.get(project_id)
if project:
project.updated_at = datetime.utcnow()
db.session.commit()
return success_response(message="Page deleted successfully")
except Exception as e:
db.session.rollback()
return error_response('SERVER_ERROR', str(e), 500)
@page_bp.route('/<project_id>/pages/<page_id>', methods=['PUT'])
def update_page(project_id, page_id):
"""
PUT /api/projects/{project_id}/pages/{page_id} - Update page fields
Request body:
{
"part": "章节名"
}
"""
try:
page = Page.query.get(page_id)
if not page or page.project_id != project_id:
return not_found('Page')
data = request.get_json()
if not data:
return bad_request("Request body is required")
# Update part field if provided
if 'part' in data:
page.part = data['part']
page.updated_at = datetime.utcnow()
# Update project
if page.project:
page.project.updated_at = datetime.utcnow()
db.session.commit()
return success_response(page.to_dict())
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update page {page_id}: {e}")
return error_response('SERVER_ERROR', 'An internal server error occurred', 500)
@page_bp.route('/<project_id>/pages/<page_id>/outline', methods=['PUT'])
def update_page_outline(project_id, page_id):
"""
PUT /api/projects/{project_id}/pages/{page_id}/outline - Edit page outline
Request body:
{
"outline_content": {"title": "...", "points": [...]}
}
"""
try:
page = Page.query.get(page_id)
if not page or page.project_id != project_id:
return not_found('Page')
data = request.get_json()
if not data or 'outline_content' not in data:
return bad_request("outline_content is required")
page.set_outline_content(data['outline_content'])
page.updated_at = datetime.utcnow()
# Update project
project = Project.query.get(project_id)
if project:
project.updated_at = datetime.utcnow()
db.session.commit()
return success_response(page.to_dict())
except Exception as e:
db.session.rollback()
return error_response('SERVER_ERROR', str(e), 500)
@page_bp.route('/<project_id>/pages/<page_id>/description', methods=['PUT'])
def update_page_description(project_id, page_id):
"""
PUT /api/projects/{project_id}/pages/{page_id}/description - Edit description
Request body:
{
"description_content": {
"title": "...",
"text_content": ["...", "..."],
"extra_fields": {"排版布局": "..."}
}
}
"""
try:
page = Page.query.get(page_id)
if not page or page.project_id != project_id:
return not_found('Page')
data = request.get_json()
if not data or 'description_content' not in data:
return bad_request("description_content is required")
page.set_description_content(data['description_content'])
page.updated_at = datetime.utcnow()
# Update project
project = Project.query.get(project_id)
if project:
project.updated_at = datetime.utcnow()
db.session.commit()
return success_response(page.to_dict())
except Exception as e:
db.session.rollback()
return error_response('SERVER_ERROR', str(e), 500)
@page_bp.route('/<project_id>/pages/<page_id>/generate/description', methods=['POST'])
def generate_page_description(project_id, page_id):
"""
POST /api/projects/{project_id}/pages/{page_id}/generate/description - Generate single page description
Request body:
{
"force_regenerate": false
}
"""
try:
page = Page.query.get(page_id)
if not page or page.project_id != project_id:
return not_found('Page')
project = Project.query.get(project_id)
if not project:
return not_found('Project')
data = request.get_json() or {}
force_regenerate = data.get('force_regenerate', False)
language = data.get('language', current_app.config.get('OUTPUT_LANGUAGE', 'zh'))
detail_level = data.get('detail_level', 'default')
# Check if already generated
if page.get_description_content() and not force_regenerate:
return bad_request("Description already exists. Set force_regenerate=true to regenerate")
# Get outline content
outline_content = page.get_outline_content()
if not outline_content:
return bad_request("Page must have outline content first")
# Reconstruct full outline
all_pages = Page.query.filter_by(project_id=project_id).order_by(Page.order_index).all()
outline = []
for p in all_pages:
oc = p.get_outline_content()
if oc:
page_data = oc.copy()
if p.part:
page_data['part'] = p.part
outline.append(page_data)
# Initialize AI service
ai_service = get_ai_service()
# Get reference files content and create project context
from controllers.project_controller import _get_project_reference_files_content
reference_files_content = _get_project_reference_files_content(project_id)
project_context = ProjectContext(project, reference_files_content)
# Generate description
page_data = outline_content.copy()
if page.part:
page_data['part'] = page.part
desc_result = ai_service.generate_page_description(
project_context,
outline,
page_data,
page.order_index + 1,
language=language,
detail_level=detail_level
)
# Save description (generate_page_description returns dict with text + optional extra_fields)
desc_content = {
"text": desc_result['text'],
"generated_at": datetime.utcnow().isoformat()
}
if desc_result.get('extra_fields'):
desc_content['extra_fields'] = desc_result['extra_fields']
page.set_description_content(desc_content)
page.status = 'DESCRIPTION_GENERATED'
page.updated_at = datetime.utcnow()
db.session.commit()
return success_response(page.to_dict())
except Exception as e:
db.session.rollback()
return error_response('AI_SERVICE_ERROR', str(e), 503)
@page_bp.route('/<project_id>/pages/<page_id>/generate/image', methods=['POST'])
def generate_page_image(project_id, page_id):
"""
POST /api/projects/{project_id}/pages/{page_id}/generate/image - Generate single page image
Request body:
{
"use_template": true,
"force_regenerate": false
}
"""
try:
page = Page.query.get(page_id)
if not page or page.project_id != project_id:
return not_found('Page')
project = Project.query.get(project_id)
if not project:
return not_found('Project')
data = request.get_json() or {}
use_template = data.get('use_template', True)
force_regenerate = data.get('force_regenerate', False)
language = data.get('language', current_app.config.get('OUTPUT_LANGUAGE', 'zh'))
# Check if already generated
if page.generated_image_path and not force_regenerate:
return bad_request("Image already exists. Set force_regenerate=true to regenerate")
# Get description content
desc_content = page.get_description_content()
if not desc_content:
return bad_request("Page must have description content first")
# Reconstruct full outline with part structure
all_pages = Page.query.filter_by(project_id=project_id).order_by(Page.order_index).all()
outline = []
current_part = None
current_part_pages = []
for p in all_pages:
oc = p.get_outline_content()
if not oc:
continue
page_data = oc.copy()
# 如果当前页面属于一个 part
if p.part:
# 如果这是新的 part,先保存之前的 part(如果有)
if current_part and current_part != p.part:
outline.append({
"part": current_part,
"pages": current_part_pages
})
current_part_pages = []
current_part = p.part
# 移除 part 字段,因为它在顶层
if 'part' in page_data:
del page_data['part']
current_part_pages.append(page_data)
else:
# 如果当前页面不属于任何 part,先保存之前的 part(如果有)
if current_part:
outline.append({
"part": current_part,
"pages": current_part_pages
})
current_part = None
current_part_pages = []
# 直接添加页面
outline.append(page_data)
# 保存最后一个 part(如果有)
if current_part:
outline.append({
"part": current_part,
"pages": current_part_pages
})
# Initialize services
ai_service = get_ai_service()
file_service = FileService(current_app.config['UPLOAD_FOLDER'])
# Get template path
ref_image_path = None
if use_template:
ref_image_path = file_service.get_template_path(project_id)
# 检查是否有模板图片或风格描述
# 如果都没有,则返回错误
if not ref_image_path and not project.template_style:
return bad_request("No template image or style description found for project")
# Generate prompt
page_data = page.get_outline_content() or {}
if page.part:
page_data['part'] = page.part
# 获取描述文本(可能是 text 字段或 text_content 数组)
desc_text = desc_content.get('text', '')
if not desc_text and desc_content.get('text_content'):
# 如果 text 字段不存在,尝试从 text_content 数组获取
text_content = desc_content.get('text_content', [])
if isinstance(text_content, list):
desc_text = '\n'.join(text_content)
else:
desc_text = str(text_content)
# 从当前页面的描述内容中提取图片 URL(在生成 prompt 之前提取,以便告知 AI)
additional_ref_images = []
has_material_images = False
# 从描述文本中提取图片
if desc_text:
image_urls = ai_service.extract_image_urls_from_markdown(desc_text)
if image_urls:
logger.info(f"Found {len(image_urls)} image(s) in page {page_id} description")
additional_ref_images = image_urls
has_material_images = True
# 合并额外要求和风格描述
combined_requirements = project.extra_requirements or ""
if project.template_style:
style_requirement = f"\n\nppt页面风格描述:\n\n{project.template_style}"
combined_requirements = combined_requirements + style_requirement
# Create async task for image generation
task = Task(
project_id=project_id,
task_type='GENERATE_PAGE_IMAGE',
status='PENDING'
)
task.set_progress({
'total': 1,
'completed': 0,
'failed': 0
})
db.session.add(task)
db.session.commit()
# Get app instance for background task
app = current_app._get_current_object()
# Submit background task
task_manager.submit_task(
task.id,
generate_single_page_image_task,
project_id,
page_id,
ai_service,
file_service,
outline,
use_template,
project.image_aspect_ratio,
current_app.config['DEFAULT_RESOLUTION'],
app,
combined_requirements if combined_requirements.strip() else None,
language
)
# Return task_id immediately
return success_response({
'task_id': task.id,
'page_id': page_id,
'status': 'PENDING'
}, status_code=202)
except Exception as e:
db.session.rollback()
return error_response('AI_SERVICE_ERROR', str(e), 503)
@page_bp.route('/<project_id>/pages/<page_id>/edit/image', methods=['POST'])
def edit_page_image(project_id, page_id):
"""
POST /api/projects/{project_id}/pages/{page_id}/edit/image - Edit page image
Request body (JSON or multipart/form-data):
{
"edit_instruction": "更改文本框样式为虚线",
"context_images": {
"use_template": true, // 是否使用template图片
"desc_image_urls": ["url1", "url2"], // desc中的图片URL列表
"uploaded_image_ids": ["file1", "file2"] // 上传的图片文件ID列表(在multipart中)
}
}
For multipart/form-data:
- edit_instruction: text field
- use_template: text field (true/false)
- desc_image_urls: JSON array string
- context_images: file uploads (multiple files with key "context_images")
"""
try:
page = Page.query.get(page_id)
if not page or page.project_id != project_id:
return not_found('Page')
if not page.generated_image_path:
return bad_request("Page must have generated image first")
project = Project.query.get(project_id)
if not project:
return not_found('Project')
# Initialize services
ai_service = get_ai_service()
file_service = FileService(current_app.config['UPLOAD_FOLDER'])
# Parse request data (support both JSON and multipart/form-data)
if request.is_json:
data = request.get_json()
uploaded_files = []
else:
# multipart/form-data
data = request.form.to_dict()
# Get uploaded files
uploaded_files = request.files.getlist('context_images')
# Parse JSON fields
if 'desc_image_urls' in data and data['desc_image_urls']:
try:
data['desc_image_urls'] = json.loads(data['desc_image_urls'])
except Exception:
data['desc_image_urls'] = []
else:
data['desc_image_urls'] = []
if not data or 'edit_instruction' not in data:
return bad_request("edit_instruction is required")
# Get current image path
current_image_path = file_service.get_absolute_path(page.generated_image_path)
# Get original description if available
original_description = None
desc_content = page.get_description_content()
if desc_content:
# Extract text from description_content
original_description = desc_content.get('text') or ''
# If text is not available, try to construct from text_content
if not original_description and desc_content.get('text_content'):
if isinstance(desc_content['text_content'], list):
original_description = '\n'.join(desc_content['text_content'])
else:
original_description = str(desc_content['text_content'])
# Collect additional reference images
additional_ref_images = []
# 1. Add template image if requested
context_images = data.get('context_images', {})
if isinstance(context_images, dict):
use_template = context_images.get('use_template', False)
else:
use_template = data.get('use_template', 'false').lower() == 'true'
if use_template:
template_path = file_service.get_template_path(project_id)
if template_path:
additional_ref_images.append(template_path)
# 2. Add desc image URLs if provided
if isinstance(context_images, dict):
desc_image_urls = context_images.get('desc_image_urls', [])
else:
desc_image_urls = data.get('desc_image_urls', [])
if desc_image_urls:
if isinstance(desc_image_urls, str):
try:
desc_image_urls = json.loads(desc_image_urls)
except Exception:
desc_image_urls = []
if isinstance(desc_image_urls, list):
additional_ref_images.extend(desc_image_urls)
# 3. Save and add uploaded files to a persistent location
temp_dir = None
if uploaded_files:
# Create a temporary directory in the project's upload folder
imp
gitextract_oydoioei/
├── .dockerignore
├── .githooks/
│ └── pre-commit.disabled
├── .github/
│ ├── CI_SETUP.md
│ ├── ISSUE_TEMPLATE/
│ │ └── bug_report.yml
│ ├── PULL_REQUEST_TEMPLATE.md
│ └── workflows/
│ ├── build-sha-image.yml
│ ├── ci-test.yml
│ ├── docker-publish.yml
│ ├── nightly.yml
│ ├── pr-quick-check.yml
│ └── translate-readme.yml
├── .gitignore
├── CLA.md
├── CONTRIBUTING.md
├── Dockerfile.allinone
├── LICENSE
├── README.md
├── backend/
│ ├── .gitignore
│ ├── Dockerfile
│ ├── README.md
│ ├── alembic.ini
│ ├── app.py
│ ├── config.py
│ ├── controllers/
│ │ ├── __init__.py
│ │ ├── export_controller.py
│ │ ├── file_controller.py
│ │ ├── material_controller.py
│ │ ├── page_controller.py
│ │ ├── project_controller.py
│ │ ├── reference_file_controller.py
│ │ ├── settings_controller.py
│ │ └── template_controller.py
│ ├── migrations/
│ │ ├── env.py
│ │ ├── script.py.mako
│ │ └── versions/
│ │ ├── 001_baseline_schema.py
│ │ ├── 002_create_settings_table.py
│ │ ├── 003_add_model_and_mineru_settings.py
│ │ ├── 004_add_template_style_to_projects.py
│ │ ├── 005_add_pdf_image_path.py
│ │ ├── 006_add_export_settings_to_projects.py
│ │ ├── 007_add_enable_reasoning_to_settings.py
│ │ ├── 008_add_baidu_ocr_api_key_to_settings.py
│ │ ├── 009_split_reasoning_config.py
│ │ ├── 010_add_cached_image_path.py
│ │ ├── 011_add_user_template_thumb.py
│ │ ├── 012_add_export_allow_partial_to_projects.py
│ │ ├── 013_add_lazyllm_source_fields.py
│ │ ├── 014_add_per_model_provider_config.py
│ │ ├── 015_rename_baidu_ocr_api_key.py
│ │ ├── 38292967f3ca_add_output_language_to_settings_table.py
│ │ ├── 64ecc9f34de0_add_description_generation_mode_to_.py
│ │ ├── 7acf21d5e41d_make_settings_columns_nullable_for_env_.py
│ │ ├── 88054bda1ece_add_outline_and_description_.py
│ │ ├── 9439faddcdd5_add_description_extra_fields_to_settings.py
│ │ ├── 9ad736fec43d_add_image_prompt_extra_fields_to_.py
│ │ ├── a912a64b7a86_add_mineru_token_to_settings_table.py
│ │ └── ee22f1512027_add_image_aspect_ratio_to_project.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── material.py
│ │ ├── page.py
│ │ ├── page_image_version.py
│ │ ├── project.py
│ │ ├── reference_file.py
│ │ ├── settings.py
│ │ ├── task.py
│ │ └── user_template.py
│ ├── run.bat
│ ├── run.sh
│ ├── server.log
│ ├── server_running.log
│ ├── services/
│ │ ├── __init__.py
│ │ ├── ai_providers/
│ │ │ ├── __init__.py
│ │ │ ├── genai_client.py
│ │ │ ├── image/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── baidu_inpainting_provider.py
│ │ │ │ ├── base.py
│ │ │ │ ├── gemini_inpainting_provider.py
│ │ │ │ ├── genai_provider.py
│ │ │ │ ├── lazyllm_provider.py
│ │ │ │ ├── openai_provider.py
│ │ │ │ └── volcengine_inpainting_provider.py
│ │ │ ├── lazyllm_env.py
│ │ │ ├── ocr/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── baidu_accurate_ocr_provider.py
│ │ │ │ └── baidu_table_ocr_provider.py
│ │ │ └── text/
│ │ │ ├── __init__.py
│ │ │ ├── base.py
│ │ │ ├── genai_provider.py
│ │ │ ├── lazyllm_provider.py
│ │ │ └── openai_provider.py
│ │ ├── ai_service.py
│ │ ├── ai_service_manager.py
│ │ ├── export_service.py
│ │ ├── file_parser_service.py
│ │ ├── file_service.py
│ │ ├── image_editability/
│ │ │ ├── __init__.py
│ │ │ ├── coordinate_mapper.py
│ │ │ ├── data_models.py
│ │ │ ├── extractors.py
│ │ │ ├── factories.py
│ │ │ ├── helpers.py
│ │ │ ├── hybrid_extractor.py
│ │ │ ├── inpaint_providers.py
│ │ │ ├── service.py
│ │ │ └── text_attribute_extractors.py
│ │ ├── inpainting_service.py
│ │ ├── pdf_service.py
│ │ ├── prompts.py
│ │ └── task_manager.py
│ ├── tests/
│ │ ├── conftest.py
│ │ ├── integration/
│ │ │ ├── README.md
│ │ │ ├── __init__.py
│ │ │ ├── test_api_full_flow.py
│ │ │ └── test_full_workflow.py
│ │ ├── pytest.ini
│ │ └── unit/
│ │ ├── __init__.py
│ │ ├── test_ai_mock.py
│ │ ├── test_api_health.py
│ │ ├── test_api_material.py
│ │ ├── test_api_project.py
│ │ ├── test_api_settings_provider.py
│ │ ├── test_editable_pptx_style_extraction.py
│ │ ├── test_file_parser_service.py
│ │ ├── test_image_prompt_ratio.py
│ │ ├── test_lazyllm_image_content_type.py
│ │ └── test_smart_merge.py
│ └── utils/
│ ├── __init__.py
│ ├── image_utils.py
│ ├── latex_utils.py
│ ├── mask_utils.py
│ ├── page_utils.py
│ ├── path_utils.py
│ ├── pptx_builder.py
│ ├── response.py
│ └── validators.py
├── create-test-data.mjs
├── create-test-data.sh
├── docker/
│ ├── nginx-allinone.conf
│ ├── start-backend.sh
│ └── supervisord.conf
├── docker-compose.allinone.yml
├── docker-compose.prod.yml
├── docker-compose.yml
├── docs/
│ ├── configuration.mdx
│ ├── docs.json
│ ├── faq.mdx
│ ├── features/
│ │ ├── creation.mdx
│ │ ├── descriptions.mdx
│ │ ├── editing.mdx
│ │ ├── export.mdx
│ │ ├── images.mdx
│ │ ├── import-export.mdx
│ │ ├── materials.mdx
│ │ ├── outline.mdx
│ │ └── overview.mdx
│ ├── history.mdx
│ ├── index.mdx
│ ├── logo/
│ │ └── .gitkeep
│ ├── quickstart.mdx
│ └── zh/
│ ├── configuration.mdx
│ ├── faq.mdx
│ ├── features/
│ │ ├── creation.mdx
│ │ ├── descriptions.mdx
│ │ ├── editing.mdx
│ │ ├── export.mdx
│ │ ├── images.mdx
│ │ ├── import-export.mdx
│ │ ├── materials.mdx
│ │ ├── outline.mdx
│ │ └── overview.mdx
│ ├── history.mdx
│ ├── index.mdx
│ └── quickstart.mdx
├── frontend/
│ ├── .eslintrc.cjs
│ ├── .gitignore
│ ├── Dockerfile
│ ├── README.md
│ ├── e2e/
│ │ ├── README.md
│ │ ├── access-code.spec.ts
│ │ ├── aspect-ratio-lock-integration.spec.ts
│ │ ├── aspect-ratio-lock.spec.ts
│ │ ├── attachment-sort-filter.spec.ts
│ │ ├── badge-status-after-generation.spec.ts
│ │ ├── desc-regeneration-skeleton.spec.ts
│ │ ├── description-detail-level.spec.ts
│ │ ├── description-no-flicker.spec.ts
│ │ ├── editable-export-failure.spec.ts
│ │ ├── export-aspect-ratio.spec.ts
│ │ ├── export-images.spec.ts
│ │ ├── extract-style-caption.spec.ts
│ │ ├── failed-file-reselect.spec.ts
│ │ ├── file-preview-scrollbar.spec.ts
│ │ ├── generation-fail.spec.ts
│ │ ├── generation-requirements.spec.ts
│ │ ├── helpers/
│ │ │ └── seed-project.ts
│ │ ├── history-pagination.spec.ts
│ │ ├── image-prompt-ratio.spec.ts
│ │ ├── image-queued-status.spec.ts
│ │ ├── import-markdown.spec.ts
│ │ ├── lazyllm-global-vendor.spec.ts
│ │ ├── lazyllm-image-content-type.spec.ts
│ │ ├── markdown-card-style.spec.ts
│ │ ├── material-aspect-ratio.spec.ts
│ │ ├── outline-autosave-blur.spec.ts
│ │ ├── outline-null-crash.spec.ts
│ │ ├── parsing-preview-toast.spec.ts
│ │ ├── pdf-export-metadata.spec.ts
│ │ ├── per-model-startup-creds.spec.ts
│ │ ├── preset-capsules.spec.ts
│ │ ├── preview-text-style-template.spec.ts
│ │ ├── renovation-aspect-ratio.spec.ts
│ │ ├── settings-api-clarity.spec.ts
│ │ ├── settings-api-links.spec.ts
│ │ ├── settings-back-to-top.spec.ts
│ │ ├── settings-backfill.spec.ts
│ │ ├── settings-env-fallback.spec.ts
│ │ ├── settings-per-model-provider-integration.spec.ts
│ │ ├── settings-per-model-provider.spec.ts
│ │ ├── settings-read-only.spec.ts
│ │ ├── settings-reset-fallback.spec.ts
│ │ ├── settings-test-vendor-format.spec.ts
│ │ ├── smart-merge.spec.ts
│ │ ├── streaming-descriptions.spec.ts
│ │ ├── streaming-outline.spec.ts
│ │ ├── ui-full-flow-mocked.spec.ts
│ │ ├── ui-full-flow.spec.ts
│ │ ├── upload-folder-path.spec.ts
│ │ ├── ux-polish-i18n.spec.ts
│ │ └── visual-regression.spec.ts
│ ├── index.html
│ ├── nginx.conf
│ ├── package.json
│ ├── playwright.config.ts
│ ├── postcss.config.js
│ ├── src/
│ │ ├── App.tsx
│ │ ├── api/
│ │ │ ├── client.ts
│ │ │ └── endpoints.ts
│ │ ├── components/
│ │ │ ├── history/
│ │ │ │ └── ProjectCard.tsx
│ │ │ ├── outline/
│ │ │ │ └── OutlineCard.tsx
│ │ │ ├── preview/
│ │ │ │ ├── DescriptionCard.tsx
│ │ │ │ └── SlideCard.tsx
│ │ │ └── shared/
│ │ │ ├── AccessCodeGuard.tsx
│ │ │ ├── AiRefineInput.tsx
│ │ │ ├── Button.tsx
│ │ │ ├── Card.tsx
│ │ │ ├── ConfirmDialog.tsx
│ │ │ ├── ContextualStatusBadge.tsx
│ │ │ ├── ExportTasksPanel.tsx
│ │ │ ├── FilePreviewModal.tsx
│ │ │ ├── Footer.tsx
│ │ │ ├── GithubBadge.tsx
│ │ │ ├── GithubRepoCard.tsx
│ │ │ ├── HelpModal.tsx
│ │ │ ├── ImagePreviewList.tsx
│ │ │ ├── Input.tsx
│ │ │ ├── Loading.tsx
│ │ │ ├── Markdown.tsx
│ │ │ ├── MarkdownTextarea.tsx
│ │ │ ├── MaterialCenterModal.tsx
│ │ │ ├── MaterialGeneratorModal.tsx
│ │ │ ├── MaterialSelector.tsx
│ │ │ ├── Modal.tsx
│ │ │ ├── Pagination.tsx
│ │ │ ├── PresetCapsules.tsx
│ │ │ ├── ProjectResourcesList.tsx
│ │ │ ├── ProjectSettingsModal.tsx
│ │ │ ├── ReferenceFileCard.tsx
│ │ │ ├── ReferenceFileList.tsx
│ │ │ ├── ReferenceFileSelector.tsx
│ │ │ ├── ShimmerOverlay.tsx
│ │ │ ├── StatusBadge.tsx
│ │ │ ├── TemplateSelector.tsx
│ │ │ ├── TextStyleSelector.tsx
│ │ │ ├── Textarea.tsx
│ │ │ ├── Toast.tsx
│ │ │ └── index.ts
│ │ ├── config/
│ │ │ ├── aspectRatio.ts
│ │ │ ├── presetStyles.ts
│ │ │ └── presetStylesI18n.ts
│ │ ├── hooks/
│ │ │ ├── useGeneratingState.ts
│ │ │ ├── useImagePaste.ts
│ │ │ ├── usePageStatus.ts
│ │ │ ├── useT.ts
│ │ │ └── useTheme.ts
│ │ ├── i18n.ts
│ │ ├── index.css
│ │ ├── locales/
│ │ │ ├── en.json
│ │ │ └── zh.json
│ │ ├── main.tsx
│ │ ├── pages/
│ │ │ ├── DetailEditor.tsx
│ │ │ ├── History.tsx
│ │ │ ├── Home.tsx
│ │ │ ├── Landing.tsx
│ │ │ ├── OutlineEditor.tsx
│ │ │ ├── Settings.tsx
│ │ │ └── SlidePreview.tsx
│ │ ├── store/
│ │ │ ├── useExportTasksStore.ts
│ │ │ └── useProjectStore.ts
│ │ ├── tests/
│ │ │ ├── components/
│ │ │ │ ├── Button.test.tsx
│ │ │ │ ├── DescriptionCard.test.tsx
│ │ │ │ └── Markdown.test.tsx
│ │ │ ├── setup.ts
│ │ │ ├── store/
│ │ │ │ ├── useProjectStore.initializeProject.test.ts
│ │ │ │ └── useProjectStore.test.ts
│ │ │ └── utils.normalizeErrorMessage.test.ts
│ │ ├── types/
│ │ │ └── index.ts
│ │ ├── utils/
│ │ │ ├── i18nHelper.ts
│ │ │ ├── index.ts
│ │ │ ├── logger.ts
│ │ │ └── projectUtils.ts
│ │ └── vite-env.d.ts
│ ├── start.bat
│ ├── start.sh
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── package.json
├── pyproject.toml
├── scripts/
│ ├── export_editable_pptx.py
│ ├── pre-push-check.sh
│ ├── run-local-ci.sh
│ ├── setup-env-from-secrets.sh
│ ├── setup_git_hooks.sh
│ ├── test_docker_environment.sh
│ ├── translate_readme.py
│ ├── translate_readme_incremental.py
│ ├── verify-e2e-refactoring.sh
│ └── wait-for-health.sh
├── tests/
│ └── docker/
│ └── test_docker_environment.sh
└── v0_demo/
├── demo.py
├── gemini_genai.py
└── lazyllm_genai.py
SYMBOL INDEX (1058 symbols across 201 files)
FILE: backend/app.py
function set_sqlite_pragma (line 33) | def set_sqlite_pragma(dbapi_conn, connection_record):
function create_app (line 52) | def create_app():
function _load_settings_to_config (line 192) | def _load_settings_to_config(app):
function _compute_worktree_port (line 329) | def _compute_worktree_port(base_port: int) -> int:
FILE: backend/config.py
class Config (line 15) | class Config:
class DevelopmentConfig (line 114) | class DevelopmentConfig(Config):
class ProductionConfig (line 119) | class ProductionConfig(Config):
function get_config (line 131) | def get_config():
FILE: backend/controllers/export_controller.py
function export_pptx (line 27) | def export_pptx(project_id):
function export_pdf (line 104) | def export_pdf(project_id):
function export_images (line 178) | def export_images(project_id):
function export_editable_pptx (line 246) | def export_editable_pptx(project_id):
FILE: backend/controllers/file_controller.py
function serve_file (line 15) | def serve_file(project_id, file_type, filename):
function serve_user_template (line 52) | def serve_user_template(template_id, filename):
function serve_global_material (line 85) | def serve_global_material(filename):
function serve_mineru_file (line 117) | def serve_mineru_file(extract_id, filepath):
FILE: backend/controllers/material_controller.py
function _generate_image_caption (line 30) | def _generate_image_caption(filepath: str) -> str:
function _build_material_query (line 102) | def _build_material_query(filter_project_id: str):
function _get_materials_list (line 118) | def _get_materials_list(filter_project_id: str):
function _handle_material_upload (line 133) | def _handle_material_upload(default_project_id: Optional[str] = None):
function _resolve_target_project_id (line 166) | def _resolve_target_project_id(raw_project_id: Optional[str], allow_none...
function _save_material_file (line 185) | def _save_material_file(file, target_project_id: Optional[str]):
function generate_material_image (line 232) | def generate_material_image(project_id):
function list_materials (line 362) | def list_materials(project_id):
function upload_material (line 384) | def upload_material(project_id):
function list_all_materials (line 399) | def list_all_materials():
function upload_material_global (line 428) | def upload_material_global():
function delete_material (line 443) | def delete_material(material_id):
function associate_materials_to_project (line 474) | def associate_materials_to_project():
function download_materials_zip (line 526) | def download_materials_zip():
FILE: backend/controllers/page_controller.py
function create_page (line 24) | def create_page(project_id):
function delete_page (line 84) | def delete_page(project_id, page_id):
function update_page (line 116) | def update_page(project_id, page_id):
function update_page_outline (line 157) | def update_page_outline(project_id, page_id):
function update_page_description (line 195) | def update_page_description(project_id, page_id):
function generate_page_description (line 237) | def generate_page_description(project_id, page_id):
function generate_page_image (line 325) | def generate_page_image(project_id, page_id):
function edit_page_image (line 502) | def edit_page_image(project_id, page_id):
function get_page_image_versions (line 676) | def get_page_image_versions(project_id, page_id):
function set_current_image_version (line 698) | def set_current_image_version(project_id, page_id, version_id):
function regenerate_renovation_page (line 742) | def regenerate_renovation_page(project_id, page_id):
FILE: backend/controllers/project_controller.py
function _get_project_reference_files_content (line 38) | def _get_project_reference_files_content(project_id: str) -> list:
function _reconstruct_outline_from_pages (line 64) | def _reconstruct_outline_from_pages(pages: list) -> list:
function _smart_merge_pages (line 123) | def _smart_merge_pages(project_id, pages_data):
function list_projects (line 157) | def list_projects():
function create_project (line 197) | def create_project():
function get_project (line 267) | def get_project(project_id):
function update_project (line 289) | def update_project(project_id):
function delete_project (line 379) | def delete_project(project_id):
function generate_outline (line 407) | def generate_outline(project_id):
function generate_outline_stream (line 499) | def generate_outline_stream(project_id):
function _sse_event (line 612) | def _sse_event(event: str, data: dict) -> str:
function generate_from_description (line 618) | def generate_from_description(project_id):
function generate_descriptions (line 735) | def generate_descriptions(project_id):
function generate_descriptions_stream (line 828) | def generate_descriptions_stream(project_id):
function generate_images (line 970) | def generate_images(project_id):
function get_task_status (line 1090) | def get_task_status(project_id, task_id):
function refine_outline (line 1108) | def refine_outline(project_id):
function refine_descriptions (line 1205) | def refine_descriptions(project_id):
function create_ppt_renovation_project (line 1329) | def create_ppt_renovation_project():
function extract_style (line 1565) | def extract_style():
FILE: backend/controllers/reference_file_controller.py
function _allowed_file (line 25) | def _allowed_file(filename: str, allowed_extensions: set) -> bool:
function _get_file_type (line 31) | def _get_file_type(filename: str) -> str:
function _parse_file_async (line 38) | def _parse_file_async(file_id: str, file_path: str, filename: str, app):
function upload_reference_file (line 107) | def upload_reference_file():
function get_reference_file (line 215) | def get_reference_file(file_id):
function delete_reference_file (line 236) | def delete_reference_file(file_id):
function list_project_reference_files (line 272) | def list_project_reference_files(project_id):
function trigger_file_parse (line 310) | def trigger_file_parse(file_id):
function associate_file_to_project (line 366) | def associate_file_to_project(file_id):
function dissociate_file_from_project (line 409) | def dissociate_file_from_project(file_id):
FILE: backend/controllers/settings_controller.py
function temporary_settings_override (line 32) | def temporary_settings_override(settings_override: dict):
function get_settings (line 151) | def get_settings():
function update_settings (line 168) | def update_settings():
function reset_settings (line 362) | def reset_settings():
function get_active_config (line 421) | def get_active_config():
function verify_api_key (line 436) | def verify_api_key():
function _sync_settings_to_config (line 537) | def _sync_settings_to_config(settings: Settings):
function _get_test_image_path (line 691) | def _get_test_image_path() -> Path:
function _get_baidu_credentials (line 698) | def _get_baidu_credentials():
function _create_file_parser (line 706) | def _create_file_parser():
function _test_baidu_ocr (line 761) | def _test_baidu_ocr():
function _test_text_model (line 778) | def _test_text_model():
function _test_caption_model (line 785) | def _test_caption_model():
function _test_baidu_inpaint (line 817) | def _test_baidu_inpaint():
function _test_image_model (line 845) | def _test_image_model():
function _test_mineru_pdf (line 864) | def _test_mineru_pdf():
function _run_test_async (line 922) | def _run_test_async(task_id: str, test_name: str, test_settings: dict, a...
function run_settings_test (line 977) | def run_settings_test(test_name: str):
function get_test_status (line 1082) | def get_test_status(task_id: str):
FILE: backend/controllers/template_controller.py
function upload_template (line 18) | def upload_template(project_id):
function delete_template (line 64) | def delete_template(project_id):
function get_system_templates (line 95) | def get_system_templates():
function upload_user_template (line 112) | def upload_user_template():
function list_user_templates (line 179) | def list_user_templates():
function delete_user_template (line 195) | def delete_user_template(template_id):
FILE: backend/migrations/env.py
function get_url (line 26) | def get_url() -> str:
function run_migrations_offline (line 32) | def run_migrations_offline() -> None:
function run_migrations_online (line 46) | def run_migrations_online() -> None:
FILE: backend/migrations/versions/001_baseline_schema.py
function upgrade (line 20) | def upgrade() -> None:
function downgrade (line 127) | def downgrade() -> None:
FILE: backend/migrations/versions/002_create_settings_table.py
function upgrade (line 20) | def upgrade() -> None:
function downgrade (line 48) | def downgrade() -> None:
FILE: backend/migrations/versions/003_add_model_and_mineru_settings.py
function _column_exists (line 20) | def _column_exists(table_name: str, column_name: str) -> bool:
function upgrade (line 28) | def upgrade() -> None:
function downgrade (line 51) | def downgrade() -> None:
FILE: backend/migrations/versions/004_add_template_style_to_projects.py
function upgrade (line 19) | def upgrade() -> None:
function downgrade (line 28) | def downgrade() -> None:
FILE: backend/migrations/versions/005_add_pdf_image_path.py
function upgrade (line 22) | def upgrade() -> None:
function downgrade (line 29) | def downgrade() -> None:
FILE: backend/migrations/versions/006_add_export_settings_to_projects.py
function upgrade (line 19) | def upgrade() -> None:
function downgrade (line 32) | def downgrade() -> None:
FILE: backend/migrations/versions/007_add_enable_reasoning_to_settings.py
function _column_exists (line 20) | def _column_exists(table_name: str, column_name: str) -> bool:
function upgrade (line 28) | def upgrade() -> None:
function downgrade (line 42) | def downgrade() -> None:
FILE: backend/migrations/versions/008_add_baidu_ocr_api_key_to_settings.py
function _column_exists (line 20) | def _column_exists(table_name: str, column_name: str) -> bool:
function upgrade (line 28) | def upgrade() -> None:
function downgrade (line 41) | def downgrade() -> None:
FILE: backend/migrations/versions/009_split_reasoning_config.py
function _column_exists (line 20) | def _column_exists(table_name: str, column_name: str) -> bool:
function upgrade (line 28) | def upgrade() -> None:
function downgrade (line 64) | def downgrade() -> None:
FILE: backend/migrations/versions/010_add_cached_image_path.py
function upgrade (line 19) | def upgrade() -> None:
function downgrade (line 23) | def downgrade() -> None:
FILE: backend/migrations/versions/011_add_user_template_thumb.py
function generate_user_template_thumbnails (line 18) | def generate_user_template_thumbnails():
function generate_page_thumbnails (line 104) | def generate_page_thumbnails():
function upgrade (line 193) | def upgrade():
function downgrade (line 204) | def downgrade():
FILE: backend/migrations/versions/012_add_export_allow_partial_to_projects.py
function upgrade (line 19) | def upgrade():
function downgrade (line 26) | def downgrade():
FILE: backend/migrations/versions/013_add_lazyllm_source_fields.py
function upgrade (line 19) | def upgrade():
function downgrade (line 26) | def downgrade():
FILE: backend/migrations/versions/014_add_per_model_provider_config.py
function upgrade (line 19) | def upgrade():
function downgrade (line 28) | def downgrade():
FILE: backend/migrations/versions/015_rename_baidu_ocr_api_key.py
function upgrade (line 18) | def upgrade():
function downgrade (line 23) | def downgrade():
FILE: backend/migrations/versions/38292967f3ca_add_output_language_to_settings_table.py
function _column_exists (line 20) | def _column_exists(table_name: str, column_name: str) -> bool:
function upgrade (line 28) | def upgrade() -> None:
function downgrade (line 40) | def downgrade() -> None:
FILE: backend/migrations/versions/64ecc9f34de0_add_description_generation_mode_to_.py
function upgrade (line 19) | def upgrade() -> None:
function downgrade (line 25) | def downgrade() -> None:
FILE: backend/migrations/versions/7acf21d5e41d_make_settings_columns_nullable_for_env_.py
function upgrade (line 19) | def upgrade() -> None:
function downgrade (line 35) | def downgrade() -> None:
FILE: backend/migrations/versions/88054bda1ece_add_outline_and_description_.py
function upgrade (line 19) | def upgrade() -> None:
function downgrade (line 26) | def downgrade() -> None:
FILE: backend/migrations/versions/9439faddcdd5_add_description_extra_fields_to_settings.py
function upgrade (line 19) | def upgrade() -> None:
function downgrade (line 25) | def downgrade() -> None:
FILE: backend/migrations/versions/9ad736fec43d_add_image_prompt_extra_fields_to_.py
function upgrade (line 19) | def upgrade() -> None:
function downgrade (line 25) | def downgrade() -> None:
FILE: backend/migrations/versions/a912a64b7a86_add_mineru_token_to_settings_table.py
function _column_exists (line 20) | def _column_exists(table_name: str, column_name: str) -> bool:
function upgrade (line 28) | def upgrade() -> None:
function downgrade (line 40) | def downgrade() -> None:
FILE: backend/migrations/versions/ee22f1512027_add_image_aspect_ratio_to_project.py
function upgrade (line 19) | def upgrade() -> None:
function downgrade (line 23) | def downgrade() -> None:
FILE: backend/models/material.py
class Material (line 9) | class Material(db.Model):
method to_dict (line 26) | def to_dict(self):
method __repr__ (line 38) | def __repr__(self):
FILE: backend/models/page.py
class Page (line 11) | class Page(db.Model):
method get_outline_content (line 35) | def get_outline_content(self):
method set_outline_content (line 44) | def set_outline_content(self, data):
method get_description_content (line 51) | def get_description_content(self):
method set_description_content (line 60) | def set_description_content(self, data):
method to_dict (line 67) | def to_dict(self, include_versions=False):
method __repr__ (line 93) | def __repr__(self):
FILE: backend/models/page_image_version.py
class PageImageVersion (line 9) | class PageImageVersion(db.Model):
method to_dict (line 25) | def to_dict(self):
method __repr__ (line 44) | def __repr__(self):
FILE: backend/models/project.py
class Project (line 9) | class Project(db.Model):
method to_dict (line 43) | def to_dict(self, include_pages=False):
method __repr__ (line 80) | def __repr__(self):
FILE: backend/models/reference_file.py
class ReferenceFile (line 9) | class ReferenceFile(db.Model):
method to_dict (line 31) | def to_dict(self, include_content=True, include_failed_count=False):
method count_failed_image_captions (line 60) | def count_failed_image_captions(self) -> int:
method __repr__ (line 79) | def __repr__(self):
FILE: backend/models/settings.py
class Settings (line 7) | class Settings(db.Model):
method _val (line 63) | def _val(self, attr, defaults):
method get_description_extra_fields (line 71) | def get_description_extra_fields(self):
method get_image_prompt_extra_fields (line 82) | def get_image_prompt_extra_fields(self):
method to_dict (line 93) | def to_dict(self):
method _get_lazyllm_api_keys_info (line 139) | def _get_lazyllm_api_keys_info(self, raw=None):
method get_lazyllm_api_keys_dict (line 150) | def get_lazyllm_api_keys_dict(self):
method _get_config_defaults (line 160) | def _get_config_defaults():
method get_settings (line 198) | def get_settings():
method __repr__ (line 215) | def __repr__(self):
FILE: backend/models/task.py
class Task (line 10) | class Task(db.Model):
method get_progress (line 28) | def get_progress(self):
method set_progress (line 37) | def set_progress(self, data):
method update_progress (line 44) | def update_progress(self, completed=None, failed=None):
method to_dict (line 53) | def to_dict(self):
method __repr__ (line 65) | def __repr__(self):
FILE: backend/models/user_template.py
class UserTemplate (line 9) | class UserTemplate(db.Model):
method to_dict (line 23) | def to_dict(self):
method __repr__ (line 40) | def __repr__(self):
FILE: backend/services/ai_providers/__init__.py
function get_provider_format (line 38) | def get_provider_format() -> str:
function _resolve_setting (line 66) | def _resolve_setting(key: str, fallback: Optional[str] = None) -> Option...
function _build_provider_config (line 97) | def _build_provider_config() -> Dict[str, Any]:
function _get_model_type_provider_config (line 155) | def _get_model_type_provider_config(model_type: str) -> Dict[str, Any]:
function get_image_caption_provider_config (line 213) | def get_image_caption_provider_config() -> Dict[str, Any]:
function get_caption_provider (line 218) | def get_caption_provider(model: str = "gemini-3-flash-preview") -> TextP...
function get_text_provider (line 241) | def get_text_provider(model: str = "gemini-3-flash-preview") -> TextProv...
function get_image_provider (line 268) | def get_image_provider(model: str = "gemini-3-pro-image-preview") -> Ima...
FILE: backend/services/ai_providers/genai_client.py
function make_genai_client (line 11) | def make_genai_client(
FILE: backend/services/ai_providers/image/baidu_inpainting_provider.py
class BaiduInpaintingProvider (line 19) | class BaiduInpaintingProvider:
method __init__ (line 31) | def __init__(self, api_key: str):
method inpaint (line 52) | def inpaint(
method inpaint_bboxes (line 187) | def inpaint_bboxes(
function create_baidu_inpainting_provider (line 224) | def create_baidu_inpainting_provider(
FILE: backend/services/ai_providers/image/base.py
class ImageProvider (line 9) | class ImageProvider(ABC):
method generate_image (line 13) | def generate_image(
FILE: backend/services/ai_providers/image/gemini_inpainting_provider.py
class GeminiInpaintingProvider (line 16) | class GeminiInpaintingProvider:
method __init__ (line 33) | def __init__(
method create_marked_image (line 62) | def create_marked_image(original_image: Image.Image, mask_image: Image...
method inpaint_image (line 111) | def inpaint_image(
FILE: backend/services/ai_providers/image/genai_provider.py
class GenAIImageProvider (line 22) | class GenAIImageProvider(ImageProvider):
method __init__ (line 25) | def __init__(
method generate_image (line 48) | def generate_image(
FILE: backend/services/ai_providers/image/lazyllm_provider.py
function _calculate_image_dimensions (line 52) | def _calculate_image_dimensions(
class LazyLLMImageProvider (line 134) | class LazyLLMImageProvider(ImageProvider):
method __init__ (line 136) | def __init__(self, source: str = 'doubao', model: str = 'doubao-seedre...
method generate_image (line 161) | def generate_image(self, prompt: str = None,
FILE: backend/services/ai_providers/image/openai_provider.py
class OpenAIImageProvider (line 26) | class OpenAIImageProvider(ImageProvider):
method __init__ (line 38) | def __init__(self, api_key: str, api_base: str = None, model: str = "g...
method _encode_image_to_base64 (line 56) | def _encode_image_to_base64(self, image: Image.Image) -> str:
method _build_extra_body (line 73) | def _build_extra_body(self, aspect_ratio: str, resolution: str) -> dict:
method generate_image (line 108) | def generate_image(
FILE: backend/services/ai_providers/image/volcengine_inpainting_provider.py
class VolcengineInpaintingProvider (line 18) | class VolcengineInpaintingProvider:
method __init__ (line 25) | def __init__(self, access_key: str, secret_key: str, timeout: int = 60):
method _encode_image_to_base64 (line 39) | def _encode_image_to_base64(self, image: Image.Image, is_mask: bool = ...
method inpaint_image (line 76) | def inpaint_image(
FILE: backend/services/ai_providers/lazyllm_env.py
function collect_env_lazyllm_api_keys (line 11) | def collect_env_lazyllm_api_keys() -> str | None:
function get_lazyllm_api_key (line 21) | def get_lazyllm_api_key(source: str, namespace: str = "BANANA") -> str:
function ensure_lazyllm_namespace_key (line 33) | def ensure_lazyllm_namespace_key(source: str, namespace: str = "BANANA")...
FILE: backend/services/ai_providers/ocr/baidu_accurate_ocr_provider.py
class BaiduAccurateOCRProvider (line 49) | class BaiduAccurateOCRProvider:
method __init__ (line 61) | def __init__(self, api_key: str):
method recognize (line 82) | def recognize(
method _location_to_bbox (line 290) | def _location_to_bbox(self, location: Dict[str, int]) -> List[int]:
method get_full_text (line 310) | def get_full_text(self, result: Dict[str, Any], separator: str = '\n')...
method get_text_with_positions (line 324) | def get_text_with_positions(self, result: Dict[str, Any]) -> List[Dict...
function create_baidu_accurate_ocr_provider (line 344) | def create_baidu_accurate_ocr_provider(
FILE: backend/services/ai_providers/ocr/baidu_table_ocr_provider.py
class BaiduTableOCRProvider (line 19) | class BaiduTableOCRProvider:
method __init__ (line 22) | def __init__(self, api_key: str):
method recognize_table (line 43) | def recognize_table(
method _location_to_bbox (line 204) | def _location_to_bbox(self, location: List[Dict[str, int]]) -> List[int]:
method get_table_structure (line 222) | def get_table_structure(self, cells: List[Dict[str, Any]]) -> Dict[str...
function create_baidu_table_ocr_provider (line 254) | def create_baidu_table_ocr_provider(
FILE: backend/services/ai_providers/text/base.py
function strip_think_tags (line 9) | def strip_think_tags(text: str) -> str:
class TextProvider (line 16) | class TextProvider(ABC):
method generate_text (line 20) | def generate_text(self, prompt: str, thinking_budget: int = 1000) -> str:
method generate_text_stream (line 33) | def generate_text_stream(self, prompt: str, thinking_budget: int = 0) ...
FILE: backend/services/ai_providers/text/genai_provider.py
function _log_retry (line 20) | def _log_retry(retry_state):
function _validate_response (line 28) | def _validate_response(response):
class GenAITextProvider (line 41) | class GenAITextProvider(TextProvider):
method __init__ (line 44) | def __init__(
method generate_text (line 68) | def generate_text(self, prompt: str, thinking_budget: int = 0) -> str:
method generate_with_image (line 97) | def generate_with_image(self, prompt: str, image_path: str, thinking_b...
method generate_text_stream (line 129) | def generate_text_stream(self, prompt: str, thinking_budget: int = 0) ...
FILE: backend/services/ai_providers/text/lazyllm_provider.py
class LazyLLMTextProvider (line 16) | class LazyLLMTextProvider(TextProvider):
method __init__ (line 18) | def __init__(self, source: str = 'deepseek', model: str = "deepseek-v3...
method generate_text (line 46) | def generate_text(self, prompt, thinking_budget = 1000):
method generate_with_image (line 50) | def generate_with_image(self, prompt: str, image_path: str, thinking_b...
FILE: backend/services/ai_providers/text/openai_provider.py
class OpenAITextProvider (line 14) | class OpenAITextProvider(TextProvider):
method __init__ (line 17) | def __init__(self, api_key: str, api_base: str = None, model: str = "g...
method generate_text (line 34) | def generate_text(self, prompt: str, thinking_budget: int = 0) -> str:
method generate_text_stream (line 53) | def generate_text_stream(self, prompt: str, thinking_budget: int = 0) ...
method generate_with_image (line 65) | def generate_with_image(self, prompt: str, image_path: str, thinking_b...
FILE: backend/services/ai_service.py
class ProjectContext (line 39) | class ProjectContext:
method __init__ (line 42) | def __init__(self, project_or_dict, reference_files_content: Optional[...
method to_dict (line 68) | def to_dict(self) -> Dict:
class AIService (line 81) | class AIService:
method __init__ (line 84) | def __init__(self, text_provider: TextProvider = None, image_provider:...
method _get_text_thinking_budget (line 128) | def _get_text_thinking_budget(self) -> int:
method _get_image_thinking_budget (line 137) | def _get_image_thinking_budget(self) -> int:
method extract_image_urls_from_markdown (line 147) | def extract_image_urls_from_markdown(text: str) -> List[str]:
method remove_markdown_images (line 174) | def remove_markdown_images(text: str) -> str:
method generate_json (line 207) | def generate_json(self, prompt: str, thinking_budget: int = 1000) -> U...
method generate_json_with_image (line 239) | def generate_json_with_image(self, prompt: str, image_path: str, think...
method _convert_mineru_path_to_local (line 283) | def _convert_mineru_path_to_local(mineru_path: str) -> Optional[str]:
method download_image_from_url (line 299) | def download_image_from_url(url: str) -> Optional[Image.Image]:
method generate_outline (line 324) | def generate_outline(self, project_context: ProjectContext, language: ...
method parse_markdown_outline (line 340) | def parse_markdown_outline(markdown: str) -> List[Dict]:
method generate_outline_stream (line 382) | def generate_outline_stream(self, project_context: ProjectContext, lan...
method parse_outline_text (line 465) | def parse_outline_text(self, project_context: ProjectContext, language...
method flatten_outline (line 480) | def flatten_outline(self, outline: List[Dict]) -> List[Dict]:
method _parse_extra_fields (line 499) | def _parse_extra_fields(text: str, field_names: list) -> tuple:
method _get_extra_field_names (line 540) | def _get_extra_field_names() -> list:
method generate_page_description (line 550) | def generate_page_description(self, project_context: ProjectContext, o...
method generate_descriptions_stream (line 593) | def generate_descriptions_stream(self, project_context: ProjectContext,
method _build_extra_field_pattern (line 716) | def _build_extra_field_pattern(field_names: list):
method generate_outline_text (line 723) | def generate_outline_text(self, outline: List[Dict]) -> str:
method generate_image_prompt (line 737) | def generate_image_prompt(self, outline: List[Dict], page: Dict,
method generate_image (line 787) | def generate_image(self, prompt: str, ref_image_path: Optional[str] = ...
method edit_image (line 883) | def edit_image(self, prompt: str, current_image_path: str,
method parse_description_to_outline (line 909) | def parse_description_to_outline(self, project_context: ProjectContext...
method parse_description_to_page_descriptions (line 923) | def parse_description_to_page_descriptions(self, project_context: Proj...
method refine_outline (line 945) | def refine_outline(self, current_outline: List[Dict], user_requirement...
method refine_descriptions (line 971) | def refine_descriptions(self, current_descriptions: List[Dict], user_r...
method extract_page_content (line 1005) | def extract_page_content(self, markdown_text: str, language: str = 'zh...
method _generate_text_from_image (line 1029) | def _generate_text_from_image(self, prompt: str, image_path: str) -> str:
method generate_layout_caption (line 1051) | def generate_layout_caption(self, image_path: str) -> str:
method extract_style_description (line 1055) | def extract_style_description(self, image_path: str) -> str:
FILE: backend/services/ai_service_manager.py
function _get_cached_text_provider (line 41) | def _get_cached_text_provider(model: str) -> TextProvider:
function _get_cached_image_provider (line 60) | def _get_cached_image_provider(model: str) -> ImageProvider:
function _get_cached_caption_provider (line 79) | def _get_cached_caption_provider(model: str) -> TextProvider:
function get_ai_service (line 88) | def get_ai_service(force_new: bool = False) -> AIService:
function clear_ai_service_cache (line 149) | def clear_ai_service_cache():
function get_provider_cache_info (line 175) | def get_provider_cache_info() -> dict:
FILE: backend/services/export_service.py
class ExportError (line 27) | class ExportError(Exception):
method __init__ (line 34) | def __init__(self, message: str, error_type: str = 'unknown', details:...
method _get_default_help_text (line 48) | def _get_default_help_text(self, error_type: str) -> str:
method to_dict (line 60) | def to_dict(self) -> Dict[str, Any]:
class ExportWarnings (line 71) | class ExportWarnings:
method add_style_extraction_failed (line 92) | def add_style_extraction_failed(self, element_id: str, reason: str):
method add_text_render_failed (line 99) | def add_text_render_failed(self, text: str, reason: str):
method add_image_failed (line 106) | def add_image_failed(self, path: str, reason: str):
method add_json_parse_failed (line 113) | def add_json_parse_failed(self, context: str, reason: str):
method add_warning (line 120) | def add_warning(self, message: str):
method has_warnings (line 124) | def has_warnings(self) -> bool:
method to_summary (line 134) | def to_summary(self) -> List[str]:
method to_dict (line 158) | def to_dict(self) -> Dict[str, Any]:
function _get_page_size_inches (line 176) | def _get_page_size_inches(aspect_ratio: str = '16:9', base: float = 10.0...
class ExportService (line 191) | class ExportService:
method _build_style_extraction_error (line 200) | def _build_style_extraction_error(
method create_pptx_from_images (line 235) | def create_pptx_from_images(image_paths: List[str], output_file: str =...
method create_pdf_from_images (line 298) | def create_pdf_from_images(image_paths: List[str], output_file: str = ...
method _add_pdf_metadata (line 346) | def _add_pdf_metadata(pdf_bytes: bytes) -> bytes:
method create_pdf_from_images_pillow (line 391) | def create_pdf_from_images_pillow(image_paths: List[str], output_file:...
method _add_mineru_text_to_slide (line 450) | def _add_mineru_text_to_slide(builder, slide, text_item: Dict[str, Any...
method _add_table_cell_elements_to_slide (line 508) | def _add_table_cell_elements_to_slide(
method _add_mineru_image_to_slide (line 570) | def _add_mineru_image_to_slide(
method _collect_text_elements_for_extraction (line 687) | def _collect_text_elements_for_extraction(
method _batch_extract_text_styles (line 724) | def _batch_extract_text_styles(
method _collect_text_elements_for_batch_extraction (line 778) | def _collect_text_elements_for_batch_extraction(
method _batch_extract_text_styles_with_full_image (line 821) | def _batch_extract_text_styles_with_full_image(
method _batch_extract_text_styles_hybrid (line 919) | def _batch_extract_text_styles_hybrid(
method create_editable_pptx_with_recursive_analysis (line 1146) | def create_editable_pptx_with_recursive_analysis(
method _add_editable_elements_to_slide (line 1377) | def _add_editable_elements_to_slide(
FILE: backend/services/file_parser_service.py
function _get_ai_provider_format (line 23) | def _get_ai_provider_format(provider_format: str = None) -> str:
class FileParserService (line 53) | class FileParserService:
method __init__ (line 56) | def __init__(self, mineru_token: str, mineru_api_base: str = "https://...
method _get_gemini_client (line 99) | def _get_gemini_client(self):
method _get_openai_client (line 110) | def _get_openai_client(self):
method _get_lazyllm_client (line 120) | def _get_lazyllm_client(self):
method _can_generate_captions (line 135) | def _can_generate_captions(self) -> bool:
method parse_file (line 145) | def parse_file(self, file_path: str, filename: str) -> tuple[Optional[...
method _parse_text_file (line 219) | def _parse_text_file(self, file_path: str, filename: str) -> tuple[Opt...
method _parse_spreadsheet_file (line 277) | def _parse_spreadsheet_file(self, file_path: str, filename: str) -> tu...
method _get_upload_url (line 304) | def _get_upload_url(self, filename: str) -> tuple[Optional[str], Optio...
method _upload_file (line 340) | def _upload_file(self, file_path: str, upload_url: str) -> Optional[str]:
method _poll_result (line 362) | def _poll_result(self, batch_id: str, max_wait_time: int = 600) -> tup...
method _download_markdown (line 412) | def _download_markdown(self, zip_url: str) -> tuple[Optional[str], Opt...
method extract_header_footer_from_layout (line 488) | def extract_header_footer_from_layout(extract_id: str) -> str:
method _replace_image_paths (line 534) | def _replace_image_paths(self, markdown_content: str, markdown_file_pa...
method _enhance_markdown_with_captions (line 580) | def _enhance_markdown_with_captions(self, markdown_content: str) -> tu...
method _generate_captions_parallel (line 639) | def _generate_captions_parallel(self, image_urls: List[str], max_worke...
method _generate_single_caption (line 693) | def _generate_single_caption(self, image_url: str) -> str:
FILE: backend/services/file_service.py
function convert_image_to_rgb (line 14) | def convert_image_to_rgb(image: Image.Image) -> Image.Image:
function resize_image_for_thumbnail (line 47) | def resize_image_for_thumbnail(image: Image.Image, max_width: int = 1920...
class FileService (line 66) | class FileService:
method __init__ (line 69) | def __init__(self, upload_folder: str):
method _get_project_dir (line 74) | def _get_project_dir(self, project_id: str) -> Path:
method _get_template_dir (line 80) | def _get_template_dir(self, project_id: str) -> Path:
method _get_pages_dir (line 86) | def _get_pages_dir(self, project_id: str) -> Path:
method _get_exports_dir (line 92) | def _get_exports_dir(self, project_id: str) -> Path:
method _get_materials_dir (line 98) | def _get_materials_dir(self, project_id: str) -> Path:
method save_template_image (line 104) | def save_template_image(self, file, project_id: str) -> str:
method save_generated_image (line 128) | def save_generated_image(self, image: Image.Image, project_id: str,
method get_cached_image_path (line 167) | def get_cached_image_path(self, project_id: str, page_id: str, version...
method save_cached_image (line 185) | def save_cached_image(self, image: Image.Image, project_id: str,
method save_material_image (line 221) | def save_material_image(self, image: Image.Image, project_id: Optional...
method delete_page_image_version (line 257) | def delete_page_image_version(self, image_path: str) -> bool:
method get_file_url (line 282) | def get_file_url(self, project_id: Optional[str], file_type: str, file...
method get_absolute_path (line 299) | def get_absolute_path(self, relative_path: str) -> str:
method delete_template (line 314) | def delete_template(self, project_id: str) -> bool:
method delete_page_image (line 333) | def delete_page_image(self, project_id: str, page_id: str) -> bool:
method delete_project_files (line 354) | def delete_project_files(self, project_id: str) -> bool:
method file_exists (line 372) | def file_exists(self, relative_path: str) -> bool:
method get_template_path (line 377) | def get_template_path(self, project_id: str) -> Optional[str]:
method _get_user_templates_dir (line 412) | def _get_user_templates_dir(self) -> Path:
method save_user_template (line 418) | def save_user_template(self, file, template_id: str) -> str:
method delete_user_template (line 444) | def delete_user_template(self, template_id: str) -> bool:
method save_user_template_thumbnail (line 463) | def save_user_template_thumbnail(self, template_id: str, original_path...
FILE: backend/services/image_editability/coordinate_mapper.py
class CoordinateMapper (line 8) | class CoordinateMapper:
method local_to_global (line 12) | def local_to_global(
method global_to_local (line 43) | def global_to_local(
FILE: backend/services/image_editability/data_models.py
class BBox (line 9) | class BBox:
method width (line 17) | def width(self) -> float:
method height (line 21) | def height(self) -> float:
method area (line 25) | def area(self) -> float:
method to_tuple (line 28) | def to_tuple(self) -> Tuple[float, float, float, float]:
method to_dict (line 32) | def to_dict(self) -> Dict[str, float]:
method scale (line 41) | def scale(self, scale_x: float, scale_y: float) -> 'BBox':
method translate (line 50) | def translate(self, offset_x: float, offset_y: float) -> 'BBox':
class EditableElement (line 61) | class EditableElement:
method to_dict (line 79) | def to_dict(self) -> Dict[str, Any]:
class EditableImage (line 96) | class EditableImage:
method to_dict (line 118) | def to_dict(self) -> Dict[str, Any]:
FILE: backend/services/image_editability/extractors.py
class ExtractionContext (line 24) | class ExtractionContext:
method __init__ (line 27) | def __init__(
class ExtractionResult (line 41) | class ExtractionResult:
method __init__ (line 44) | def __init__(
method has_error (line 61) | def has_error(self) -> bool:
class ElementExtractor (line 66) | class ElementExtractor(ABC):
method extract (line 79) | def extract(
method supports_type (line 106) | def supports_type(self, element_type: Optional[str]) -> bool:
class MinerUElementExtractor (line 119) | class MinerUElementExtractor(ElementExtractor):
method __init__ (line 127) | def __init__(self, parser_service, upload_folder: Path):
method supports_type (line 138) | def supports_type(self, element_type: Optional[str]) -> bool:
method extract (line 142) | def extract(
method _find_cache (line 187) | def _find_cache(self, image_path: str) -> Optional[str]:
method _parse_image (line 208) | def _parse_image(self, image_path: str, depth: int) -> Tuple[Optional[...
method _extract_from_result (line 244) | def _extract_from_result(
class BaiduOCRElementExtractor (line 451) | class BaiduOCRElementExtractor(ElementExtractor):
method __init__ (line 459) | def __init__(self, baidu_table_ocr_provider):
method supports_type (line 468) | def supports_type(self, element_type: Optional[str]) -> bool:
method extract (line 472) | def extract(
method _shrink_cells_to_avoid_overlap (line 547) | def _shrink_cells_to_avoid_overlap(
class BaiduAccurateOCRElementExtractor (line 655) | class BaiduAccurateOCRElementExtractor(ElementExtractor):
method __init__ (line 663) | def __init__(self, baidu_accurate_ocr_provider):
method supports_type (line 672) | def supports_type(self, element_type: Optional[str]) -> bool:
method extract (line 676) | def extract(
class ExtractorRegistry (line 773) | class ExtractorRegistry:
method __init__ (line 797) | def __init__(self):
method register (line 802) | def register(self, element_type: str, extractor: ElementExtractor) -> ...
method register_types (line 817) | def register_types(self, element_types: List[str], extractor: ElementE...
method register_default (line 832) | def register_default(self, extractor: ElementExtractor) -> 'ExtractorR...
method get_extractor (line 846) | def get_extractor(self, element_type: Optional[str]) -> Optional[Eleme...
method get_all_extractors (line 866) | def get_all_extractors(self) -> List[ElementExtractor]:
method create_default (line 879) | def create_default(
FILE: backend/services/image_editability/factories.py
class ExtractorFactory (line 28) | class ExtractorFactory:
method create_default_extractors (line 32) | def create_default_extractors(
method create_extractor_registry (line 75) | def create_extractor_registry(
method create_baidu_accurate_ocr_extractor (line 134) | def create_baidu_accurate_ocr_extractor(
method create_hybrid_extractor (line 160) | def create_hybrid_extractor(
method create_hybrid_extractor_registry (line 212) | def create_hybrid_extractor_registry(
class InpaintProviderFactory (line 286) | class InpaintProviderFactory:
method create_default_provider (line 290) | def create_default_provider(inpainting_service: Optional[Any] = None) ...
method create_generative_edit_provider (line 308) | def create_generative_edit_provider(
method create_inpaint_registry (line 338) | def create_inpaint_registry(
method create_baidu_inpaint_provider (line 390) | def create_baidu_inpaint_provider() -> Optional[BaiduInpaintProvider]:
method create_hybrid_inpaint_provider (line 413) | def create_hybrid_inpaint_provider(
class ServiceConfig (line 457) | class ServiceConfig:
method __init__ (line 460) | def __init__(
method from_defaults (line 488) | def from_defaults(
class TextAttributeExtractorFactory (line 675) | class TextAttributeExtractorFactory:
method create_caption_model_extractor (line 679) | def create_caption_model_extractor(
method create_text_attribute_registry (line 707) | def create_text_attribute_registry(
FILE: backend/services/image_editability/helpers.py
function collect_bboxes_from_elements (line 16) | def collect_bboxes_from_elements(elements: List[EditableElement]) -> Lis...
function crop_element_from_image (line 34) | def crop_element_from_image(
function should_recurse_into_element (line 60) | def should_recurse_into_element(
FILE: backend/services/image_editability/hybrid_extractor.py
class BBoxUtils (line 27) | class BBoxUtils:
method is_contained (line 31) | def is_contained(inner_bbox: List[float], outer_bbox: List[float], thr...
method has_intersection (line 71) | def has_intersection(bbox1: List[float], bbox2: List[float], min_overl...
method get_intersection_ratio (line 115) | def get_intersection_ratio(bbox1: List[float], bbox2: List[float]) -> ...
class HybridElementExtractor (line 151) | class HybridElementExtractor(ElementExtractor):
method __init__ (line 170) | def __init__(
method supports_type (line 191) | def supports_type(self, element_type: Optional[str]) -> bool:
method extract (line 195) | def extract(
method _merge_results (line 306) | def _merge_results(
function create_hybrid_extractor (line 432) | def create_hybrid_extractor(
FILE: backend/services/image_editability/inpaint_providers.py
class InpaintProvider (line 24) | class InpaintProvider(ABC):
method inpaint_regions (line 36) | def inpaint_regions(
class DefaultInpaintProvider (line 58) | class DefaultInpaintProvider(InpaintProvider):
method __init__ (line 65) | def __init__(self, inpainting_service):
method inpaint_regions (line 74) | def inpaint_regions(
class GenerativeEditInpaintProvider (line 116) | class GenerativeEditInpaintProvider(InpaintProvider):
method __init__ (line 135) | def __init__(self, ai_service, aspect_ratio: str = "16:9", resolution:...
method inpaint_regions (line 148) | def inpaint_regions(
class BaiduInpaintProvider (line 211) | class BaiduInpaintProvider(InpaintProvider):
method __init__ (line 225) | def __init__(self, baidu_inpainting_provider):
method inpaint_regions (line 234) | def inpaint_regions(
class HybridInpaintProvider (line 273) | class HybridInpaintProvider(InpaintProvider):
method __init__ (line 290) | def __init__(
method inpaint_regions (line 308) | def inpaint_regions(
method _enhance_image_quality (line 370) | def _enhance_image_quality(
class InpaintProviderRegistry (line 456) | class InpaintProviderRegistry:
method __init__ (line 481) | def __init__(self):
method register (line 486) | def register(self, element_type: str, provider: InpaintProvider) -> 'I...
method register_types (line 501) | def register_types(self, element_types: List[str], provider: InpaintPr...
method register_default (line 516) | def register_default(self, provider: InpaintProvider) -> 'InpaintProvi...
method get_provider (line 530) | def get_provider(self, element_type: Optional[str]) -> Optional[Inpain...
method get_all_providers (line 550) | def get_all_providers(self) -> List[InpaintProvider]:
method create_default (line 563) | def create_default(
FILE: backend/services/image_editability/service.py
class ImageEditabilityService (line 25) | class ImageEditabilityService:
method __init__ (line 47) | def __init__(self, config: ServiceConfig):
method make_image_editable (line 71) | def make_image_editable(
method _extract_elements (line 186) | def _extract_elements(
method _select_extractor (line 205) | def _select_extractor(self, element_type: Optional[str]) -> ElementExt...
method _convert_to_editable_elements (line 212) | def _convert_to_editable_elements(
method _generate_clean_background (line 298) | def _generate_clean_background(
method _process_children (line 391) | def _process_children(
FILE: backend/services/image_editability/text_attribute_extractors.py
class ColoredSegment (line 21) | class ColoredSegment:
method to_dict (line 31) | def to_dict(self) -> Dict[str, Any]:
method from_dict (line 42) | def from_dict(cls, data: Dict[str, Any]) -> 'ColoredSegment':
class TextStyleResult (line 66) | class TextStyleResult:
method to_dict (line 101) | def to_dict(self) -> Dict[str, Any]:
method from_dict (line 111) | def from_dict(cls, data: Dict[str, Any]) -> 'TextStyleResult':
method get_hex_color (line 123) | def get_hex_color(self) -> str:
method get_full_text (line 128) | def get_full_text(self) -> str:
method has_multi_color (line 134) | def has_multi_color(self) -> bool:
class TextAttributeExtractor (line 142) | class TextAttributeExtractor(ABC):
method extract (line 152) | def extract(
method supports_batch (line 172) | def supports_batch(self) -> bool:
method extract_batch (line 181) | def extract_batch(
class CaptionModelTextAttributeExtractor (line 211) | class CaptionModelTextAttributeExtractor(TextAttributeExtractor):
method build_prompt (line 219) | def build_prompt(text_content: Optional[str] = None) -> str:
method __init__ (line 230) | def __init__(self, ai_service, prompt_template: Optional[str] = None):
method supports_batch (line 241) | def supports_batch(self) -> bool:
method extract (line 245) | def extract(
method _call_vision_model (line 296) | def _call_vision_model(self, image: Image.Image, prompt: str, thinking...
method _hex_to_rgb (line 336) | def _hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
method _parse_result (line 364) | def _parse_result(self, result_json: Dict[str, Any]) -> TextStyleResult:
method extract_batch_with_full_image (line 428) | def extract_batch_with_full_image(
method _parse_batch_result (line 524) | def _parse_batch_result(
class TextAttributeExtractorRegistry (line 585) | class TextAttributeExtractorRegistry:
method __init__ (line 608) | def __init__(self):
method register (line 613) | def register(self, element_type: str, extractor: TextAttributeExtracto...
method register_types (line 628) | def register_types(self, element_types: List[str], extractor: TextAttr...
method register_default (line 643) | def register_default(self, extractor: TextAttributeExtractor) -> 'Text...
method get_extractor (line 657) | def get_extractor(self, element_type: Optional[str]) -> Optional[TextA...
method get_all_extractors (line 677) | def get_all_extractors(self) -> List[TextAttributeExtractor]:
method create_default (line 690) | def create_default(
FILE: backend/services/inpainting_service.py
class InpaintingService (line 26) | class InpaintingService:
method __init__ (line 40) | def __init__(self, provider=None, provider_type: str = "volcengine"):
method remove_regions_by_bboxes (line 87) | def remove_regions_by_bboxes(
method regenerate_background (line 174) | def regenerate_background(
method create_mask_preview (line 222) | def create_mask_preview(
method create_mask_image (line 245) | def create_mask_image(
function get_inpainting_service (line 269) | def get_inpainting_service(provider_type: str = None) -> InpaintingService:
function remove_regions (line 296) | def remove_regions(
function regenerate_background (line 316) | def regenerate_background(
FILE: backend/services/pdf_service.py
function split_pdf_to_pages (line 12) | def split_pdf_to_pages(pdf_path: str, output_dir: str) -> List[str]:
FILE: backend/services/prompts.py
function _build_prompt (line 83) | def _build_prompt(prompt_text: str, reference_files_content=None, *, tag...
function _get_original_input (line 92) | def _get_original_input(project_context: 'ProjectContext') -> str:
function _get_original_input_labeled (line 103) | def _get_original_input_labeled(project_context: 'ProjectContext') -> str:
function _get_previous_requirements_text (line 117) | def _get_previous_requirements_text(previous_requirements: Optional[List...
function _format_extra_field_instructions (line 125) | def _format_extra_field_instructions(extra_fields: list | None) -> str:
function _format_reference_files_xml (line 133) | def _format_reference_files_xml(reference_files_content: Optional[List[D...
function _format_requirements (line 151) | def _format_requirements(requirements: str, context: str = "outline") ->...
function get_default_output_language (line 180) | def get_default_output_language() -> str:
function get_language_instruction (line 186) | def get_language_instruction(language: str = None) -> str:
function get_ppt_language_instruction (line 193) | def get_ppt_language_instruction(language: str = None) -> str:
function get_outline_generation_prompt (line 205) | def get_outline_generation_prompt(project_context: 'ProjectContext', lan...
function get_outline_generation_prompt_markdown (line 227) | def get_outline_generation_prompt_markdown(project_context: 'ProjectCont...
function get_outline_parsing_prompt (line 277) | def get_outline_parsing_prompt(project_context: 'ProjectContext', langua...
function get_outline_parsing_prompt_markdown (line 311) | def get_outline_parsing_prompt_markdown(project_context: 'ProjectContext...
function get_description_to_outline_prompt (line 338) | def get_description_to_outline_prompt(project_context: 'ProjectContext',...
function get_description_to_outline_prompt_markdown (line 373) | def get_description_to_outline_prompt_markdown(project_context: 'Project...
function get_outline_refinement_prompt (line 400) | def get_outline_refinement_prompt(current_outline: List[Dict], user_requ...
function get_page_description_prompt (line 465) | def get_page_description_prompt(project_context: 'ProjectContext', outli...
function get_all_descriptions_stream_prompt (line 509) | def get_all_descriptions_stream_prompt(project_context: 'ProjectContext',
function get_description_split_prompt (line 568) | def get_description_split_prompt(project_context: 'ProjectContext',
function get_descriptions_refinement_prompt (line 624) | def get_descriptions_refinement_prompt(current_descriptions: List[Dict],...
function get_image_generation_prompt (line 703) | def get_image_generation_prompt(page_desc: str, outline_text: str,
function get_image_edit_prompt (line 751) | def get_image_edit_prompt(edit_instruction: str, original_description: s...
function get_clean_background_prompt (line 777) | def get_clean_background_prompt() -> str:
function get_quality_enhancement_prompt (line 795) | def get_quality_enhancement_prompt(inpainted_regions: list = None) -> str:
function get_text_attribute_extraction_prompt (line 846) | def get_text_attribute_extraction_prompt(content_hint: str = "") -> str:
function get_batch_text_attribute_extraction_prompt (line 890) | def get_batch_text_attribute_extraction_prompt(text_elements_json: str) ...
function get_ppt_page_content_extraction_prompt (line 949) | def get_ppt_page_content_extraction_prompt(markdown_text: str, language:...
function get_layout_caption_prompt (line 991) | def get_layout_caption_prompt() -> str:
function get_style_extraction_prompt (line 1016) | def get_style_extraction_prompt() -> str:
FILE: backend/services/task_manager.py
function _get_image_prompt_field_names (line 18) | def _get_image_prompt_field_names() -> set | None:
function _append_extra_fields (line 30) | def _append_extra_fields(desc_text: str, desc_content: dict) -> str:
class TaskManager (line 47) | class TaskManager:
method __init__ (line 50) | def __init__(self, max_workers: int = 4):
method submit_task (line 56) | def submit_task(self, task_id: str, func: Callable, *args, **kwargs):
method _task_done_callback (line 66) | def _task_done_callback(self, task_id: str, future):
method _cleanup_task (line 78) | def _cleanup_task(self, task_id: str):
method is_task_active (line 84) | def is_task_active(self, task_id: str) -> bool:
method shutdown (line 89) | def shutdown(self):
function save_image_with_version (line 98) | def save_image_with_version(image, project_id: str, page_id: str, file_s...
function generate_descriptions_task (line 167) | def generate_descriptions_task(task_id: str, project_id: str, ai_service,
function generate_images_task (line 323) | def generate_images_task(task_id: str, project_id: str, ai_service, file...
function generate_single_page_image_task (line 550) | def generate_single_page_image_task(task_id: str, project_id: str, page_...
function edit_page_image_task (line 679) | def edit_page_image_task(task_id: str, project_id: str, page_id: str,
function generate_material_image_task (line 786) | def generate_material_image_task(task_id: str, project_id: str, prompt: ...
function process_ppt_renovation_task (line 883) | def process_ppt_renovation_task(task_id: str, project_id: str, ai_service,
function export_editable_pptx_with_recursive_analysis_task (line 1130) | def export_editable_pptx_with_recursive_analysis_task(
FILE: backend/tests/conftest.py
function app (line 26) | def app():
function client (line 65) | def client(app):
function db_session (line 80) | def db_session(app):
function sample_project (line 91) | def sample_project(client):
function mock_ai_service (line 104) | def mock_ai_service():
function temp_upload_dir (line 139) | def temp_upload_dir():
function sample_image_file (line 146) | def sample_image_file():
function assert_success_response (line 164) | def assert_success_response(response, status_code=200):
function assert_error_response (line 173) | def assert_error_response(response, expected_status=None):
FILE: backend/tests/integration/test_api_full_flow.py
function wait_for_project_status (line 38) | def wait_for_project_status(project_id: str, expected_status: str, timeo...
function wait_for_task_completion (line 90) | def wait_for_task_completion(project_id: str, task_id: str, timeout: int...
function project_id (line 146) | def project_id():
class TestAPIFullFlow (line 164) | class TestAPIFullFlow:
method test_api_full_flow_create_to_export (line 174) | def test_api_full_flow_create_to_export(self, project_id):
method test_quick_api_flow_no_ai (line 350) | def test_quick_api_flow_no_ai(self):
FILE: backend/tests/integration/test_full_workflow.py
class TestFullWorkflow (line 12) | class TestFullWorkflow:
method test_create_project_and_get_details (line 15) | def test_create_project_and_get_details(self, client):
method test_template_upload_workflow (line 33) | def test_template_upload_workflow(self, client, sample_image_file):
method test_project_lifecycle (line 54) | def test_project_lifecycle(self, client):
class TestAPIErrorHandling (line 80) | class TestAPIErrorHandling:
method test_invalid_json_body (line 83) | def test_invalid_json_body(self, client):
method test_missing_required_fields (line 93) | def test_missing_required_fields(self, client):
method test_method_not_allowed (line 99) | def test_method_not_allowed(self, client):
class TestConcurrentRequests (line 107) | class TestConcurrentRequests:
method test_multiple_project_creation (line 110) | def test_multiple_project_creation(self, client):
FILE: backend/tests/unit/test_ai_mock.py
class TestAIMock (line 11) | class TestAIMock:
method test_ai_service_is_mocked (line 14) | def test_ai_service_is_mocked(self, mock_ai_service):
method test_description_generation_mocked (line 26) | def test_description_generation_mocked(self, mock_ai_service):
method test_image_generation_mocked (line 35) | def test_image_generation_mocked(self, mock_ai_service):
method test_no_real_api_calls (line 43) | def test_no_real_api_calls(self, mock_ai_service):
class TestEnvironmentFlags (line 55) | class TestEnvironmentFlags:
method test_testing_flag_is_set (line 58) | def test_testing_flag_is_set(self):
method test_mock_ai_flag_is_set (line 63) | def test_mock_ai_flag_is_set(self):
FILE: backend/tests/unit/test_api_health.py
class TestHealthEndpoint (line 8) | class TestHealthEndpoint:
method test_health_check_returns_ok (line 11) | def test_health_check_returns_ok(self, client):
method test_health_check_response_format (line 20) | def test_health_check_response_format(self, client):
FILE: backend/tests/unit/test_api_material.py
function _create_test_image (line 11) | def _create_test_image():
class TestMaterialUpload (line 21) | class TestMaterialUpload:
method test_upload_material_without_caption (line 24) | def test_upload_material_without_caption(self, client):
method test_upload_material_with_caption (line 37) | def test_upload_material_with_caption(self, mock_caption, client):
method test_upload_material_caption_failure_still_succeeds (line 52) | def test_upload_material_caption_failure_still_succeeds(self, mock_cap...
method test_upload_material_caption_false_param (line 66) | def test_upload_material_caption_false_param(self, mock_caption, client):
method test_upload_material_invalid_file_type (line 78) | def test_upload_material_invalid_file_type(self, client):
method test_upload_material_no_file (line 87) | def test_upload_material_no_file(self, client):
class TestGenerateImageCaption (line 97) | class TestGenerateImageCaption:
method test_caption_returns_empty_on_missing_gemini_key (line 100) | def test_caption_returns_empty_on_missing_gemini_key(self, app):
method test_caption_returns_empty_on_missing_openai_key (line 119) | def test_caption_returns_empty_on_missing_openai_key(self, app):
method test_caption_returns_empty_on_invalid_file (line 137) | def test_caption_returns_empty_on_invalid_file(self, app):
method test_caption_gemini_success (line 147) | def test_caption_gemini_success(self, mock_client_class, app):
FILE: backend/tests/unit/test_api_project.py
class TestProjectCreate (line 9) | class TestProjectCreate:
method test_create_project_idea_mode (line 12) | def test_create_project_idea_mode(self, client):
method test_create_project_outline_mode (line 23) | def test_create_project_outline_mode(self, client):
method test_create_project_missing_type (line 36) | def test_create_project_missing_type(self, client):
method test_create_project_invalid_type (line 45) | def test_create_project_invalid_type(self, client):
class TestProjectGet (line 55) | class TestProjectGet:
method test_get_project_success (line 58) | def test_get_project_success(self, client, sample_project):
method test_get_project_not_found (line 69) | def test_get_project_not_found(self, client):
method test_get_project_invalid_id_format (line 75) | def test_get_project_invalid_id_format(self, client):
class TestProjectUpdate (line 83) | class TestProjectUpdate:
method test_update_project_status (line 86) | def test_update_project_status(self, client, sample_project):
class TestProjectDelete (line 102) | class TestProjectDelete:
method test_delete_project_success (line 105) | def test_delete_project_success(self, client, sample_project):
method test_delete_project_not_found (line 119) | def test_delete_project_not_found(self, client):
FILE: backend/tests/unit/test_api_settings_provider.py
function _build_settings (line 13) | def _build_settings(**overrides):
function test_update_settings_accepts_lazyllm_provider (line 30) | def test_update_settings_accepts_lazyllm_provider():
function test_verify_uses_configured_text_model (line 48) | def test_verify_uses_configured_text_model():
FILE: backend/tests/unit/test_editable_pptx_style_extraction.py
class FailingExtractor (line 7) | class FailingExtractor:
method extract_batch_with_full_image (line 8) | def extract_batch_with_full_image(self, full_image, text_elements, **k...
method extract (line 11) | def extract(self, image, text_content=None, **kwargs):
class EmptyGlobalExtractor (line 15) | class EmptyGlobalExtractor:
method extract_batch_with_full_image (line 16) | def extract_batch_with_full_image(self, full_image, text_elements, **k...
method extract (line 19) | def extract(self, image, text_content=None, **kwargs):
class EditableImageStub (line 23) | class EditableImageStub:
class BBox (line 24) | class BBox:
method __init__ (line 25) | def __init__(self):
class Element (line 31) | class Element:
method __init__ (line 32) | def __init__(self, image_path: str):
method __init__ (line 41) | def __init__(self, image_path: str):
function _make_editable_images (line 46) | def _make_editable_images(tmp_path):
function test_hybrid_style_extraction_fails_fast_when_provider_has_no_image_input (line 52) | def test_hybrid_style_extraction_fails_fast_when_provider_has_no_image_i...
function test_hybrid_style_extraction_reports_missing_global_results_when_not_fail_fast (line 69) | def test_hybrid_style_extraction_reports_missing_global_results_when_not...
FILE: backend/tests/unit/test_file_parser_service.py
function _create_temp_image (line 15) | def _create_temp_image() -> str:
function test_generate_single_caption_openai_uses_configured_model (line 21) | def test_generate_single_caption_openai_uses_configured_model():
function test_can_generate_captions_does_not_accept_legacy_prefixes (line 48) | def test_can_generate_captions_does_not_accept_legacy_prefixes():
function test_can_generate_captions_accepts_vendor_prefix_key (line 68) | def test_can_generate_captions_accepts_vendor_prefix_key():
FILE: backend/tests/unit/test_image_prompt_ratio.py
class TestImagePromptAspectRatio (line 5) | class TestImagePromptAspectRatio:
method test_default_ratio_is_16_9 (line 6) | def test_default_ratio_is_16_9(self):
method test_custom_ratio_4_3 (line 14) | def test_custom_ratio_4_3(self):
method test_custom_ratio_1_1 (line 24) | def test_custom_ratio_1_1(self):
FILE: backend/tests/unit/test_lazyllm_image_content_type.py
function _make_png_bytes (line 15) | def _make_png_bytes() -> bytes:
function _inject_lazyllm_mock (line 22) | def _inject_lazyllm_mock():
class TestLazyLLMContentTypeFallback (line 37) | class TestLazyLLMContentTypeFallback:
method setup_method (line 39) | def setup_method(self):
method _make_provider (line 46) | def _make_provider(self):
method test_fallback_on_content_type_error (line 54) | def test_fallback_on_content_type_error(self):
method test_untrusted_host_is_not_fetched (line 80) | def test_untrusted_host_is_not_fetched(self):
method test_non_content_type_error_is_reraised (line 96) | def test_non_content_type_error_is_reraised(self):
FILE: backend/tests/unit/test_smart_merge.py
function merge_app (line 15) | def merge_app():
function ctx (line 33) | def ctx(merge_app):
function _make_project (line 43) | def _make_project(pid='test-proj'):
function _make_page (line 51) | def _make_page(project_id, title, order, desc=None, image_path=None, sta...
class TestPositionBasedMerge (line 64) | class TestPositionBasedMerge:
method test_equal_pages_preserves_description_and_image (line 66) | def test_equal_pages_preserves_description_and_image(self, ctx):
method test_more_pages_creates_new_ones (line 94) | def test_more_pages_creates_new_ones(self, ctx):
method test_fewer_pages_deletes_trailing (line 114) | def test_fewer_pages_deletes_trailing(self, ctx):
method test_no_old_pages_creates_all_new (line 135) | def test_no_old_pages_creates_all_new(self, ctx):
method test_part_field_updated (line 151) | def test_part_field_updated(self, ctx):
method test_order_index_updated (line 166) | def test_order_index_updated(self, ctx):
FILE: backend/utils/image_utils.py
function check_image_resolution (line 8) | def check_image_resolution(image: Image.Image, expected_resolution: str)...
FILE: backend/utils/latex_utils.py
function is_simple_latex (line 79) | def is_simple_latex(latex: str) -> bool:
function latex_to_text (line 116) | def latex_to_text(latex: str) -> str:
function latex_to_mathml (line 160) | def latex_to_mathml(latex: str) -> Optional[str]:
function mathml_to_omml (line 179) | def mathml_to_omml(mathml: str) -> Optional[str]:
function convert_latex_for_pptx (line 221) | def convert_latex_for_pptx(latex: str) -> Tuple[str, Optional[str]]:
FILE: backend/utils/mask_utils.py
function normalize_bbox (line 14) | def normalize_bbox(bbox: Union[Tuple, List, dict]) -> Tuple[int, int, in...
function normalize_bboxes (line 38) | def normalize_bboxes(bboxes: List[Union[Tuple, List, dict]]) -> List[Tup...
function merge_two_boxes (line 49) | def merge_two_boxes(box1: Tuple, box2: Tuple) -> Tuple[int, int, int, int]:
function _iterative_merge (line 59) | def _iterative_merge(
function create_mask_from_bboxes (line 109) | def create_mask_from_bboxes(
function create_inverse_mask_from_bboxes (line 225) | def create_inverse_mask_from_bboxes(
function create_mask_from_image_and_bboxes (line 251) | def create_mask_from_image_and_bboxes(
function visualize_mask_overlay (line 274) | def visualize_mask_overlay(
function merge_vertical_nearby_bboxes (line 331) | def merge_vertical_nearby_bboxes(
function merge_overlapping_bboxes (line 422) | def merge_overlapping_bboxes(
FILE: backend/utils/page_utils.py
function parse_page_ids_from_query (line 8) | def parse_page_ids_from_query(request: Request) -> List[str]:
function parse_page_ids_from_body (line 24) | def parse_page_ids_from_body(data: dict) -> List[str]:
function get_filtered_pages (line 40) | def get_filtered_pages(project_id: str, page_ids: Optional[List[str]] = ...
FILE: backend/utils/path_utils.py
function convert_mineru_path_to_local (line 12) | def convert_mineru_path_to_local(mineru_path: str, project_root: Optiona...
function find_mineru_file_with_prefix (line 46) | def find_mineru_file_with_prefix(mineru_path: str, project_root: Optiona...
function find_file_with_prefix (line 75) | def find_file_with_prefix(file_path: Path) -> Optional[Path]:
FILE: backend/utils/pptx_builder.py
class HTMLTableParser (line 20) | class HTMLTableParser(HTMLParser):
method __init__ (line 23) | def __init__(self):
method handle_starttag (line 32) | def handle_starttag(self, tag, attrs):
method handle_endtag (line 43) | def handle_endtag(self, tag):
method handle_data (line 55) | def handle_data(self, data):
method parse_html_table (line 60) | def parse_html_table(html: str) -> List[List[str]]:
class PPTXBuilder (line 67) | class PPTXBuilder:
method _get_font (line 95) | def _get_font(cls, size_pt: float) -> Optional[ImageFont.FreeTypeFont]:
method _measure_text_width (line 110) | def _measure_text_width(cls, text: str, font_size_pt: float) -> Option...
method __init__ (line 135) | def __init__(self, slide_width_inches: float = None, slide_height_inch...
method create_presentation (line 148) | def create_presentation(self) -> Presentation:
method _set_core_properties (line 157) | def _set_core_properties(prs: Presentation) -> None:
method setup_presentation_size (line 170) | def setup_presentation_size(self, width_pixels: int, height_pixels: in...
method add_blank_slide (line 226) | def add_blank_slide(self):
method pixels_to_inches (line 236) | def pixels_to_inches(self, pixels: float, dpi: int = None) -> float:
method calculate_font_size (line 250) | def calculate_font_size(self, bbox: List[int], text: str, text_level: ...
method add_text_element (line 346) | def add_text_element(
method add_image_element (line 502) | def add_image_element(
method add_image_placeholder (line 540) | def add_image_placeholder(
method add_table_element (line 571) | def add_table_element(
method save (line 650) | def save(self, output_path: str):
method get_presentation (line 669) | def get_presentation(self) -> Presentation:
FILE: backend/utils/response.py
function success_response (line 8) | def success_response(data: Any = None, message: str = "Success", status_...
function error_response (line 31) | def error_response(error_code: str, message: str, status_code: int = 400):
function bad_request (line 53) | def bad_request(message: str = "Invalid request"):
function not_found (line 57) | def not_found(resource: str = "Resource"):
function invalid_status (line 61) | def invalid_status(message: str = "Invalid status for this operation"):
function ai_service_error (line 65) | def ai_service_error(message: str = "AI service error"):
function rate_limit_error (line 69) | def rate_limit_error(message: str = "Rate limit exceeded"):
FILE: backend/utils/validators.py
function normalize_aspect_ratio (line 15) | def normalize_aspect_ratio(raw_value) -> str:
function validate_project_status (line 90) | def validate_project_status(status: str) -> bool:
function validate_page_status (line 95) | def validate_page_status(status: str) -> bool:
function validate_task_status (line 100) | def validate_task_status(status: str) -> bool:
function validate_task_type (line 105) | def validate_task_type(task_type: str) -> bool:
function allowed_file (line 110) | def allowed_file(filename: str, allowed_extensions: Set[str]) -> bool:
FILE: create-test-data.mjs
constant BASE_URL (line 6) | const BASE_URL = process.env.BASE_URL || 'http://localhost:5401';
function createProject (line 9) | async function createProject(title) {
function createTempFile (line 25) | function createTempFile(filename, content) {
function uploadFile (line 36) | async function uploadFile(projectId, filename, content) {
function main (line 53) | async function main() {
FILE: frontend/e2e/aspect-ratio-lock-integration.spec.ts
constant BASE (line 9) | const BASE = process.env.BASE_URL || 'http://localhost:3000'
constant API (line 10) | const API = `http://localhost:${Number(new URL(BASE).port) + 2000}`
FILE: frontend/e2e/aspect-ratio-lock.spec.ts
constant PROJECT_ID (line 12) | const PROJECT_ID = 'mock-aspect-lock'
function mockRoutes (line 41) | function mockRoutes(page: any, pages: any[]) {
FILE: frontend/e2e/badge-status-after-generation.spec.ts
constant PROJECT_ID (line 4) | const PROJECT_ID = 'badge-race-mock'
constant PAGE_IDS (line 5) | const PAGE_IDS = ['p-1', 'p-2', 'p-3']
function makePage (line 7) | function makePage(id: string, idx: number, status: string, hasImage: boo...
function projectJson (line 20) | function projectJson(pages: ReturnType<typeof makePage>[], projectStatus...
function mockCommonRoutes (line 37) | async function mockCommonRoutes(page: Page) {
FILE: frontend/e2e/desc-regeneration-skeleton.spec.ts
constant PROJECT_ID (line 9) | const PROJECT_ID = 'mock-proj-regen-skeleton'
function makePage (line 11) | function makePage(id: string, index: number, title: string, opts?: { des...
constant OLD_DESC_1 (line 25) | const OLD_DESC_1 = 'Old description for page one'
constant OLD_DESC_2 (line 26) | const OLD_DESC_2 = 'Old description for page two'
constant NEW_DESC_1 (line 27) | const NEW_DESC_1 = 'Brand new description for page one'
constant NEW_DESC_2 (line 28) | const NEW_DESC_2 = 'Brand new description for page two'
FILE: frontend/e2e/description-detail-level.spec.ts
constant PROJECT_ID (line 11) | const PROJECT_ID = 'mock-proj-detail-level'
function makePage (line 13) | function makePage(id: string, index: number, title: string, description?...
function setupMockRoutes (line 33) | async function setupMockRoutes(page: import('@playwright/test').Page) {
FILE: frontend/e2e/description-no-flicker.spec.ts
constant PROJECT_ID (line 10) | const PROJECT_ID = 'mock-proj-flicker'
function makePage (line 12) | function makePage(id: string, index: number, title: string, description?...
FILE: frontend/e2e/export-aspect-ratio.spec.ts
constant BASE (line 13) | const BASE = process.env.BASE_URL || 'http://localhost:3000'
constant API (line 15) | const API = `http://localhost:${Number(new URL(BASE).port) + 2000}`
constant TINY_PNG (line 18) | const TINY_PNG = Buffer.from(
constant WORKTREE_ROOT (line 26) | const WORKTREE_ROOT = path.resolve(__dirname, '..', '..')
constant UPLOADS_DIR (line 27) | const UPLOADS_DIR = path.join(WORKTREE_ROOT, 'uploads')
constant DB_PATH (line 28) | const DB_PATH = path.join(WORKTREE_ROOT, 'backend', 'instance', 'databas...
constant UUID_RE (line 30) | const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-...
function assertUUID (line 32) | function assertUUID(val: string, label: string) {
type ProjectData (line 36) | interface ProjectData {
function setupProject (line 42) | async function setupProject(
function cleanup (line 84) | function cleanup(projectId: string) {
FILE: frontend/e2e/extract-style-caption.spec.ts
constant BASE_URL (line 9) | const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'
constant TINY_PNG (line 11) | const TINY_PNG = Buffer.from(
function triggerStyleExtract (line 17) | async function triggerStyleExtract(page: import('@playwright/test').Page) {
FILE: frontend/e2e/failed-file-reselect.spec.ts
constant FILE_FAILED (line 8) | const FILE_FAILED = 'file-failed-001'
constant FILE_COMPLETED (line 9) | const FILE_COMPLETED = 'file-completed-002'
FILE: frontend/e2e/file-preview-scrollbar.spec.ts
constant LONG_MARKDOWN (line 12) | const LONG_MARKDOWN = '# Test Document\n\n' + 'Lorem ipsum dolor sit ame...
constant PROJECT_ID (line 15) | const PROJECT_ID = 'mock-proj-001'
constant FILE_ID (line 16) | const FILE_ID = 'mock-file-001'
FILE: frontend/e2e/generation-fail.spec.ts
function setupFailureMocks (line 3) | async function setupFailureMocks(page: Page, projectId: string, failUrl:...
FILE: frontend/e2e/generation-requirements.spec.ts
constant PROJECT_ID (line 3) | const PROJECT_ID = 'mock-gen-req-project'
function clearAndType (line 43) | async function clearAndType(editor: import('@playwright/test').Locator, ...
FILE: frontend/e2e/helpers/seed-project.ts
constant FRONTEND_DIR (line 14) | const FRONTEND_DIR = cwd.endsWith('frontend') ? cwd : path.join(cwd, 'fr...
constant PROJECT_ROOT (line 15) | const PROJECT_ROOT = path.resolve(FRONTEND_DIR, '..')
constant DB_PATH (line 16) | const DB_PATH = path.join(PROJECT_ROOT, 'backend', 'instance', 'database...
constant UPLOADS (line 17) | const UPLOADS = path.join(PROJECT_ROOT, 'uploads')
constant FIXTURES (line 18) | const FIXTURES = path.join(FRONTEND_DIR, 'e2e', 'fixtures')
function sql (line 20) | function sql(query: string) {
function getFixtureImage (line 25) | function getFixtureImage(index: number): string {
type SeededProject (line 30) | interface SeededProject {
function seedProjectWithImages (line 39) | async function seedProjectWithImages(
FILE: frontend/e2e/history-pagination.spec.ts
constant PAGE_SIZE (line 12) | const PAGE_SIZE = 5
function makeProject (line 14) | function makeProject(index: number) {
function setupMockRoutes (line 37) | async function setupMockRoutes(
function createSimpleProject (line 199) | async function createSimpleProject(index: number): Promise<string> {
function deleteProject (line 209) | async function deleteProject(projectId: string) {
FILE: frontend/e2e/image-prompt-ratio.spec.ts
constant BASE (line 9) | const BASE = process.env.BASE_URL || 'http://localhost:3000'
constant API (line 10) | const API = `http://localhost:${Number(new URL(BASE).port) + 2000}`
FILE: frontend/e2e/image-queued-status.spec.ts
constant PROJECT_ID (line 3) | const PROJECT_ID = 'queued-status-mock'
constant PAGE_IDS (line 4) | const PAGE_IDS = ['p-1', 'p-2', 'p-3', 'p-4']
function makePage (line 6) | function makePage(id: string, idx: number, status: string, hasImage: boo...
function projectJson (line 19) | function projectJson(pages: ReturnType<typeof makePage>[], projectStatus...
function mockCommonRoutes (line 36) | async function mockCommonRoutes(page: Page) {
FILE: frontend/e2e/import-markdown.spec.ts
constant PROJECT_ID (line 10) | const PROJECT_ID = 'mock-import-proj'
constant UNIFIED_MD (line 27) | const UNIFIED_MD = `# 项目
constant LEGACY_MD (line 57) | const LEGACY_MD = `# 大纲
constant EMPTY_MD (line 65) | const EMPTY_MD = `# 空文件
function writeTempFile (line 123) | function writeTempFile(name: string, content: string): string {
FILE: frontend/e2e/lazyllm-image-content-type.spec.ts
constant BASE (line 13) | const BASE = process.env.BASE_URL ?? 'http://localhost:3000'
FILE: frontend/e2e/markdown-card-style.spec.ts
constant BASE (line 3) | const BASE = process.env.BASE_URL || 'http://localhost:3000';
constant PROJECT_ID (line 4) | const PROJECT_ID = 'mock-style-test';
constant TINY_PNG (line 7) | const TINY_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB...
function makePage (line 9) | function makePage(id: string, index: number, title: string, description:...
function setupMocks (line 23) | function setupMocks(page: import('@playwright/test').Page, pages: Return...
FILE: frontend/e2e/material-aspect-ratio.spec.ts
function openMaterialGeneratorModal (line 23) | async function openMaterialGeneratorModal(page: Page) {
FILE: frontend/e2e/outline-autosave-blur.spec.ts
constant PROJECT_ID (line 3) | const PROJECT_ID = 'mock-autosave-project'
FILE: frontend/e2e/outline-null-crash.spec.ts
constant PROJECT_ID (line 3) | const PROJECT_ID = 'mock-null-outline'
FILE: frontend/e2e/parsing-preview-toast.spec.ts
constant PROJECT_ID (line 8) | const PROJECT_ID = 'mock-proj-parse'
constant FILE_PARSING (line 9) | const FILE_PARSING = 'file-parsing-001'
constant FILE_COMPLETED (line 10) | const FILE_COMPLETED = 'file-completed-002'
FILE: frontend/e2e/per-model-startup-creds.spec.ts
constant BACKEND_URL (line 21) | const BACKEND_URL = `http://localhost:${backendPort}`
constant PROJECT_ROOT (line 22) | const PROJECT_ROOT = path.resolve(__dirname, '..', '..')
constant LOG_FILE (line 23) | const LOG_FILE = path.join('/tmp', `startup-creds-backend-${process.pid}...
function restartBackend (line 25) | function restartBackend() {
FILE: frontend/e2e/preset-capsules.spec.ts
constant PROJECT_ID (line 3) | const PROJECT_ID = 'mock-preset-project'
function setupProjectMock (line 27) | async function setupProjectMock(page: import('@playwright/test').Page) {
FILE: frontend/e2e/preview-text-style-template.spec.ts
constant BASE_URL (line 10) | const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'
constant BACKEND_URL (line 11) | const BACKEND_URL = BASE_URL.replace(/:\d+$/, (m) => `:${parseInt(m.slic...
function setupMocks (line 14) | async function setupMocks(page: import('@playwright/test').Page) {
FILE: frontend/e2e/renovation-aspect-ratio.spec.ts
constant BASE (line 12) | const BASE = process.env.BASE_URL || 'http://localhost:3000'
constant API (line 13) | const API = `http://localhost:${Number(new URL(BASE).port) + 2000}`
FILE: frontend/e2e/settings-backfill.spec.ts
constant BASE_URL (line 10) | const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'
FILE: frontend/e2e/settings-env-fallback.spec.ts
constant BASE_URL (line 11) | const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'
FILE: frontend/e2e/settings-per-model-provider-integration.spec.ts
function getModelGroup (line 8) | function getModelGroup(page: Page, index: number) {
FILE: frontend/e2e/settings-per-model-provider.spec.ts
function getModelGroup (line 64) | function getModelGroup(page: Page, index: number) {
FILE: frontend/e2e/settings-read-only.spec.ts
constant BASE (line 6) | const BASE = process.env.BASE_URL ?? 'http://localhost:5173';
constant DB_PATH (line 7) | const DB_PATH = process.env.DB_PATH ??
function dbQuery (line 10) | function dbQuery(sql: string): string {
FILE: frontend/e2e/settings-reset-fallback.spec.ts
constant BASE (line 15) | const BASE = process.env.BASE_URL ?? 'http://localhost:5173'
FILE: frontend/e2e/smart-merge.spec.ts
constant BASE (line 9) | const BASE = process.env.BASE_URL || 'http://localhost:3000'
constant PROJECT_ID (line 10) | const PROJECT_ID = 'mock-merge-proj'
constant INITIAL_PAGES (line 12) | const INITIAL_PAGES = [
constant REFINED_FEWER_PAGES (line 43) | const REFINED_FEWER_PAGES = [
constant REFINED_MORE_PAGES (line 65) | const REFINED_MORE_PAGES = [
function setupMocks (line 104) | function setupMocks(page: import('@playwright/test').Page, pagesRef: { c...
FILE: frontend/e2e/streaming-descriptions.spec.ts
constant BASE_URL (line 3) | const BASE_URL = process.env.BASE_URL || 'http://localhost:3240';
function createProjectWithOutline (line 8) | async function createProjectWithOutline(page: import('@playwright/test')...
FILE: frontend/e2e/streaming-outline.spec.ts
constant BASE_URL (line 3) | const BASE_URL = process.env.BASE_URL || 'http://localhost:3240';
function createProjectAndNavigate (line 8) | async function createProjectAndNavigate(page: import('@playwright/test')...
FILE: frontend/e2e/upload-folder-path.spec.ts
constant FRONTEND_DIR (line 19) | const FRONTEND_DIR = process.cwd().endsWith('frontend')
constant PROJECT_ROOT (line 22) | const PROJECT_ROOT = path.resolve(FRONTEND_DIR, '..')
constant FIXTURES (line 23) | const FIXTURES = path.join(FRONTEND_DIR, 'e2e', 'fixtures')
constant BACKEND_LOG (line 24) | const BACKEND_LOG = '/tmp/fix-upload-backend.log'
FILE: frontend/e2e/ux-polish-i18n.spec.ts
constant BASE (line 4) | const BASE = process.env.BASE_URL || 'http://localhost:3000';
FILE: frontend/src/App.tsx
function App (line 13) | function App() {
FILE: frontend/src/api/client.ts
constant API_BASE_URL (line 5) | const API_BASE_URL = '';
FILE: frontend/src/api/endpoints.ts
type OutlineStreamPage (line 134) | interface OutlineStreamPage {
type OutlineStreamCallbacks (line 141) | interface OutlineStreamCallbacks {
type DescriptionStreamEvent (line 247) | interface DescriptionStreamEvent {
type DescriptionStreamCallbacks (line 254) | interface DescriptionStreamCallbacks {
type Material (line 713) | interface Material {
type UserTemplate (line 838) | interface UserTemplate {
type ReferenceFile (line 887) | interface ReferenceFile {
type OutputLanguage (line 1000) | type OutputLanguage = 'zh' | 'ja' | 'en' | 'auto';
type OutputLanguageOption (line 1002) | interface OutputLanguageOption {
constant OUTPUT_LANGUAGE_OPTIONS (line 1007) | const OUTPUT_LANGUAGE_OPTIONS: OutputLanguageOption[] = [
type TestSettingsOverride (line 1087) | interface TestSettingsOverride {
FILE: frontend/src/components/history/ProjectCard.tsx
type ProjectCardProps (line 18) | interface ProjectCardProps {
FILE: frontend/src/components/outline/OutlineCard.tsx
type OutlineCardProps (line 33) | interface OutlineCardProps {
FILE: frontend/src/components/preview/DescriptionCard.tsx
type DescriptionCardProps (line 38) | interface DescriptionCardProps {
FILE: frontend/src/components/preview/SlideCard.tsx
type SlideCardProps (line 30) | interface SlideCardProps {
FILE: frontend/src/components/shared/AccessCodeGuard.tsx
constant STORAGE_KEY (line 7) | const STORAGE_KEY = 'banana-access-code';
function AccessCodeGuard (line 32) | function AccessCodeGuard({ children }: { children: ReactNode }) {
FILE: frontend/src/components/shared/AiRefineInput.tsx
type AiRefineInputProps (line 23) | interface AiRefineInputProps {
FILE: frontend/src/components/shared/Button.tsx
type ButtonProps (line 4) | interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonEleme...
FILE: frontend/src/components/shared/Card.tsx
type CardProps (line 4) | interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
FILE: frontend/src/components/shared/ConfirmDialog.tsx
type ConfirmDialogProps (line 6) | interface ConfirmDialogProps {
FILE: frontend/src/components/shared/ContextualStatusBadge.tsx
type ContextualStatusBadgeProps (line 6) | interface ContextualStatusBadgeProps {
FILE: frontend/src/components/shared/ExportTasksPanel.tsx
type ExportTasksPanelProps (line 362) | interface ExportTasksPanelProps {
FILE: frontend/src/components/shared/FilePreviewModal.tsx
type FilePreviewModalProps (line 22) | interface FilePreviewModalProps {
FILE: frontend/src/components/shared/Footer.tsx
constant GITHUB_REPO (line 4) | const GITHUB_REPO = 'Anionex/banana-slides';
constant GITHUB_URL (line 5) | const GITHUB_URL = `https://github.com/${GITHUB_REPO}`;
FILE: frontend/src/components/shared/GithubBadge.tsx
constant GITHUB_REPO (line 4) | const GITHUB_REPO = 'Anionex/banana-slides';
constant GITHUB_URL (line 5) | const GITHUB_URL = `https://github.com/${GITHUB_REPO}`;
type GithubStats (line 7) | interface GithubStats {
constant CACHE_KEY (line 12) | const CACHE_KEY = 'github-stats-cache-v2';
constant CACHE_DURATION (line 13) | const CACHE_DURATION = 3600 * 1000;
FILE: frontend/src/components/shared/GithubRepoCard.tsx
constant GITHUB_REPO (line 4) | const GITHUB_REPO = 'Anionex/banana-slides';
constant GITHUB_URL (line 5) | const GITHUB_URL = `https://github.com/${GITHUB_REPO}`;
type RepoStats (line 7) | interface RepoStats {
FILE: frontend/src/components/shared/HelpModal.tsx
constant SHOWCASES (line 90) | const SHOWCASES = [
constant FEATURES (line 97) | const FEATURES: { key: string; icon: React.ReactNode }[] = [
function tList (line 108) | function tList(lang: 'zh' | 'en', path: string): string[] {
type PageRenderer (line 121) | type PageRenderer = (ctx: {
type PageDef (line 304) | interface PageDef {
constant PAGES (line 310) | const PAGES: PageDef[] = [
type HelpModalProps (line 319) | interface HelpModalProps {
FILE: frontend/src/components/shared/ImagePreviewList.tsx
type ImagePreviewListProps (line 16) | interface ImagePreviewListProps {
FILE: frontend/src/components/shared/Input.tsx
type InputProps (line 4) | interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
FILE: frontend/src/components/shared/Loading.tsx
type ProgressData (line 6) | interface ProgressData {
type LoadingProps (line 14) | interface LoadingProps {
FILE: frontend/src/components/shared/Markdown.tsx
type MarkdownProps (line 11) | interface MarkdownProps {
function preprocessMarkdown (line 20) | function preprocessMarkdown(content: string): string {
FILE: frontend/src/components/shared/MarkdownTextarea.tsx
constant IMAGE_REGEX (line 27) | const IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
constant CHIP_SELECTED_CLASS (line 28) | const CHIP_SELECTED_CLASS = 'md-chip-selected';
constant CHIP_CLASS (line 29) | const CHIP_CLASS = 'md-chip';
type MarkdownTextareaProps (line 31) | interface MarkdownTextareaProps {
type MarkdownTextareaRef (line 55) | interface MarkdownTextareaRef {
function escapeHtml (line 62) | function escapeHtml(text: string) {
type Segment (line 66) | type Segment =
function parseSegments (line 70) | function parseSegments(text: string): Segment[] {
function serializeDOM (line 88) | function serializeDOM(element: HTMLElement): string {
function getDisplayName (line 110) | function getDisplayName(alt: string, url: string): string {
constant IMAGE_ICON (line 120) | const IMAGE_ICON = '<svg class="flex-shrink-0" width="12" height="12" vi...
constant SPINNER_ICON (line 121) | const SPINNER_ICON = '<span class="inline-block w-3 h-3 border-2 border-...
function applyChipContent (line 123) | function applyChipContent(chip: HTMLElement, seg: { alt: string; url: st...
function buildDOM (line 143) | function buildDOM(container: HTMLElement, segments: Segment[], tooltips?...
function patchChips (line 168) | function patchChips(container: HTMLElement, oldValue: string, newValue: ...
function getChipBeforeCursor (line 197) | function getChipBeforeCursor(): HTMLElement | null {
function getChipAfterCursor (line 212) | function getChipAfterCursor(): HTMLElement | null {
function clearChipSelection (line 229) | function clearChipSelection(container: HTMLElement) {
function selectChip (line 235) | function selectChip(chip: HTMLElement) {
FILE: frontend/src/components/shared/MaterialCenterModal.tsx
type State (line 78) | interface State {
type Action (line 92) | type Action =
function reducer (line 123) | function reducer(s: State, a: Action): State {
constant ACCEPTED_TYPES (line 184) | const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/g...
type MaterialCenterModalProps (line 387) | interface MaterialCenterModalProps {
FILE: frontend/src/components/shared/MaterialGeneratorModal.tsx
type MaterialGeneratorModalProps (line 57) | interface MaterialGeneratorModalProps {
FILE: frontend/src/components/shared/MaterialSelector.tsx
type MaterialSelectorProps (line 48) | interface MaterialSelectorProps {
FILE: frontend/src/components/shared/Modal.tsx
type ModalProps (line 5) | interface ModalProps {
FILE: frontend/src/components/shared/Pagination.tsx
type PaginationProps (line 5) | interface PaginationProps {
FILE: frontend/src/components/shared/PresetCapsules.tsx
type Preset (line 36) | interface Preset {
type PresetType (line 41) | type PresetType = 'outline' | 'description';
constant SYSTEM_PRESETS (line 44) | const SYSTEM_PRESETS: Record<PresetType, Record<'zh' | 'en', Preset[]>> = {
constant STORAGE_KEY_PREFIX (line 55) | const STORAGE_KEY_PREFIX = 'presetCapsules_';
function loadUserPresets (line 57) | function loadUserPresets(type: PresetType): Preset[] {
function saveUserPresets (line 66) | function saveUserPresets(type: PresetType, presets: Preset[]) {
type PresetCapsulesProps (line 71) | interface PresetCapsulesProps {
function PresetCapsules (line 76) | function PresetCapsules({ type, onAppend }: PresetCapsulesProps) {
FILE: frontend/src/components/shared/ProjectResourcesList.tsx
type ProjectResourcesListProps (line 26) | interface ProjectResourcesListProps {
FILE: frontend/src/components/shared/ProjectSettingsModal.tsx
type ProjectSettingsModalProps (line 79) | interface ProjectSettingsModalProps {
type SettingsTab (line 105) | type SettingsTab = 'project' | 'global' | 'export';
FILE: frontend/src/components/shared/ReferenceFileCard.tsx
type ReferenceFileCardProps (line 26) | interface ReferenceFileCardProps {
FILE: frontend/src/components/shared/ReferenceFileList.tsx
type ReferenceFileListProps (line 12) | interface ReferenceFileListProps {
FILE: frontend/src/components/shared/ReferenceFileSelector.tsx
type ReferenceFileSelectorProps (line 68) | interface ReferenceFileSelectorProps {
FILE: frontend/src/components/shared/ShimmerOverlay.tsx
type ShimmerOverlayProps (line 3) | interface ShimmerOverlayProps {
FILE: frontend/src/components/shared/StatusBadge.tsx
type StatusBadgeProps (line 32) | interface StatusBadgeProps {
FILE: frontend/src/components/shared/TemplateSelector.tsx
type TemplateSelectorProps (line 44) | interface TemplateSelectorProps {
FILE: frontend/src/components/shared/TextStyleSelector.tsx
type TextStyleSelectorProps (line 32) | interface TextStyleSelectorProps {
FILE: frontend/src/components/shared/Textarea.tsx
type TextareaProps (line 4) | interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAre...
FILE: frontend/src/components/shared/Toast.tsx
type ToastProps (line 5) | interface ToastProps {
FILE: frontend/src/config/aspectRatio.ts
constant ASPECT_RATIO_OPTIONS (line 1) | const ASPECT_RATIO_OPTIONS = [
FILE: frontend/src/config/presetStyles.ts
type PresetStyle (line 3) | interface PresetStyle {
constant PRESET_STYLES (line 12) | const PRESET_STYLES: PresetStyle[] = [
FILE: frontend/src/hooks/useImagePaste.ts
constant ALLOWED_IMAGE_TYPES (line 5) | const ALLOWED_IMAGE_TYPES = [
constant UPLOADING_PREFIX (line 10) | const UPLOADING_PREFIX = 'uploading:';
type UseImagePasteOptions (line 55) | interface UseImagePasteOptions {
FILE: frontend/src/hooks/usePageStatus.ts
type PageStatusContext (line 30) | type PageStatusContext = 'description' | 'image' | 'full';
type DerivedPageStatus (line 32) | interface DerivedPageStatus {
function getStatusLabel (line 128) | function getStatusLabel(status: PageStatus, t: (key: string) => string):...
function getStatusDescription (line 141) | function getStatusDescription(status: PageStatus, t: (key: string) => st...
FILE: frontend/src/hooks/useT.ts
type NestedRecord (line 3) | type NestedRecord = Record<string, unknown>;
type Translations (line 5) | type Translations = {
function getNestedValue (line 14) | function getNestedValue(obj: NestedRecord, path: string): string | undef...
function useT (line 60) | function useT<T extends Translations>(translations: T) {
FILE: frontend/src/hooks/useTheme.ts
type Theme (line 3) | type Theme = 'light' | 'dark' | 'system';
constant THEME_KEY (line 5) | const THEME_KEY = 'banana-slides-theme';
function getSystemTheme (line 7) | function getSystemTheme(): 'light' | 'dark' {
function applyTheme (line 14) | function applyTheme(theme: Theme) {
function useTheme (line 25) | function useTheme() {
FILE: frontend/src/pages/DetailEditor.tsx
constant PRESET_EXTRA_FIELDS (line 115) | const PRESET_EXTRA_FIELDS = new Set(['视觉元素', '视觉焦点', '排版布局', '演讲者备注']);
FILE: frontend/src/pages/History.tsx
constant DEFAULT_PAGE_SIZE (line 75) | const DEFAULT_PAGE_SIZE = 5;
constant PAGE_SIZE_KEY (line 76) | const PAGE_SIZE_KEY = 'history_page_size';
FILE: frontend/src/pages/Home.tsx
type CreationType (line 16) | type CreationType = 'idea' | 'outline' | 'description' | 'ppt_renovation';
FILE: frontend/src/pages/Settings.tsx
type FieldType (line 216) | type FieldType = 'text' | 'password' | 'number' | 'select' | 'buttons' |...
type FieldConfig (line 218) | interface FieldConfig {
type SectionConfig (line 232) | interface SectionConfig {
type TestStatus (line 238) | type TestStatus = 'idle' | 'loading' | 'success' | 'error';
type ServiceTestState (line 240) | interface ServiceTestState {
constant LAZYLLM_SOURCES (line 247) | const LAZYLLM_SOURCES = [
constant ALL_PROVIDER_SOURCES (line 260) | const ALL_PROVIDER_SOURCES = [
constant API_KEY_PROVIDERS (line 267) | const API_KEY_PROVIDERS = new Set(['gemini', 'openai']);
constant LAZYLLM_VENDOR_SET (line 270) | const LAZYLLM_VENDOR_SET = new Set(LAZYLLM_SOURCES.map(s => s.value));
constant SCROLL_SHOW_THRESHOLD (line 1302) | const SCROLL_SHOW_THRESHOLD = 300;
FILE: frontend/src/store/useExportTasksStore.ts
type ExportTaskStatus (line 14) | type ExportTaskStatus = 'PENDING' | 'PROCESSING' | 'RUNNING' | 'COMPLETE...
type ExportTaskType (line 15) | type ExportTaskType = 'pptx' | 'pdf' | 'editable-pptx' | 'images';
type ExportTask (line 17) | interface ExportTask {
type ExportTasksState (line 47) | interface ExportTasksState {
FILE: frontend/src/store/useProjectStore.ts
type ProjectState (line 76) | interface ProjectState {
FILE: frontend/src/types/index.ts
type PageStatus (line 2) | type PageStatus = 'DRAFT' | 'GENERATING_DESCRIPTION' | 'DESCRIPTION_GENE...
type ProjectStatus (line 5) | type ProjectStatus = 'DRAFT' | 'OUTLINE_GENERATED' | 'DESCRIPTIONS_GENER...
type OutlineContent (line 8) | interface OutlineContent {
type DescriptionContent (line 14) | type DescriptionContent =
type ImageVersion (line 30) | interface ImageVersion {
type Page (line 41) | interface Page {
type ExportExtractorMethod (line 57) | type ExportExtractorMethod = 'mineru' | 'hybrid';
type ExportInpaintMethod (line 60) | type ExportInpaintMethod = 'generative' | 'baidu' | 'hybrid';
type Project (line 63) | interface Project {
type TaskStatus (line 88) | type TaskStatus = 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED';
type Task (line 91) | interface Task {
type CreateProjectRequest (line 110) | interface CreateProjectRequest {
type ApiResponse (line 120) | interface ApiResponse<T = any> {
type Settings (line 129) | interface Settings {
FILE: frontend/src/utils/i18nHelper.ts
type NestedRecord (line 3) | type NestedRecord = Record<string, unknown>;
type Translations (line 4) | type Translations = { zh: NestedRecord; en: NestedRecord };
function getNestedValue (line 6) | function getNestedValue(obj: NestedRecord, path: string): string | undef...
function getT (line 22) | function getT<T extends Translations>(translations: T) {
FILE: frontend/src/utils/index.ts
function cn (line 8) | function cn(...inputs: ClassValue[]) {
function normalizeProject (line 15) | function normalizeProject(data: any): Project {
function normalizePage (line 27) | function normalizePage(data: any): Page {
function debounce (line 38) | function debounce<T extends (...args: any[]) => any>(
function throttle (line 52) | function throttle<T extends (...args: any[]) => any>(
function downloadFile (line 69) | function downloadFile(blob: Blob, filename: string) {
function formatDate (line 83) | function formatDate(dateString: string): string {
function generateId (line 99) | function generateId(): string {
function normalizeErrorMessage (line 106) | function normalizeErrorMessage(errorMessage: string | null | undefined):...
FILE: frontend/src/utils/projectUtils.ts
type StatusKey (line 103) | type StatusKey = 'notStarted' | 'completed' | 'pendingImages' | 'pending...
type ExportOptions (line 176) | interface ExportOptions {
type ParsedPage (line 239) | interface ParsedPage {
constant EXTRA_FIELD_RE (line 255) | const EXTRA_FIELD_RE = /^([^\s::]{1,20})[::](.+)/;
FILE: frontend/src/vite-env.d.ts
type ImportMetaEnv (line 3) | interface ImportMetaEnv {
type ImportMeta (line 6) | interface ImportMeta {
FILE: frontend/vite.config.ts
function computeWorktreePort (line 15) | function computeWorktreePort(basePort: number): number {
FILE: scripts/export_editable_pptx.py
function setup_flask_app (line 63) | def setup_flask_app():
function collect_image_paths (line 79) | def collect_image_paths(paths: List[str]) -> List[str]:
function create_service_config (line 102) | def create_service_config(
function export_editable_pptx (line 155) | def export_editable_pptx(
function main (line 277) | def main():
FILE: scripts/translate_readme.py
function translate_readme (line 26) | def translate_readme(source_file: str, target_file: str):
function main (line 116) | def main():
FILE: scripts/translate_readme_incremental.py
function split_by_headers (line 27) | def split_by_headers(content: str) -> List[Tuple[str, str, str]]:
function get_git_diff_lines (line 68) | def get_git_diff_lines(file_path: str) -> set:
function find_changed_blocks (line 108) | def find_changed_blocks(content: str, changed_lines: set) -> set:
function translate_block (line 138) | def translate_block(content: str, text_provider) -> str:
function incremental_translate (line 162) | def incremental_translate(source_file: str, target_file: str, force_full...
function main (line 296) | def main():
FILE: v0_demo/demo.py
function gen_outline (line 11) | def gen_outline(idea_prompt:str)->list[dict]:
function flatten_outline (line 49) | def flatten_outline(outline: list[dict]) -> list[dict]:
function gen_desc (line 65) | def gen_desc(idea_prompt, outline: list[Dict])->list[Dict] :
function gen_outline_text (line 112) | def gen_outline_text(outline: list[Dict]) -> str:
function gen_prompts (line 124) | def gen_prompts(outline: list[Dict], desc: list[str]) -> list[str]:
function gen_images_parallel (line 154) | def gen_images_parallel(prompts: list[str], ref_image: str, output_dir: ...
function create_pptx_from_images (line 192) | def create_pptx_from_images(input_dir: str = "output", output_file: str ...
function gen_ppt (line 244) | def gen_ppt(idea_prompt, ref_image):
FILE: v0_demo/gemini_genai.py
function gen_image (line 19) | def gen_image(prompt: str, ref_image_path: str, aspect_ratio: str = DEFA...
function gen_json_text (line 47) | def gen_json_text(prompt: str, model: str = "gemini-3-flash-preview") ->...
function gen_text (line 61) | def gen_text(prompt: str, model: str = "gemini-3-flash-preview") -> str:
FILE: v0_demo/lazyllm_genai.py
function gen_text (line 56) | def gen_text(prompt: str,
function gen_json_text (line 68) | def gen_json_text(prompt: str,
function gen_image (line 79) | def gen_image(prompt: str,
function describe_image (line 133) | def describe_image(image_path: str,
Condensed preview — 333 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (2,512K chars).
[
{
"path": ".dockerignore",
"chars": 906,
"preview": "# 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**"
},
{
"path": ".githooks/pre-commit.disabled",
"chars": 1255,
"preview": "#!/bin/bash\n# Pre-commit hook: 自动翻译README.md到README_EN.md\n# 只有在README.md被修改时才会触发\n\nset -e\n\n# 检查README.md是否在本次提交中被修改\nif gi"
},
{
"path": ".github/CI_SETUP.md",
"chars": 7273,
"preview": "# CI/CD 配置说明\n\n本项目使用GitHub Actions实现自动化CI/CD,包含**Light检查**和**Full测试**两个层级。\n\n## 📋 CI架构概览\n\n### 🚀 Light检查 - PR快速反馈\n**触发时机**:"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.yml",
"chars": 1792,
"preview": "name: Bug Report / 问题反馈\ndescription: Report a bug or issue / 报告错误或问题\nlabels: [\"bug\"]\nbody:\n - type: dropdown\n id: de"
},
{
"path": ".github/PULL_REQUEST_TEMPLATE.md",
"chars": 340,
"preview": "## Summary\n\n<!-- Briefly describe the changes in this PR -->\n\n## Changed Files\n\n<!-- List the key files changed and what"
},
{
"path": ".github/workflows/build-sha-image.yml",
"chars": 6070,
"preview": "name: Build SHA Image\n\non:\n workflow_dispatch:\n inputs:\n sha:\n description: 'Git SHA to build (full or s"
},
{
"path": ".github/workflows/ci-test.yml",
"chars": 1914,
"preview": "name: CI Tests\n\n# Push to main/develop (excluding docs-only changes) or manual trigger\non:\n push:\n branches: [ main,"
},
{
"path": ".github/workflows/docker-publish.yml",
"chars": 10217,
"preview": "name: Release\n\n# 手动触发,输入版本号\non:\n workflow_dispatch:\n inputs:\n version:\n description: 'Release version (e"
},
{
"path": ".github/workflows/nightly.yml",
"chars": 6062,
"preview": "name: Nightly\n\n# Every day at 03:00 UTC, or manual trigger\non:\n schedule:\n - cron: '0 3 * * *'\n workflow_dispatch:\n"
},
{
"path": ".github/workflows/pr-quick-check.yml",
"chars": 3525,
"preview": "name: PR Quick Check\n\non:\n pull_request:\n branches: [ main, develop ]\n\n# Quick check: lint + unit tests + build + sm"
},
{
"path": ".github/workflows/translate-readme.yml",
"chars": 2225,
"preview": "name: Auto Translate README\n\n# 当主分支的 README.md 改动时自动翻译到 README_EN.md\n\non:\n push:\n branches:\n - main\n paths:\n"
},
{
"path": ".gitignore",
"chars": 824,
"preview": "*.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"
},
{
"path": "CLA.md",
"chars": 3698,
"preview": "# Banana-slides Contributor License Agreement\n\nThank you for your interest in contributing to Banana-slides (\"Project\")."
},
{
"path": "CONTRIBUTING.md",
"chars": 3011,
"preview": "# Contributing to Banana-slides\n\nThank you for your interest in contributing to Banana-slides! We welcome contributions "
},
{
"path": "Dockerfile.allinone",
"chars": 2089,
"preview": "# 镜像源配置参数(可通过 build args 覆盖)\nARG DOCKER_REGISTRY=\nARG GHCR_REGISTRY=ghcr.io/\nARG NPM_REGISTRY=\nARG APT_MIRROR=\nARG PYPI_"
},
{
"path": "LICENSE",
"chars": 34523,
"preview": " GNU AFFERO GENERAL PUBLIC LICENSE\n Version 3, 19 November 2007\n\n Copyright (C)"
},
{
"path": "README.md",
"chars": 20396,
"preview": "<div align=\"center\">\n<img src=\"https://github.com/user-attachments/assets/81fe6816-44cc-4c61-97c7-f3c099650966\" alt=\"ban"
},
{
"path": "backend/.gitignore",
"chars": 352,
"preview": "# Python\n__pycache__/\n*.py[cod]\n*$py.class\n*.so\n.Python\nenv/\nvenv/\nENV/\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.egg"
},
{
"path": "backend/Dockerfile",
"chars": 1801,
"preview": "# 镜像源配置参数(可通过 build args 覆盖)\nARG DOCKER_REGISTRY=\nARG GHCR_REGISTRY=ghcr.io/\nARG APT_MIRROR=\nARG PYPI_INDEX_URL=\n\n# 安装 u"
},
{
"path": "backend/README.md",
"chars": 6669,
"preview": "# Banana Slides Backend\n\n蕉幻(Banana Slides)后端服务 - AI驱动的PPT生成系统\n\n## 技术栈\n\n- **框架**: Flask 3.0\n- **数据库**: SQLite + SQLAlchem"
},
{
"path": "backend/alembic.ini",
"chars": 551,
"preview": "[alembic]\nscript_location = migrations\nsqlalchemy.url = sqlite:///placeholder.db\n\n[loggers]\nkeys = root,sqlalchemy,alemb"
},
{
"path": "backend/app.py",
"chars": 15663,
"preview": "\"\"\"\nSimplified Flask Application Entry Point\n\"\"\"\nimport os\nimport sys\nimport hmac\nimport logging\nfrom pathlib import Pat"
},
{
"path": "backend/config.py",
"chars": 4965,
"preview": "\"\"\"\nBackend configuration file\n\"\"\"\nimport os\nimport sys\nfrom datetime import timedelta\n\n# 基础配置 - 使用更可靠的路径计算方式\n# 在模块加载时立即"
},
{
"path": "backend/controllers/__init__.py",
"chars": 486,
"preview": "\"\"\"Controllers package\"\"\"\nfrom .project_controller import project_bp, style_bp\nfrom .page_controller import page_bp\nfrom"
},
{
"path": "backend/controllers/export_controller.py",
"chars": 13765,
"preview": "\"\"\"\nExport Controller - handles file export endpoints\n\"\"\"\nimport logging\nimport os\nimport io\nimport shutil\nimport time\ni"
},
{
"path": "backend/controllers/file_controller.py",
"chars": 5340,
"preview": "\"\"\"\nFile Controller - handles static file serving\n\"\"\"\nfrom flask import Blueprint, send_from_directory, current_app\nfrom"
},
{
"path": "backend/controllers/material_controller.py",
"chars": 20133,
"preview": "\"\"\"\nMaterial Controller - handles standalone material image generation\n\"\"\"\nfrom flask import Blueprint, request, current"
},
{
"path": "backend/controllers/page_controller.py",
"chars": 30136,
"preview": "\"\"\"\nPage Controller - handles page-related endpoints\n\"\"\"\nimport logging\nfrom flask import Blueprint, request, current_ap"
},
{
"path": "backend/controllers/project_controller.py",
"chars": 60573,
"preview": "\"\"\"\nProject Controller - handles project-related endpoints\n\"\"\"\nimport json\nimport logging\nimport os\nimport subprocess\nim"
},
{
"path": "backend/controllers/reference_file_controller.py",
"chars": 16654,
"preview": "\"\"\"\nReference File Controller - handles file upload and parsing\n\"\"\"\nimport os\nimport logging\nimport re\nimport uuid\nfrom "
},
{
"path": "backend/controllers/settings_controller.py",
"chars": 46128,
"preview": "\"\"\"Settings Controller - handles application settings endpoints\"\"\"\n\nimport json\nimport logging\nimport os\nimport shutil\ni"
},
{
"path": "backend/controllers/template_controller.py",
"chars": 6901,
"preview": "\"\"\"\nTemplate Controller - handles template-related endpoints\n\"\"\"\nimport logging\nfrom flask import Blueprint, request, cu"
},
{
"path": "backend/migrations/env.py",
"chars": 1782,
"preview": "import os\nimport sys\nfrom logging.config import fileConfig\n\nfrom alembic import context\nfrom sqlalchemy import engine_fr"
},
{
"path": "backend/migrations/script.py.mako",
"chars": 513,
"preview": "\"\"\"${message}\n\nRevision ID: ${up_revision}\nRevises: ${down_revision | comma,n}\nCreate Date: ${create_date}\n\n\"\"\"\nfrom ale"
},
{
"path": "backend/migrations/versions/001_baseline_schema.py",
"chars": 5800,
"preview": "\"\"\"baseline schema - core tables only\n\nRevision ID: 001_baseline\nRevises: \nCreate Date: 2025-12-17 22:00:00.000000\n\n\"\"\"\n"
},
{
"path": "backend/migrations/versions/002_create_settings_table.py",
"chars": 1571,
"preview": "\"\"\"create settings table\n\nRevision ID: 002_settings\nRevises: 001_baseline\nCreate Date: 2025-12-17 22:01:00.000000\n\n\"\"\"\nf"
},
{
"path": "backend/migrations/versions/003_add_model_and_mineru_settings.py",
"chars": 1827,
"preview": "\"\"\"add model and mineru settings to settings table\n\nRevision ID: 003_new_fields\nRevises: 002_settings\nCreate Date: 2025-"
},
{
"path": "backend/migrations/versions/004_add_template_style_to_projects.py",
"chars": 797,
"preview": "\"\"\"add template_style to projects\n\nRevision ID: 004_add_template_style\nRevises: 38292967f3ca\nCreate Date: 2025-12-27 00:"
},
{
"path": "backend/migrations/versions/005_add_pdf_image_path.py",
"chars": 789,
"preview": "\"\"\"add pdf_image_path placeholder (migration file was lost)\n\nRevision ID: 005_add_pdf_image_path\nRevises: 004_add_templa"
},
{
"path": "backend/migrations/versions/006_add_export_settings_to_projects.py",
"chars": 1220,
"preview": "\"\"\"add export settings to projects\n\nRevision ID: 006_add_export_settings\nRevises: 005_add_pdf_image_path\nCreate Date: 20"
},
{
"path": "backend/migrations/versions/007_add_enable_reasoning_to_settings.py",
"chars": 1390,
"preview": "\"\"\"add enable_reasoning to settings\n\nRevision ID: 007_add_enable_reasoning\nRevises: 006_add_export_settings\nCreate Date:"
},
{
"path": "backend/migrations/versions/008_add_baidu_ocr_api_key_to_settings.py",
"chars": 1240,
"preview": "\"\"\"add baidu_ocr_api_key to settings\n\nRevision ID: 008_add_baidu_ocr_api_key\nRevises: 007_add_enable_reasoning\nCreate Da"
},
{
"path": "backend/migrations/versions/009_split_reasoning_config.py",
"chars": 3491,
"preview": "\"\"\"split reasoning config into text and image\n\nRevision ID: 009_split_reasoning_config\nRevises: 007_add_enable_reasoning"
},
{
"path": "backend/migrations/versions/010_add_cached_image_path.py",
"chars": 559,
"preview": "\"\"\"add cached_image_path to pages\n\nRevision ID: 010_add_cached_image_path\nRevises: 009_split_reasoning_config\nCreate Dat"
},
{
"path": "backend/migrations/versions/011_add_user_template_thumb.py",
"chars": 7371,
"preview": "\"\"\"Add thumb_path to user_templates table\n\nRevision ID: 011_add_user_template_thumb\nRevises: 010_add_cached_image_path\nC"
},
{
"path": "backend/migrations/versions/012_add_export_allow_partial_to_projects.py",
"chars": 718,
"preview": "\"\"\"add export_allow_partial to projects table\n\nRevision ID: 012\nRevises: 011_add_user_template_thumb\nCreate Date: 2025-0"
},
{
"path": "backend/migrations/versions/013_add_lazyllm_source_fields.py",
"chars": 901,
"preview": "\"\"\"add lazyllm source fields to settings table\n\nRevision ID: 013\nRevises: 012\nCreate Date: 2026-02-13\n\n\"\"\"\nfrom alembic "
},
{
"path": "backend/migrations/versions/014_add_per_model_provider_config.py",
"chars": 1211,
"preview": "\"\"\"add per-model provider config fields to settings table\n\nRevision ID: 014\nRevises: 013\nCreate Date: 2026-02-16\n\n\"\"\"\nfr"
},
{
"path": "backend/migrations/versions/015_rename_baidu_ocr_api_key.py",
"chars": 581,
"preview": "\"\"\"rename baidu_ocr_api_key to baidu_api_key\n\nRevision ID: 015\nRevises: 7acf21d5e41d\nCreate Date: 2026-02-26\n\n\"\"\"\nfrom a"
},
{
"path": "backend/migrations/versions/38292967f3ca_add_output_language_to_settings_table.py",
"chars": 1245,
"preview": "\"\"\"add output_language to settings table\n\nRevision ID: 38292967f3ca\nRevises: a912a64b7a86\nCreate Date: 2025-12-17 22:26:"
},
{
"path": "backend/migrations/versions/64ecc9f34de0_add_description_generation_mode_to_.py",
"chars": 755,
"preview": "\"\"\"add description_generation_mode to settings\n\nRevision ID: 64ecc9f34de0\nRevises: 88054bda1ece\nCreate Date: 2026-03-01 "
},
{
"path": "backend/migrations/versions/7acf21d5e41d_make_settings_columns_nullable_for_env_.py",
"chars": 1922,
"preview": "\"\"\"make settings columns nullable for env fallback\n\nRevision ID: 7acf21d5e41d\nRevises: 014\nCreate Date: 2026-02-23 14:22"
},
{
"path": "backend/migrations/versions/88054bda1ece_add_outline_and_description_.py",
"chars": 875,
"preview": "\"\"\"add outline and description requirements to projects\n\nRevision ID: 88054bda1ece\nRevises: 015\nCreate Date: 2026-03-01 "
},
{
"path": "backend/migrations/versions/9439faddcdd5_add_description_extra_fields_to_settings.py",
"chars": 735,
"preview": "\"\"\"add description_extra_fields to settings\n\nRevision ID: 9439faddcdd5\nRevises: 64ecc9f34de0\nCreate Date: 2026-03-03 00:"
},
{
"path": "backend/migrations/versions/9ad736fec43d_add_image_prompt_extra_fields_to_.py",
"chars": 738,
"preview": "\"\"\"add image_prompt_extra_fields to settings\n\nRevision ID: 9ad736fec43d\nRevises: 9439faddcdd5\nCreate Date: 2026-03-04 21"
},
{
"path": "backend/migrations/versions/a912a64b7a86_add_mineru_token_to_settings_table.py",
"chars": 1194,
"preview": "\"\"\"add mineru_token to settings table\n\nRevision ID: a912a64b7a86\nRevises: 003_new_fields\nCreate Date: 2025-12-17 22:07:2"
},
{
"path": "backend/migrations/versions/ee22f1512027_add_image_aspect_ratio_to_project.py",
"chars": 528,
"preview": "\"\"\"add image_aspect_ratio to project\n\nRevision ID: ee22f1512027\nRevises: 013\nCreate Date: 2026-02-14 01:58:15.948064\n\n\"\""
},
{
"path": "backend/models/__init__.py",
"chars": 936,
"preview": "\"\"\"Database models package\"\"\"\nfrom flask_sqlalchemy import SQLAlchemy\n\n# 创建 SQLAlchemy 实例,配置 SQLite 连接选项\ndb = SQLAlchemy"
},
{
"path": "backend/models/material.py",
"chars": 1570,
"preview": "\"\"\"\nMaterial model - stores material images\n\"\"\"\nimport uuid\nfrom datetime import datetime\nfrom . import db\n\n\nclass Mater"
},
{
"path": "backend/models/page.py",
"chars": 3774,
"preview": "\"\"\"\nPage model\n\"\"\"\nimport uuid\nimport json\nfrom pathlib import Path\nfrom datetime import datetime\nfrom . import db\n\n\ncla"
},
{
"path": "backend/models/page_image_version.py",
"chars": 2003,
"preview": "\"\"\"\nPage Image Version model - stores historical versions of generated images\n\"\"\"\nimport uuid\nfrom datetime import datet"
},
{
"path": "backend/models/project.py",
"chars": 4112,
"preview": "\"\"\"\nProject model\n\"\"\"\nimport uuid\nfrom datetime import datetime\nfrom . import db\n\n\nclass Project(db.Model):\n \"\"\"\n "
},
{
"path": "backend/models/reference_file.py",
"chars": 3407,
"preview": "\"\"\"\nReference File model - stores uploaded reference files and their parsed content\n\"\"\"\nimport uuid\nfrom datetime import"
},
{
"path": "backend/models/settings.py",
"chars": 10985,
"preview": "\"\"\"Settings model\"\"\"\nimport json\nfrom datetime import datetime, timezone\nfrom . import db\n\n\nclass Settings(db.Model):\n "
},
{
"path": "backend/models/task.py",
"chars": 2392,
"preview": "\"\"\"\nTask model for tracking async operations\n\"\"\"\nimport uuid\nimport json\nfrom datetime import datetime\nfrom . import db\n"
},
{
"path": "backend/models/user_template.py",
"chars": 1617,
"preview": "\"\"\"\nUser Template model - stores user-uploaded templates\n\"\"\"\nimport uuid\nfrom datetime import datetime\nfrom . import db\n"
},
{
"path": "backend/run.bat",
"chars": 1018,
"preview": "@echo off\nREM Banana Slides Backend Startup Script for Windows\n\necho ╔══════════════════════════════════════╗\necho ║ 🍌"
},
{
"path": "backend/run.sh",
"chars": 995,
"preview": "#!/bin/bash\n\n# Banana Slides Backend Startup Script\n\necho \"╔══════════════════════════════════════╗\"\necho \"║ 🍌 Banana "
},
{
"path": "backend/server.log",
"chars": 7772,
"preview": "Traceback (most recent call last):\n File \"/home/aa/miniconda3/lib/python3.13/site-packages/sqlalchemy/engine/base.py\", "
},
{
"path": "backend/server_running.log",
"chars": 39357,
"preview": "\n ╔══════════════════════════════════════╗\n ║ 🍌 Banana Slides API Server 🍌 ║\n ╚════════════════════════════"
},
{
"path": "backend/services/__init__.py",
"chars": 229,
"preview": "\"\"\"Services package\"\"\"\nfrom .ai_service import AIService, ProjectContext\nfrom .file_service import FileService\nfrom .exp"
},
{
"path": "backend/services/ai_providers/__init__.py",
"chars": 12662,
"preview": "\"\"\"\nAI Providers factory module\n\nProvides factory functions to get the appropriate text/image generation providers\nbased"
},
{
"path": "backend/services/ai_providers/genai_client.py",
"chars": 981,
"preview": "\"\"\"Shared GenAI client factory used by both text and image providers.\"\"\"\n\nimport logging\nfrom google import genai\nfrom g"
},
{
"path": "backend/services/ai_providers/image/__init__.py",
"chars": 497,
"preview": "\"\"\"Image generation providers\"\"\"\nfrom .base import ImageProvider\nfrom .genai_provider import GenAIImageProvider\nfrom .op"
},
{
"path": "backend/services/ai_providers/image/baidu_inpainting_provider.py",
"chars": 7909,
"preview": "\"\"\"\n百度图像修复 Provider\n基于百度AI的图像修复能力,在指定矩形区域去除遮挡物并用背景内容填充\n\nAPI文档: https://ai.baidu.com/ai-doc/IMAGEPROCESS/Mk4i6o3w3\n\"\"\"\nim"
},
{
"path": "backend/services/ai_providers/image/base.py",
"chars": 1178,
"preview": "\"\"\"\nAbstract base class for image generation providers\n\"\"\"\nfrom abc import ABC, abstractmethod\nfrom typing import Option"
},
{
"path": "backend/services/ai_providers/image/gemini_inpainting_provider.py",
"chars": 7471,
"preview": "\"\"\"\nGemini Inpainting 消除服务提供者\n使用 Gemini 2.5 Flash Image Preview 模型进行基于 mask 的图像编辑\n\"\"\"\nimport logging\nfrom typing import "
},
{
"path": "backend/services/ai_providers/image/genai_provider.py",
"chars": 6355,
"preview": "\"\"\"\nGoogle GenAI SDK — image generation provider\n\nOperates in two authentication modes selected at construction time:\n "
},
{
"path": "backend/services/ai_providers/image/lazyllm_provider.py",
"chars": 9306,
"preview": "\"\"\"\nLazyllm framework implementation for image editing and generation\n\nSupport models:\n- qwen-image-edit\n- qwen-image-ed"
},
{
"path": "backend/services/ai_providers/image/openai_provider.py",
"chars": 14285,
"preview": "\"\"\"\nOpenAI SDK implementation for image generation\n\nSupports multiple resolution parameter formats for different OpenAI-"
},
{
"path": "backend/services/ai_providers/image/volcengine_inpainting_provider.py",
"chars": 9160,
"preview": "\"\"\"\n火山引擎 Inpainting 消除服务提供者\n直接HTTP调用,完全绕过SDK限制\n\"\"\"\nimport logging\nimport base64\nimport json\nimport requests\nfrom datetim"
},
{
"path": "backend/services/ai_providers/lazyllm_env.py",
"chars": 1438,
"preview": "\"\"\"Utilities for resolving LazyLLM API keys from vendor-prefixed env vars.\"\"\"\nimport json\nimport os\n\nALLOWED_LAZYLLM_VEN"
},
{
"path": "backend/services/ai_providers/ocr/__init__.py",
"chars": 452,
"preview": "\"\"\"OCR相关的AI Provider\"\"\"\n\nfrom services.ai_providers.ocr.baidu_table_ocr_provider import (\n BaiduTableOCRProvider,\n "
},
{
"path": "backend/services/ai_providers/ocr/baidu_accurate_ocr_provider.py",
"chars": 13354,
"preview": "\"\"\"\n百度通用文字识别(高精度含位置版)OCR Provider\n提供多场景、多语种、高精度的整图文字检测和识别服务,支持返回文字位置信息\n\nAPI文档: https://ai.baidu.com/ai-doc/OCR/1k3h7y3db"
},
{
"path": "backend/services/ai_providers/ocr/baidu_table_ocr_provider.py",
"chars": 9955,
"preview": "\"\"\"\n百度表格识别OCR Provider\n提供基于百度AI的表格识别能力,支持精确到单元格级别的识别\n\nAPI文档: https://ai.baidu.com/ai-doc/OCR/1k3h7y3db\n\"\"\"\nimport loggin"
},
{
"path": "backend/services/ai_providers/text/__init__.py",
"chars": 339,
"preview": "\"\"\"Text generation providers\"\"\"\nfrom .base import TextProvider, strip_think_tags\nfrom .genai_provider import GenAITextPr"
},
{
"path": "backend/services/ai_providers/text/base.py",
"chars": 1249,
"preview": "\"\"\"\nAbstract base class for text generation providers\n\"\"\"\nimport re\nfrom abc import ABC, abstractmethod\nfrom typing impo"
},
{
"path": "backend/services/ai_providers/text/genai_provider.py",
"chars": 5092,
"preview": "\"\"\"\nGoogle GenAI SDK — text generation provider\n\nOperates in two authentication modes selected at construction time:\n *"
},
{
"path": "backend/services/ai_providers/text/lazyllm_provider.py",
"chars": 2105,
"preview": "\"\"\"\nLazyllm framework for text generation\nSupports modes:\n- Qwen\n- Deepseek\n- doubao\n- GLM\n- MINIMAX\n- sensenova\n- ...\n\""
},
{
"path": "backend/services/ai_providers/text/openai_provider.py",
"chars": 3376,
"preview": "\"\"\"\nOpenAI SDK implementation for text generation\n\"\"\"\nimport base64\nimport logging\nfrom typing import Generator\nfrom ope"
},
{
"path": "backend/services/ai_service.py",
"chars": 41541,
"preview": "\"\"\"\nAI Service - handles all AI model interactions\nBased on demo.py and gemini_genai.py\nTODO: use structured output API\n"
},
{
"path": "backend/services/ai_service_manager.py",
"chars": 6753,
"preview": "\"\"\"\nAIService singleton manager for optimizing provider initialization\n\nThis module provides a singleton pattern impleme"
},
{
"path": "backend/services/export_service.py",
"chars": 63345,
"preview": "\"\"\"\nExport Service - handles PPTX and PDF export\nBased on demo.py create_pptx_from_images()\n\"\"\"\nimport math\nimport os\nim"
},
{
"path": "backend/services/file_parser_service.py",
"chars": 35473,
"preview": "\"\"\"\nFile Parser Service - handles file parsing using MinerU service and image captioning\n\"\"\"\nimport os\nimport re\nimport "
},
{
"path": "backend/services/file_service.py",
"chars": 17158,
"preview": "\"\"\"\nFile Service - handles all file operations\n\"\"\"\nimport os\nimport uuid\nfrom pathlib import Path\nfrom typing import Opt"
},
{
"path": "backend/services/image_editability/__init__.py",
"chars": 2966,
"preview": "\"\"\"\n图片可编辑化服务模块\n\n核心设计:\n- 无状态服务 - 线程安全,可并行调用\n- 依赖注入 - 通过配置对象注入所有依赖\n- 单一职责 - 只负责单张图片的可编辑化,批量处理由调用者控制\n\n组件:\n- 数据模型(BBox, Edit"
},
{
"path": "backend/services/image_editability/coordinate_mapper.py",
"chars": 1913,
"preview": "\"\"\"\n坐标映射工具 - 处理父子图片间的坐标转换\n\"\"\"\nfrom typing import Tuple\nfrom .data_models import BBox\n\n\nclass CoordinateMapper:\n \"\"\"坐标"
},
{
"path": "backend/services/image_editability/data_models.py",
"chars": 3539,
"preview": "\"\"\"\n数据模型 - 图片可编辑化服务的核心数据结构\n\"\"\"\nfrom typing import Dict, Any, List, Optional, Tuple\nfrom dataclasses import dataclass, fi"
},
{
"path": "backend/services/image_editability/extractors.py",
"chars": 32246,
"preview": "\"\"\"\n元素提取器 - 抽象不同的元素识别方法\n\n包含:\n- ElementExtractor: 提取器抽象接口\n- MinerUElementExtractor: MinerU版面分析提取器\n- BaiduOCRElementExtrac"
},
{
"path": "backend/services/image_editability/factories.py",
"chars": 26598,
"preview": "\"\"\"\n工厂类 - 负责创建和配置具体的提取器和Inpaint提供者\n\"\"\"\nimport logging\nfrom typing import List, Optional, Any\nfrom pathlib import Path\n\nf"
},
{
"path": "backend/services/image_editability/helpers.py",
"chars": 2661,
"preview": "\"\"\"\n辅助函数和工具方法\n\n纯函数,不依赖任何具体实现\n\"\"\"\nimport logging\nimport tempfile\nfrom typing import List\nfrom PIL import Image\n\nfrom .dat"
},
{
"path": "backend/services/image_editability/hybrid_extractor.py",
"chars": 16654,
"preview": "\"\"\"\n混合元素提取器 - 结合MinerU版面分析和百度高精度OCR的提取策略\n\n工作流程:\n1. MinerU和百度OCR并行识别(提升速度)\n2. 结果合并:\n - 图片类型bbox里包含的百度OCR bbox → 删除百度OCR"
},
{
"path": "backend/services/image_editability/inpaint_providers.py",
"chars": 19408,
"preview": "\"\"\"\nInpaint提供者 - 抽象不同的inpaint实现\n\n提供多种重绘方法:\n1. DefaultInpaintProvider - 基于mask的精确区域重绘(使用Volcengine Inpainting服务)\n2. Gener"
},
{
"path": "backend/services/image_editability/service.py",
"chars": 15932,
"preview": "\"\"\"\n图片可编辑化服务 - 核心服务类\n\n设计原则:\n1. 无状态设计 - 线程安全,可并行调用\n2. 单一职责 - 只负责单张图片的可编辑化\n3. 依赖注入 - 通过配置对象注入所有依赖\n4. 零具体实现依赖 - 完全依赖抽象接口\n\"\""
},
{
"path": "backend/services/image_editability/text_attribute_extractors.py",
"chars": 22747,
"preview": "\"\"\"\n文字属性提取器 - 从文字区域图像中提取文字的视觉属性\n\n包含:\n- TextStyleResult: 文字样式数据结构\n- TextAttributeExtractor: 提取器抽象接口\n- CaptionModelTextAtt"
},
{
"path": "backend/services/inpainting_service.py",
"chars": 10276,
"preview": "\"\"\"\nInpainting 服务\n提供基于多种 provider 的图像区域消除和背景重新生成功能\n支持的 provider:\n- volcengine: 火山引擎 Inpainting\n- gemini: Google Gemini 2"
},
{
"path": "backend/services/pdf_service.py",
"chars": 1015,
"preview": "\"\"\"\nPDF Service - PDF splitting utilities using PyPDF2\n\"\"\"\nimport logging\nimport os\nfrom typing import List\nfrom PyPDF2 "
},
{
"path": "backend/services/prompts.py",
"chars": 34981,
"preview": "\"\"\"\nAI Service Prompts - 集中管理所有 AI 服务的 prompt 模板\n\n分区:\n 1. 共享工具 & 常量 — 语言配置、格式化辅助、DRY 常量\n 2. 大纲 Prompts — 生成、解"
},
{
"path": "backend/services/task_manager.py",
"chars": 57040,
"preview": "\"\"\"\nTask Manager - handles background tasks using ThreadPoolExecutor\nNo need for Celery or Redis, uses in-memory task tr"
},
{
"path": "backend/tests/conftest.py",
"chars": 4475,
"preview": "\"\"\"\npytest配置文件 - 提供测试fixtures和配置\n\n用于后端所有测试的共享配置和fixtures\n\"\"\"\n\nimport os\nimport sys\nimport pytest\nimport tempfile\nfrom pa"
},
{
"path": "backend/tests/integration/README.md",
"chars": 3469,
"preview": "# Backend Integration Tests\n\n## 测试分类\n\n### 1. Flask Test Client 测试(不需要运行服务)\n**文件**: `test_full_workflow.py`\n\n这些测试使用 Flask"
},
{
"path": "backend/tests/integration/__init__.py",
"chars": 12,
"preview": "# 后端集成测试模块\n\n"
},
{
"path": "backend/tests/integration/test_api_full_flow.py",
"chars": 14529,
"preview": "\"\"\"\nAPI Full Flow Integration Test\n\nThis test validates the complete API flow without UI:\n1. Create project from idea\n2."
},
{
"path": "backend/tests/integration/test_full_workflow.py",
"chars": 3812,
"preview": "\"\"\"\n完整工作流集成测试\n\n测试从创建项目到导出PPTX的完整流程\n\"\"\"\n\nimport pytest\nimport time\nfrom conftest import assert_success_response\n\n\nclass T"
},
{
"path": "backend/tests/pytest.ini",
"chars": 663,
"preview": "[pytest]\n# pytest配置文件\n\n# 测试文件匹配模式\npython_files = test_*.py\npython_classes = Test*\npython_functions = test_*\n\n# 测试目录\ntest"
},
{
"path": "backend/tests/unit/__init__.py",
"chars": 12,
"preview": "# 后端单元测试模块\n\n"
},
{
"path": "backend/tests/unit/test_ai_mock.py",
"chars": 1838,
"preview": "\"\"\"\nAI服务Mock测试\n\n验证AI服务被正确mock,不会真正调用外部API\n\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock\n\n\nclass TestAIMo"
},
{
"path": "backend/tests/unit/test_api_health.py",
"chars": 641,
"preview": "\"\"\"\n健康检查API单元测试\n\"\"\"\n\nimport pytest\n\n\nclass TestHealthEndpoint:\n \"\"\"健康检查端点测试\"\"\"\n \n def test_health_check_returns"
},
{
"path": "backend/tests/unit/test_api_material.py",
"chars": 7092,
"preview": "\"\"\"\nMaterial upload API tests - including caption generation\n\"\"\"\nimport io\nimport pytest\nfrom unittest.mock import patch"
},
{
"path": "backend/tests/unit/test_api_project.py",
"chars": 3643,
"preview": "\"\"\"\n项目管理API单元测试\n\"\"\"\n\nimport pytest\nfrom conftest import assert_success_response, assert_error_response\n\n\nclass TestProje"
},
{
"path": "backend/tests/unit/test_api_settings_provider.py",
"chars": 2621,
"preview": "\"\"\"\nSettings controller tests for provider format handling.\n\"\"\"\n\nfrom types import SimpleNamespace\nfrom unittest.mock im"
},
{
"path": "backend/tests/unit/test_editable_pptx_style_extraction.py",
"chars": 2626,
"preview": "from pathlib import Path\n\nfrom services.export_service import ExportError, ExportService\nfrom services.image_editability"
},
{
"path": "backend/tests/unit/test_file_parser_service.py",
"chars": 2802,
"preview": "\"\"\"\nUnit tests for FileParserService provider-specific behavior.\n\"\"\"\n\nimport os\nimport tempfile\nfrom pathlib import Path"
},
{
"path": "backend/tests/unit/test_image_prompt_ratio.py",
"chars": 1041,
"preview": "\"\"\"Test that image generation prompt uses the correct aspect ratio.\"\"\"\nfrom services.prompts import get_image_generation"
},
{
"path": "backend/tests/unit/test_lazyllm_image_content_type.py",
"chars": 4082,
"preview": "\"\"\"\nUnit tests for LazyLLM image provider content-type fallback.\n\nVerifies that when LazyLLM raises a content-type error"
},
{
"path": "backend/tests/unit/test_smart_merge.py",
"chars": 6308,
"preview": "\"\"\"Test _smart_merge_pages position-based logic with a minimal Flask app.\"\"\"\nimport json\nimport os\nimport sys\nimport tem"
},
{
"path": "backend/utils/__init__.py",
"chars": 968,
"preview": "\"\"\"Utils package\"\"\"\nfrom .response import (\n success_response, \n error_response, \n bad_request, \n not_found,"
},
{
"path": "backend/utils/image_utils.py",
"chars": 783,
"preview": "\"\"\"\nImage utility functions\n\"\"\"\nfrom typing import Tuple\nfrom PIL import Image\n\n\ndef check_image_resolution(image: Image"
},
{
"path": "backend/utils/latex_utils.py",
"chars": 6617,
"preview": "\"\"\"\nLaTeX 工具模块 - 处理 LaTeX 公式转换\n\n提供以下功能:\n1. 简单 LaTeX 转文本(转义字符、简单符号)\n2. LaTeX 转 MathML\n3. MathML 转 OMML(用于 PPTX)\n\"\"\"\nimpor"
},
{
"path": "backend/utils/mask_utils.py",
"chars": 13579,
"preview": "\"\"\"\n掩码图像生成工具\n用于从边界框(bbox)生成黑白掩码图像\n\"\"\"\nimport logging\nfrom typing import List, Tuple, Union, Callable\nfrom PIL import Ima"
},
{
"path": "backend/utils/page_utils.py",
"chars": 1642,
"preview": "\"\"\"\nPage utilities - shared helpers for parsing page_ids and fetching pages\n\"\"\"\nfrom typing import List, Optional, Union"
},
{
"path": "backend/utils/path_utils.py",
"chars": 3561,
"preview": "\"\"\"\nPath utilities for handling MinerU file paths and prefix matching\n\"\"\"\nimport os\nimport logging\nfrom pathlib import P"
},
{
"path": "backend/utils/pptx_builder.py",
"chars": 26131,
"preview": "\"\"\"\nPPTX Builder - utilities for creating editable PPTX files\nBased on OpenDCAI/DataFlow-Agent's implementation\n\"\"\"\nimpo"
},
{
"path": "backend/utils/response.py",
"chars": 1729,
"preview": "\"\"\"\nUnified response format utilities\n\"\"\"\nfrom flask import jsonify\nfrom typing import Any, Dict, Optional\n\n\ndef success"
},
{
"path": "backend/utils/validators.py",
"chars": 2776,
"preview": "\"\"\"\nData validation utilities\n\"\"\"\nimport re\nfrom math import gcd\nfrom typing import Set\n\n# --- Aspect ratio validation -"
},
{
"path": "create-test-data.mjs",
"chars": 2567,
"preview": "import fetch from 'node-fetch';\nimport FormData from 'form-data';\nimport fs from 'fs';\nimport path from 'path';\n\nconst B"
},
{
"path": "create-test-data.sh",
"chars": 1392,
"preview": "#!/bin/bash\nBASE_URL=\"http://localhost:5401\"\n\necho \"Creating test projects and attachments...\"\n\n# 创建项目并上传文件\nprojects=(\"产"
},
{
"path": "docker/nginx-allinone.conf",
"chars": 1659,
"preview": "server {\n listen 80;\n server_name localhost;\n root /usr/share/nginx/html;\n index index.html;\n\n client_max"
},
{
"path": "docker/start-backend.sh",
"chars": 120,
"preview": "#!/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",
"chars": 511,
"preview": "[supervisord]\nnodaemon=true\nlogfile=/dev/null\nlogfile_maxbytes=0\npidfile=/tmp/supervisord.pid\n\n[program:backend]\ncommand"
},
{
"path": "docker-compose.allinone.yml",
"chars": 689,
"preview": "services:\n app:\n build:\n context: .\n dockerfile: Dockerfile.allinone\n args:\n DOCKER_REGISTRY: "
},
{
"path": "docker-compose.prod.yml",
"chars": 1354,
"preview": "# 此配置文件用于直接拉取 Docker Hub 上已构建好的镜像\n\nservices:\n backend:\n # 使用预构建的后端镜像\n image: ${DOCKER_IMAGE_BACKEND:-anoinex/bana"
},
{
"path": "docker-compose.yml",
"chars": 1692,
"preview": "services:\n backend:\n build:\n context: .\n dockerfile: backend/Dockerfile\n args:\n DOCKER_REGISTR"
},
{
"path": "docs/configuration.mdx",
"chars": 2777,
"preview": "---\ntitle: \"Configuration\"\ndescription: \"Environment variables and provider setup\"\n---\n\n## AI Provider\n\nSet `AI_PROVIDER"
},
{
"path": "docs/docs.json",
"chars": 1917,
"preview": "{\n \"$schema\": \"https://mintlify.com/docs.json\",\n \"theme\": \"mint\",\n \"name\": \"Banana Slides\",\n \"colors\": {\n \"primar"
},
{
"path": "docs/faq.mdx",
"chars": 4732,
"preview": "---\ntitle: \"FAQ\"\ndescription: \"Frequently asked questions\"\n---\n\n## Generated text is garbled or blurry\n\n- Check if you'r"
},
{
"path": "docs/features/creation.mdx",
"chars": 3804,
"preview": "---\ntitle: \"Creating a Presentation\"\ndescription: \"Four ways to start — pick the one that fits you\"\n---\n\nOn the home pag"
},
{
"path": "docs/features/descriptions.mdx",
"chars": 2587,
"preview": "---\ntitle: \"Write Descriptions\"\ndescription: \"Generate or write detailed visual descriptions for each slide\"\n---\n\nThe De"
},
{
"path": "docs/features/editing.mdx",
"chars": 1748,
"preview": "---\ntitle: \"Editing Slides\"\ndescription: \"Modify presentations with natural language\"\n---\n\n## Natural Language Editing\n\n"
},
{
"path": "docs/features/export.mdx",
"chars": 1434,
"preview": "---\ntitle: \"Export Options\"\ndescription: \"Export presentations as PPTX, PDF, or images\"\n---\n\n## Formats\n\n| Format | Desc"
},
{
"path": "docs/features/images.mdx",
"chars": 3238,
"preview": "---\ntitle: \"Generate & Refine Images\"\ndescription: \"Generate slide images and fine-tune them page by page\"\n---\n\nThe Slid"
},
{
"path": "docs/features/import-export.mdx",
"chars": 2891,
"preview": "---\ntitle: \"Import & Export Format\"\ndescription: \"Markdown format for importing and exporting outlines and descriptions\""
},
{
"path": "docs/features/materials.mdx",
"chars": 2898,
"preview": "---\ntitle: \"Materials & Files\"\ndescription: \"Upload reference files, paste images, or generate custom materials with AI\""
},
{
"path": "docs/features/outline.mdx",
"chars": 2340,
"preview": "---\ntitle: \"Edit the Outline\"\ndescription: \"Review and adjust the AI-generated outline, or build one from scratch\"\n---\n\n"
},
{
"path": "docs/features/overview.mdx",
"chars": 1854,
"preview": "---\ntitle: \"Features Overview\"\ndescription: \"What Banana Slides can do\"\n---\n\n## The Workflow\n\n```\nCreate Project → Edit "
},
{
"path": "docs/history.mdx",
"chars": 734,
"preview": "---\ntitle: \"Manage Projects\"\ndescription: \"View, rename, and delete past projects\"\n---\n\nClick **History** in the top nav"
},
{
"path": "docs/index.mdx",
"chars": 1635,
"preview": "---\ntitle: \"Banana Slides\"\ndescription: \"AI-native presentation generation — Vibe your slides like vibe coding\"\n---\n\nBan"
},
{
"path": "docs/logo/.gitkeep",
"chars": 0,
"preview": ""
},
{
"path": "docs/quickstart.mdx",
"chars": 2293,
"preview": "---\ntitle: \"Quick Start\"\ndescription: \"Try the demo or deploy Banana Slides locally\"\n---\n\n## Online Demo\n\nNo installatio"
},
{
"path": "docs/zh/configuration.mdx",
"chars": 2063,
"preview": "---\ntitle: \"配置\"\ndescription: \"环境变量与服务商设置\"\n---\n\n## AI 服务商\n\n在 `.env` 中设置 `AI_PROVIDER_FORMAT` 选择服务商:\n\n| 格式 | 说明 |\n|------|"
},
{
"path": "docs/zh/faq.mdx",
"chars": 2046,
"preview": "---\ntitle: \"常见问题\"\ndescription: \"常见问题解答\"\n---\n\n## 生成的文字有乱码或模糊\n\n- 检查是否处于 1K 低分辨率模式。在「**项目设置 → 全局设置**」中切换到 2K 或 4K 分辨率,文字清晰度"
},
{
"path": "docs/zh/features/creation.mdx",
"chars": 1633,
"preview": "---\ntitle: \"创建演示文稿\"\ndescription: \"四种方式开始创作,选一个最适合你的\"\n---\n\n在首页选择创建方式,填写内容,点击「**创建新项目**」。\n\n## 从想法开始(最简单)\n\n只需一句话描述主题,AI 自动完"
},
{
"path": "docs/zh/features/descriptions.mdx",
"chars": 1066,
"preview": "---\ntitle: \"完善描述\"\ndescription: \"为每页幻灯片生成或编写详细的视觉描述\"\n---\n\n描述编辑器是第三步。每页的「描述」告诉 AI 这张幻灯片画什么——包括布局、配色、图表、文字内容。描述越具体,生成效果越好。\n"
},
{
"path": "docs/zh/features/editing.mdx",
"chars": 597,
"preview": "---\ntitle: \"编辑幻灯片\"\ndescription: \"用自然语言修改演示文稿\"\n---\n\n## 自然语言编辑\n\n直接用口头描述修改,无需菜单操作:\n\n- \"把第三页改成案例分析\"\n- \"把这个图换成饼图\"\n- \"背景调暗一点\"\n"
},
{
"path": "docs/zh/features/export.mdx",
"chars": 593,
"preview": "---\ntitle: \"导出选项\"\ndescription: \"导出为 PPTX、PDF 或图片\"\n---\n\n## 格式\n\n| 格式 | 说明 |\n|------|------|\n| PPTX | 幻灯片以图片形式嵌入 PPT,可在 Pow"
},
{
"path": "docs/zh/features/images.mdx",
"chars": 1300,
"preview": "---\ntitle: \"生成与精修图片\"\ndescription: \"生成幻灯片图片,并对单页进行精细调整\"\n---\n\n幻灯片预览是最后一个编辑步骤。在这里生成图片、精修细节、查看历史版本,最后导出。\n\n## 批量生成所有图片\n\n点击「**"
},
{
"path": "docs/zh/features/import-export.mdx",
"chars": 1445,
"preview": "---\ntitle: \"导入导出格式\"\ndescription: \"大纲和描述的 Markdown 导入导出格式说明\"\n---\n\nBanana Slides 使用 Markdown 文件(`.md` / `.txt`)导入导出大纲和页面描述"
},
{
"path": "docs/zh/features/materials.mdx",
"chars": 1237,
"preview": "---\ntitle: \"素材与文件\"\ndescription: \"上传参考文件、粘贴图片,或用 AI 生成专属素材\"\n---\n\n## 上传参考文件\n\n上传文档后,AI 在生成大纲、描述和图片时会参考其中的内容。\n\n### 支持的格式\n\n| "
},
{
"path": "docs/zh/features/outline.mdx",
"chars": 997,
"preview": "---\ntitle: \"编辑大纲\"\ndescription: \"检查、调整 AI 生成的大纲,或从头手动搭建\"\n---\n\n大纲编辑器是第二步。每张幻灯片对应一张卡片,卡片包含**标题**和**要点**,不涉及视觉效果。\n\n## 用 AI 修"
},
{
"path": "docs/zh/features/overview.mdx",
"chars": 880,
"preview": "---\ntitle: \"功能总览\"\ndescription: \"Banana Slides 能做什么\"\n---\n\n## 完整工作流程\n\n```\n创建项目 → 编辑大纲 → 完善描述 → 生成图片 → 导出\n```\n\n每步都可以用 AI 辅助"
},
{
"path": "docs/zh/history.mdx",
"chars": 318,
"preview": "---\ntitle: \"管理项目\"\ndescription: \"查看、重命名和删除历史项目\"\n---\n\n点击顶部导航栏的「**历史项目**」,查看所有已创建的演示文稿。\n\n## 继续编辑项目\n\n点击任意项目卡片,直接跳转到该项目的编辑页面继"
},
{
"path": "docs/zh/index.mdx",
"chars": 1051,
"preview": "---\ntitle: \"Banana Slides\"\ndescription: \"AI 原生演示文稿生成 — Vibe your slides like vibe coding\"\n---\n\nBanana Slides 是一款 AI 原生 P"
},
{
"path": "docs/zh/quickstart.mdx",
"chars": 1765,
"preview": "---\ntitle: \"快速开始\"\ndescription: \"立即体验或在本地部署 Banana Slides\"\n---\n\n## 在线 Demo\n\n无需安装,直接体验:[bananaslides.online](https://banan"
},
{
"path": "frontend/.eslintrc.cjs",
"chars": 580,
"preview": "module.exports = {\n root: true,\n env: { browser: true, es2020: true },\n extends: [\n 'eslint:recommended',\n 'plu"
},
{
"path": "frontend/.gitignore",
"chars": 442,
"preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndis"
},
{
"path": "frontend/Dockerfile",
"chars": 850,
"preview": "# 镜像源配置参数(可通过 build args 覆盖)\nARG DOCKER_REGISTRY=\nARG NPM_REGISTRY=\n\n# 构建阶段\n# 如果指定了 DOCKER_REGISTRY,使用镜像源;否则使用官方源\nFROM $"
},
{
"path": "frontend/README.md",
"chars": 1978,
"preview": "# 蕉幻 (Banana Slides) 前端\n\n这是蕉幻 AI PPT 生成器的前端应用。\n\n## 技术栈\n\n- **框架**: React 18 + TypeScript\n- **构建工具**: Vite\n- **状态管理**: Zus"
},
{
"path": "frontend/e2e/README.md",
"chars": 4584,
"preview": "# E2E 测试说明\n\n## 📋 测试策略\n\n本项目采用**单一真正的 E2E 测试**策略,避免\"伪 E2E\"测试造成混淆。\n\n### 测试金字塔\n\n```\n ┌──────────────────┐\n │ "
},
{
"path": "frontend/e2e/access-code.spec.ts",
"chars": 3865,
"preview": "import { test, expect } from '@playwright/test';\n\n// ===== Mock Tests =====\n\ntest.describe('Access Code Guard (mocked)',"
},
{
"path": "frontend/e2e/aspect-ratio-lock-integration.spec.ts",
"chars": 1287,
"preview": "/**\n * Aspect Ratio Lock - Integration E2E Test\n *\n * Uses real backend to verify aspect ratio lock when project has gen"
},
{
"path": "frontend/e2e/aspect-ratio-lock.spec.ts",
"chars": 3521,
"preview": "/**\n * Aspect Ratio Lock & Help Tooltip - Mock E2E Tests\n *\n * Covers:\n * 1. Aspect ratio buttons disabled when project "
},
{
"path": "frontend/e2e/attachment-sort-filter.spec.ts",
"chars": 7016,
"preview": "import { test, expect } from '@playwright/test';\n\ntest.describe('Attachment Sorting and Filtering', () => {\n const BASE"
},
{
"path": "frontend/e2e/badge-status-after-generation.spec.ts",
"chars": 5150,
"preview": "import { test, expect, Page, Route } from '@playwright/test'\nimport { seedProjectWithImages } from './helpers/seed-proje"
},
{
"path": "frontend/e2e/desc-regeneration-skeleton.spec.ts",
"chars": 5905,
"preview": "/**\n * Mock E2E test: Skeleton stays visible during batch description RE-generation.\n *\n * When re-generating descriptio"
},
{
"path": "frontend/e2e/description-detail-level.spec.ts",
"chars": 5918,
"preview": "/**\n * E2E tests for description detail level selector.\n *\n * Mock tests: verify UI rendering, default selection, click "
},
{
"path": "frontend/e2e/description-no-flicker.spec.ts",
"chars": 5433,
"preview": "/**\n * Mock E2E test: Description cards should not flicker during batch generation.\n *\n * Simulates incremental descript"
},
{
"path": "frontend/e2e/editable-export-failure.spec.ts",
"chars": 4753,
"preview": "import { expect, test } from '@playwright/test';\n\ntest.describe('Editable export failure UI', () => {\n test('shows toas"
},
{
"path": "frontend/e2e/export-aspect-ratio.spec.ts",
"chars": 5531,
"preview": "/**\n * Export Aspect Ratio - Integration E2E Test\n *\n * Verifies that PDF and PPTX exports use the project's aspect rati"
},
{
"path": "frontend/e2e/export-images.spec.ts",
"chars": 7201,
"preview": "/**\n * E2E tests for image export feature.\n *\n * 1. Backend API tests: error case + happy path (single & multi-image exp"
},
{
"path": "frontend/e2e/extract-style-caption.spec.ts",
"chars": 3294,
"preview": "/**\n * E2E tests for extract-style using caption_provider.\n *\n * Mock test: verify frontend handles the extract-style AP"
},
{
"path": "frontend/e2e/failed-file-reselect.spec.ts",
"chars": 5174,
"preview": "/**\n * E2E test: Failed files can be re-selected in selector and re-parsed from card\n */\nimport { test, expect } from '@"
},
{
"path": "frontend/e2e/file-preview-scrollbar.spec.ts",
"chars": 4568,
"preview": "/**\n * E2E test: FilePreviewModal scrollbar fix\n *\n * Verifies that the PDF/file preview modal does not have nested scro"
},
{
"path": "frontend/e2e/generation-fail.spec.ts",
"chars": 6531,
"preview": "import { test, expect, Page } from '@playwright/test'\n\nasync function setupFailureMocks(page: Page, projectId: string, f"
},
{
"path": "frontend/e2e/generation-requirements.spec.ts",
"chars": 10185,
"preview": "import { test, expect } from '@playwright/test'\n\nconst PROJECT_ID = 'mock-gen-req-project'\n\nconst mockProject = (overrid"
},
{
"path": "frontend/e2e/helpers/seed-project.ts",
"chars": 3518,
"preview": "/**\n * Shared helper to create projects with real images for E2E testing.\n * Bypasses AI image generation by placing fix"
},
{
"path": "frontend/e2e/history-pagination.spec.ts",
"chars": 8366,
"preview": "/**\n * E2E tests for history page pagination.\n *\n * Mock tests: verify pagination UI renders correctly, page navigation "
},
{
"path": "frontend/e2e/image-prompt-ratio.spec.ts",
"chars": 2353,
"preview": "/**\n * Image Prompt Aspect Ratio - Integration E2E Test\n *\n * Verifies that the project's aspect ratio is correctly stor"
},
{
"path": "frontend/e2e/image-queued-status.spec.ts",
"chars": 9401,
"preview": "import { test, expect, Page } from '@playwright/test'\n\nconst PROJECT_ID = 'queued-status-mock'\nconst PAGE_IDS = ['p-1', "
},
{
"path": "frontend/e2e/import-markdown.spec.ts",
"chars": 9161,
"preview": "/**\n * E2E test: Import outline / description from Markdown files\n */\nimport { test, expect } from '@playwright/test'\nim"
},
{
"path": "frontend/e2e/lazyllm-global-vendor.spec.ts",
"chars": 8997,
"preview": "/**\n * E2E tests for lazyllm global vendor fix.\n *\n * Bug: selecting a lazyllm vendor (e.g., \"doubao\") as global provide"
}
]
// ... and 133 more files (download for full content)
About this extraction
This page contains the full source code of the Anionex/banana-slides GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 333 files (2.3 MB), approximately 617.0k tokens, and a symbol index with 1058 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.