{"repo": "anionex/banana-slides", "files": {"README.md": {"828bf6dfa535083b4fa6f5bed097655847fe26e5": {"t": 1785768258.071873, "n": 19393, "h": "6c93b2b533bd6b39", "c": "[//]: # \"Banana Slides is an AI-native PPT generation app for creating editable presentations from ideas, outlines, documents, images, and custom templates. Features: prompt-to-slide generation, template control, material parsing, conversational editing, PPTX export, project history, and reproducible workflows. Quick Start / Install / Usage / Demo / API / Deploy / Architecture / Test / Screenshot guides are provided for local Docker deployment and online use.\"\n
\n\n

\n \"Banana\n

\n

\n \n \"Anionex%2Fbanana-slides\n \n
\n \n \"Featured|HelloGitHub\"\n \n

\n

\n 简体中文\n  • \n English\n

\n

\n \"GitHub\n \"GitHub\n \"GitHub\n \"Version\"\n \"License\"\n
\n \"Docker\n \"Ask\n

\n\n

\n 一个基于 nano banana pro 🍌 的原生 AI PPT 生成应用
\n 在几分钟内从想法到演示文稿,无需繁琐排版、口头提出修改,迈向真正的 \"Vibe PPT\"\n

\n

\n 🚀 在线 Demo\n  | \n 📖 文档\n  | \n 💻 桌面版 RC2\n  | \n 部署方法\n

\n

\n 如果该项目对你有用,欢迎 Star 🌟 & Fork 🍴\n

\n\n
\n\n## 🔥 最新动态\n- **[2026-07-15]**:自定义大纲/描述要求预设现在会自动修复损坏的浏览器缓存,保留仍然有效的预设,避免异常缓存阻断编辑页面\n- **[2026-07-11]**:0.9.0 候选版本 2 发布,包含 RC1 的全部能力,并修复 Windows 桌面端可编辑 PPTX 的 MinerU 目录不一致、讲解视频 FFprobe 路径错误;[一键下载并安装](https://github.com/Anionex/banana-slides/releases/tag/v0.9.0-rc.2)\n- **[2026-06-23]**:逐页模板上线 — 支持统一模板 / 每页独立模板两种模式,可上传图片或 PDF 构建项目模板库,AI 自动解析模板风格并一键为每页智能匹配,也可逐页手动绑定;两种模式随时双向切换([文档](https://docs.bananaslides.online/zh/features/templates))\n- **[2026-04-25]**: 素材工具箱上线 — 在原有素材生成基础上新增整图编辑、框选编辑(overlay/replace)、智能擦除三种模式,统一入口一站式操作\n- **[2026-04-25]**:支持通过 OpenAI 官方 OAuth 登录绑定账号,绑定后可直接使用 Codex 作为文本/图片生成 provider,无需手动填写 API Key,plus账号五小时可生成100+ 2k图([教程](https://ziy68cvfvu3.feishu.cn/wiki/LDSOwPzkhiNonkkNTF1ct2VBnNc))(基于 OpenAI 官方 OAuth PKCE 授权流程,非逆向)\n- **[2026-04-25]**:支持保存自定义文字风格描述模板,可命名、标色、持久化复用,无需每次重新输入\n- **[2026-04-23]**:支持了gpt-image-2模型,同时导出可编辑背景效果也因模型能力升级得到了提升(在 设置-导出选项-背景获取 选择 生成式获取)\n- **[2026-04-11]**:支持了[cli操作并加入了agent skills](https://docs.bananaslides.online/cli)\n- **[2026-03]**:加入了若干功能和优化,如额外字段、多比例设定等\n- **[2026-02-09]**: 新功能和优化\n * 新功能\n * 支持在首页、大纲、描述卡片里面粘贴图片并立即识别,并提供更好的交互体验\n * 大纲章节手动编辑:支持手动调整页面所属章节(part)。\n * Docker 多架构:镜像支持 amd64 / arm64 构建。\n * 国际化 + 暗黑模式:新增中英文切换;支持亮色/暗色/跟随系统主题;全组件适配暗黑模式。\n * 修复与体验优化\n * 修复导出相关 500、参考文件关联时序、outline/page 数据错位、任务轮询错误项目、描述生成无限轮询、图片预览内存泄漏、批量删除部分失败处理。\n * 优化格式示例提示、HTTP 错误提示文案、Modal 关闭体验、清理旧项目 localStorage、移除首次创建项目冗余提示。\n * 若干其他优化和修复\n\n## ✨ 项目缘起\n你是否也曾陷入这样的困境:明天就要汇报,但PPT还是一片空白;脑中有无数精彩的想法,却被繁琐的排版和设计消磨掉所有热情?\n\n我(们)渴望能快速创作出既专业又具设计感的演示文稿,传统的AI PPT生成app,虽然大体满足“快”这一需求,却还存在以下问题:\n\n- 1️⃣只能选择预设模版,无法灵活调整风格\n- 2️⃣自由度低,多轮改动难以进行 \n- 3️⃣成品观感相似,同质化严重\n- 4️⃣素材质量较低,缺乏针对性\n- 5️⃣图文排版割裂,设计感差\n\n以上这些缺陷,让传统的AI ppt生成器难以同时满足我们“快”和“美”的两大PPT制作需求。即使自称Vibe PPT,但是在我的眼中还远不够“Vibe”。\n\n但是,nano banana🍌模型的出现让一切有了转机。我尝试使用🍌pro进行ppt页面生成,发现生成的结果无论是质量、美感还是一致性,都做的非常好,且几乎能精确渲染prompt要求的所有文字+遵循参考图的风格。那为什么不基于🍌pro,做一个原生的\"Vibe PPT\"应用呢?\n\n## 👨‍💻 适用场景\n\n\n1. **小白**:零门槛快速生成美观PPT,无需设计经验,减少模板选择烦恼\n2. **PPT专业人士**:参考AI生成的布局和图文元素组合,快速获取设计灵感\n3. **教育工作者**:将教学内容快速转换为配图教案PPT,提升课堂效果\n4. **学生**:快速完成作业Pre,把精力专注于内容而非排版美化\n5. **职场人士**:商业提案、产品介绍快速可视化,多场景快速适配\n\n

\n 🎯目标: 降低 PPT 制作门槛,让每个人都能快速创作出美观专业的演示文稿\n

\n\n\n## 🎨 结果案例\n\n\n
\n\n| | |\n|:---:|:---:|\n| \"案例3\" | \"案例2\" |\n| **软件开发最佳实践** | **DeepSeek-V3.2技术展示** |\n| \"案例4\" | \"案例1\" |\n| **预制菜智能产线装备研发和产业化** | **钱的演变:从贝壳到纸币的旅程** |\n\n
\n\n更多可见 使用案例 \n\n\n## 🎯 功能介绍\n\n### 1. 灵活多样的创作路径\n支持**想法**、**大纲**、**页面描述**三种起步方式,满足不同创作习惯。\n- **一句话生成**:输入一个主题,AI 自动生成结构清晰的大纲和逐页内容描述。\n- **自然语言编辑**:支持以 Vibe 形式口头修改大纲或描述(如\"把第三页改成案例分析\"),AI 实时响应调整。\n- **大纲/描述模式**:既可一键批量生成,也可手动调整细节。\n\n\"image\"\n\n\n### 2. 强大的素材解析能力\n- **多格式支持**:上传 PDF/Docx/MD/Txt 等文件,后台自动解析内容。\n- **智能提取**:自动识别文本中的关键点、图片链接和图表信息,为生成提供丰富素材。\n- **图片自动入库**:文档解析出的图片会随参考文件关联项目后自动进入项目素材库,后续可直接复用。\n- **风格参考**:支持上传参考图片或模板,定制 PPT 风格。\n\n\"文件解析与素材处理\"\n\n### 3. \"Vibe\" 式自然语言修改\n不再受限于复杂的菜单按钮,直接通过**自然语言**下达修改指令。\n- **局部重绘**:对不满意的区域进行口头式修改(如\"把这个图换成饼图\")。\n- **整页优化**:基于 nano banana pro🍌 生成高清、风格统一的页面。\n\n\"image\"\n\n\n### 4. 开箱即用的格式导出\n- **多格式支持**:一键导出标准 **PPTX** 或 **PDF** 文件。\n- **播放设置**:导出 PPTX 前可开启页面切换动画,支持淡入淡出等经典效果\n- **完美适配**:默认 16:9 比例,排版无需二次调整,直接演示。\n\n\"image\"\n\"PPT与PDF导出\"\n\n### 5. 可自由编辑的pptx导出(Beta迭代中)\n- **导出图像为高还原度、背景干净的、可自由编辑图像和文字的PPT页面**\n- 相关更新见 https://github.com/Anionex/banana-slides/issues/121\n\"image\"\n\n### 6. 一键导出讲解视频\n- **一键将幻灯片转换为带 AI 语音旁白和字幕的讲解视频(MP4)**\n- AI 自动根据页面描述和内容生成口语化旁白\n- 支持配置多种表达风格、多语言和多种音色\n\n
\n\n**🌟和notebooklm slide deck功能对比**\n| 功能 | notebooklm | 本项目 | \n| --- | --- | --- |\n| 页数上限 | 15页 | **无限制** | \n| 二次编辑 | 提示词修改 | **框选编辑+口头编辑** |\n| 素材添加 | 生成后无法添加 | **生成后自由添加** |\n| 导出格式 | 支持导出为 PDF、(不可编辑图片)pptx | **导出为PDF、(图片or可编辑)pptx、讲解视频** |\n| 水印 | 免费版有水印 | **无水印,自由增删元素** |\n\n> 注:随着新功能添加,对比可能过时\n\n\n\n## 🗺️ 开发计划\n\n| 状态 | 里程碑 |\n| --- | --- |\n| ✅ 已完成 | PPT 单页添加更多素材 |\n| ✅ 已完成 | PPT 单页框选区域Vibe口头编辑 |\n| ✅ 已完成 | 素材模块: 素材生成、上传等 |\n| ✅ 已完成 | 支持多种文件的上传+解析 |\n| ✅ 已完成 | 支持Vibe口头调整大纲和描述 |\n| ✅ 已完成 | 初步支持可编辑版本pptx文件导出 |\n| 🔄 进行中 | 支持多层次、精确抠图的可编辑pptx导出 |\n| 🔄 进行中 | 网络搜索 |\n| 🔄 进行中 | Agent 模式 |\n| ✅ 已完成 | TTS 讲解视频导出(中/英/日多音色、字幕) |\n\n## 📦 使用方法\n\n### (新)使用应用模板一键部署\n这是最简单的方式,无需安装docker或下载项目,创建后可直接进入应用\n\n\n1. 通过雨云一键部署和启动本应用 (带宽大,适合高清图片生成和下载。新用户有免费试用)\n- [图文教程](https://ziy68cvfvu3.feishu.cn/wiki/B5RIwg3OUiCfo9kyadzcR9CInnc?from=from_copylink)\n\n[![通过雨云一键部署](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/7549/anionex_)\n\n2. 敬请期待\n\n\n### 使用 Docker Compose🐳\n通过docker compose快速启动前后端服务。\n\n
\n 📒 Windows/Mac用户说明\n\n如果你使用 **Windows 或 macOS**,请先[安装 **Docker Desktop**](https://docs.docker.com/desktop/setup/install/windows-install/),并确保 Docker 正在运行(Windows 可检查系统托盘图标;macOS 可检查菜单栏图标),然后按文档中的相同步骤操作。\n\n> **提示**:如果遇到问题,Windows 用户请在 Docker Desktop 设置中启用 **WSL 2 后端**(推荐);同时确保端口 **3011** 和 **5011** 未被占用。\n\n
\n\n0. **克隆代码仓库**\n```bash\ngit clone https://github.com/Anionex/banana-slides\ncd banana-slides\n```\n\n1. **配置环境变量**\n\n创建 `.env` 文件(参考 `.env.example`):\n```bash\ncp .env.example .env\n```\n\n**(可选, 也可以启动后在用户界面配置,[教程可点击此处](https://ziy68cvfvu3.feishu.cn/wiki/GiNawdmpiinSRqkGspocqEWAnkh?from=from_copylink ))** 编辑 `.env` 文件,配置必要的环境变量:\n\n
\n点击展开详情\n \n> **项目中大模型接口以AIHubMix平台格式为标准,推荐使用 [AIHubMix(点击此处可直接访问)](https://api.inferera.com/?aff=17EC) 获取API密钥,减小迁移成本**
\n> **友情提示:谷歌nano banana pro模型接口费用较高,请注意调用成本**\n```env\n# AI Provider格式配置 (gemini / openai / volcengine / vertex)\nAI_PROVIDER_FORMAT=gemini\n\n# Gemini 格式配置(当 AI_PROVIDER_FORMAT=gemini 时使用)\nGOOGLE_API_KEY=your-api-key-here\nGOOGLE_API_BASE=https://generativelanguage.googleapis.com\n# 代理示例: https://api.inferera.com/gemini\n\n# OpenAI 格式配置(当 AI_PROVIDER_FORMAT=openai 时使用)\nOPENAI_API_KEY=your-api-key-here\nOPENAI_API_BASE=https://api.openai.com/v1\n# 代理示例: https://api.inferera.com/v1\n\n# 火山方舟 AgentPlans 配置(当 AI_PROVIDER_FORMAT=volcengine 时使用)\nVOLCENGINE_API_KEY=your-volcengine-api-key-here\nVOLCENGINE_API_BASE=https://ark.cn-beijing.volces.com/api/v3\n\n# Vertex AI 配置(AI_PROVIDER_FORMAT=vertex)\n# 需要 GCP 项目和服务账户密钥\n# VERTEX_PROJECT_ID=your-gcp-project-id\n# VERTEX_LOCATION=global\n# GOOGLE_APPLICATION_CREDENTIALS=./gcp-service-account.json\n\n# Lazyllm 格式配置(当 AI_PROVIDER_FORMAT=lazyllm 时使用)\n# 选择文本生成和图片生成使用的厂商\nTEXT_MODEL_SOURCE=deepseek # 文本生成模型厂商\nIMAGE_MODEL_SOURCE=doubao # 图片编辑模型厂商\nIMAGE_CAPTION_MODEL_SOURCE=qwen # 图片描述模型厂商\n\n# 各厂商 API Key(只需配置你要使用的厂商)\nDOUBAO_API_KEY=your-doubao-api-key # 火山引擎/豆包\nDEEPSEEK_API_KEY=your-deepseek-api-key # DeepSeek\nQWEN_API_KEY=your-qwen-api-key # 阿里云/通义千问\nGLM_API_KEY=your-glm-api-key # 智谱 GLM\nSILICONFLOW_API_KEY=your-siliconflow-api-key # 硅基流动\nSENSENOVA_API_KEY=your-sensenova-api-key # 商汤日日新\nMINIMAX_API_KEY=your-minimax-api-key # MiniMax\n...\n```\n\n> Banana Slides explicitly packages the LazyLLM online provider SDKs used by domestic vendors:\n> `volcengine-python-sdk[ark]` for Doubao, `dashscope` for Qwen/Wanxiang, and `zhipuai` for GLM/Zhipu.\n> LazyLLM also exposes `lazyllm install online-advanced`, but the PyPI wheel may not publish that group as a standard install extra, so Docker/prebuilt images rely on these explicit dependencies instead.\n \n
\n\n\n**使用新版可编辑导出配置方法,获得更好的可编辑导出效果**: 需在[百度智能云平台](https://console.bce.baidu.com/iam/#/iam/apikey/list)(点击此处进入)中获取API KEY,填写在.env文件中的BAIDU_API_KEY字段(有充足的免费使用额度)。详见https://github.com/Anionex/banana-slides/issues/121 中的说明\n\n\n
\n 📒 Vertex AI 配置指南(适用于 GCP 用户)\n\nGoogle Cloud Vertex AI 允许通过 GCP 服务账户调用 Gemini 模型,新用户可使用赠金额度。配置步骤:\n\n1. 前往 [GCP Console](https://console.cloud.google.com/),创建一个服务账户并下载 JSON 格式的密钥文件\n2. 将密钥文件保存为项目根目录下的 `gcp-service-account.json`\n3. 在 `.env` 中设置:\n ```env\n AI_PROVIDER_FORMAT=vertex\n VERTEX_PROJECT_ID=your-gcp-project-id\n VERTEX_LOCATION=global\n ```\n4. 如果使用 Docker 部署,还需要在 `docker-compose.yml` 中取消相关注释,将密钥文件挂载到容器内并设置 `GOOGLE_APPLICATION_CREDENTIALS` 环境变量。\n\n> `gemini-3-*` 系列模型要求 `VERTEX_LOCATION=global`\n\n
\n\n2. **启动服务**\n\n**⚡ 使用预构建镜像(推荐)**\n\n项目在 Docker Hub 提供了构建好的前端和后端镜像(同步主分支最新版本),可以跳过本地构建步骤,实现快速部署:\n\n```bash\n# 使用预构建镜像启动(无需从头构建)\ndocker compose -f docker-compose.prod.yml up -d\n```\n\n镜像名称:\n- `anoinex/banana-slides-frontend:latest`\n- `anoinex/banana-slides-backend:latest`\n\n启动后可在应用内进入 **设置 → 关于 → 检查更新**,应用会根据当前版本 SHA 判断是否已有可用更新;源码运行时也会用当前 Git SHA 参与判断。\n\n**从头构建镜像**\n\n```bash\ndocker compose up -d\n```\n\n\n> [!TIP]\n> 如遇网络问题,可在 `.env` 文件中取消镜像源配置的注释, 再重新运行启动命令:\n> ```env\n> # 在 .env 文件中取消以下注释即可使用国内镜像源\n> DOCKER_REGISTRY=docker.1ms.run/\n> GHCR_REGISTRY=ghcr.nju.edu.cn/\n> APT_MIRROR=mirrors.aliyun.com\n> PYPI_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple\n> NPM_REGISTRY=https://registry.npmmirror.com/\n> ```\n\n\n3. **访问应用**\n\n- 前端:http://localhost:3011\n- 后端 API:http://localhost:5011\n\n4. **查看日志**\n\n```bash\n# 查看后端日志(最后 200 行)\ndocker logs --tail 200 banana-slides-backend\n\n# 实时查看后端日志(最后 100 行)\ndocker logs -f --tail 100 banana-slides-backend\n\n# 查看前端日志(最后 100 行)\ndocker logs --tail 100 banana-slides-frontend\n```\n\n5. **停止服务**\n\n```bash\ndocker compose down\n```\n\n6. **更新项目**\n\n**使用预构建镜像(docker-compose.prod.yml)**\n\n也可以先在应用内进入 **设置 → 关于 → 检查更新** 查看是否已有新版本。\n\n```bash\ndocker compose -f docker-compose.prod.yml pull\ndocker compose -f docker-compose.prod.yml up -d\n```\n\n**使用本地构建(docker-compose.yml)**\n\n注:若手动修改了代码,该方法不适用,需要先将代码还原到拉取时的版本。\n\n```bash\ngit pull \ndocker compose down\ndocker compose build --no-cache\ndocker compose up -d\n```\n\n**注:感谢优秀开发者朋友 [@ShellMonster](https://github.com/ShellMonster/) 提供了[新人部署教程](https://github.com/ShellMonster/banana-slides/blob/docs-deploy-tutorial/docs/NEWBIE_DEPLOYMENT.md),专为没有任何服务器部署经验的新手设计,可[点击链接](https://github.com/ShellMonster/banana-slides/blob/docs-deploy-tutorial/docs/NEWBIE_DEPLOYMENT.md)查看。**\n\n### 从源码部署\n\n#### 环境要求\n- Python 3.10 或更高版本\n- [uv](https://github.com/astral-sh/uv) - Python 包管理器\n- Node.js 16+ 和 npm\n- [FFmpeg](https://ffmpeg.org/) - 讲解视频导出必需,且必须包含 `libass` / `ass` 字幕滤镜支持\n- 有效的 Google Gemini API 密钥\n- (可选)[LibreOffice](https://www.libreoffice.org/) - 使用「PPT 翻新」功能上传 PPTX 文件时需要,用于将 PPTX 转换为 PDF。**推荐先在本地将 PPTX 转为 PDF 后再上传**,原因:LibreOffice 在服务端渲染时可能因缺少字体(如微软雅黑、Calibri 等)导致排版错位,且无法完整还原部分特效。上传 PDF 文件则不需要 LibreOffice。Docker 用户如仍需在容器内支持 PPTX 上传,可执行:\n ```bash\n docker exec -it banana-slides-backend bash -c \"apt-get update && apt-get install -y libreoffice-impress && rm -rf /var/lib/apt/lists/*\"\n ```\n > 注意:此方式安装的 LibreOffice 在容器重建后会丢失,需重新安装。\n\n#### 后端安装\n\n0. **克隆代码仓库**\n```bash\ngit clone https://github.com/Anionex/banana-slides\ncd banana-slides\n```\n\n1. **安装 uv(如果尚未安装)**\n```bash\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n```\n\n2. **安装依赖**\n\n在项目根目录下运行:\n```bash\n# macOS(Homebrew)\nbrew install ffmpeg-full\nbrew unlink ffmpeg 2>/dev/null || true\nbrew link --overwrite --force ffmpeg-full\n\n# Ubuntu / Debian\nsudo apt-get update\nsudo apt-get install -y ffmpeg libass9\n\n# 然后安装 Python 依赖\nuv sync\n```\n\n这将根据 `pyproject.toml` 自动安装所有依赖。\n\n3. **配置环境变量**\n\n复制环境变量模板:\n```bash\ncp .env.example .env\n# 然后按照前述方法,打开编辑 `.env` 文件,配置你的 API 密钥\n```\n\n#### 前端安装\n\n1. **进入前端目录**\n```bash\ncd frontend\n```\n\n2. **安装依赖**\n```bash\nnpm install\n```\n\n3. **配置API地址**\n\n前端会通过 Vite proxy 自动连接到 `BACKEND_PORT` 指定的后端服务(默认 `http://localhost:5011`)。如需修改,请在项目根目录的 `.env` 中设置 `BACKEND_PORT`。\n\n\n#### 启动后端服务\n> (可选)如果本地已有重要数据,升级前建议先备份数据库: \n> `cp backend/instance/database.db backend/instance/database.db.bak`\n> 备注: 默认配置下,模板、素材、成品都在uploads/文件夹中\n\n```bash\ncd backend\nuv run alembic upgrade head && uv run python app.py\n```\n\n后端服务将在 `http://localhost:5011` 启动。\n\n访问 `http://localhost:5011/health` 验证服务是否正常运行。\n\n#### 启动前端开发服务器\n\n```bash\ncd frontend\nnpm run dev\n```\n\n前端开发服务器将在 `http://localhost:3011` 启动。\n\n打开浏览器访问即可使用应用。\n\n\n## 🛠️ 技术架构\n\n### 前端技术栈\nReact 18 + TypeScript + Vite 5 + Zustand\n\n### 后端技术栈\nPython 3.10+ + Flask 3.0 + uv + SQLite\n\n## 交流群\n\n欢迎在群中提出新功能建议或反馈~\n\n\"image\"\n\n\n\n\n\n\n欢迎关注作者的社交媒体,我会分享有关本项目和有关AI的信息:\n\n

\n \"X\n \"小红书\"\n \"Bilibili\"\n

\n\n\n\n\n\n## **🔧 常见问题**\n可见[官网文档](https://docs.bananaslides.online/zh/faq)\n\n也可以直接到 DeepWiki 提问 \n\"Ask\n\n\n\n## 🤝 贡献指南\n\n欢迎通过\n[Issue](https://github.com/Anionex/banana-slides/issues)\n和\n[Pull Request](https://github.com/Anionex/banana-slides/pulls)\n为本项目贡献力量!\n\n> **重要:** 贡献前请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)\n\n## 📄 许可证\n\n本项目采用 **GNU Affero General Public License v3.0(AGPL-3.0)** 开源,\n可自由用于个人学习、研究、试验、教育或非营利科研活动等非商业用途;\n\n如有疑问或合作意向,可联系: davidyang042@gmail.com\n\n\n\n

🚀 Sponsor / 赞助

\n
\n
\n\n \"AIHubMix\"\n\n

感谢AIHubMix对本项目的赞助

\n
\n\n\n
\n\n \"火山引擎\"\n

感谢火山引擎 赞助了本项目
\n 方舟 Agent Plan 限时2.5折订阅,
点击链接抢购

\n\n
\n\n\n\n\n\n
\n\n
\n\n\"image\"\n\n\n感谢AI火宝对本项目的赞助\n \n
\n\n\n## 致谢\n\n- 项目贡献者们:\n\n[![Contributors](https://contrib.rocks/image?repo=Anionex/banana-slides)](https://github.com/Anionex/banana-slides/graphs/contributors)\n\n- [Linux.do](https://linux.do/): 新的理想型社区\n \n## 赞赏\n\n开源不易🙏如果本项目对你有价值,欢迎请开发者喝杯咖啡☕️\n\n\"image\"\n\n感谢以下朋友对项目的无偿赞助支持:\n> @雅俗共赏、@曹峥、@以年观日、@John、@胡yun星Ethan, @azazo1、@刘聪NLP、@🍟、@苍何、@万瑾、@biubiu、@law、@方源、@寒松Falcon、@刘星宇&小陀螺AIGC\n> 如对赞助列表有疑问,可联系作者\n \n## 📈 项目统计\n\n\n\n \n\n \n\n \n\n \"Star\n\n \n\n\n\n
\n"}}, "docs/zh/configuration.mdx": {"828bf6dfa535083b4fa6f5bed097655847fe26e5": {"t": 1785768258.698153, "n": 2804, "h": "d2b87ba911a2fd83", "c": "---\ntitle: \"配置\"\ndescription: \"环境变量与服务商设置\"\n---\n\n## AI 服务商\n\n在 `.env` 中设置 `AI_PROVIDER_FORMAT` 选择服务商:\n\n| 格式 | 说明 |\n|------|------|\n| `gemini` | Google Gemini API(默认) |\n| `openai` | OpenAI 兼容 API |\n| `volcengine` | 火山方舟 AgentPlans(OpenAI 兼容) |\n| `vertex` | Google Cloud Vertex AI |\n| `lazyllm` | 多厂商国产模型路由 |\n\n## Gemini(默认)\n\n```env\nAI_PROVIDER_FORMAT=gemini\nGOOGLE_API_KEY=your-api-key\nGOOGLE_API_BASE=https://generativelanguage.googleapis.com\n```\n\n\n Gemini API 免费层仅支持文本生成,不支持图片生成。\n\n\n## OpenAI 兼容\n\n```env\nAI_PROVIDER_FORMAT=openai\nOPENAI_API_KEY=your-api-key\nOPENAI_API_BASE=https://api.openai.com/v1\n```\n\n## 火山 AgentPlans\n\n火山方舟 Agent Plan 模型订阅套餐可通过 OpenAI 兼容接口接入。选择设置页里的“火山 AgentPlans”,或在 `.env` 中配置:\n\n```env\nAI_PROVIDER_FORMAT=volcengine\nVOLCENGINE_API_KEY=your-volcengine-api-key\nVOLCENGINE_API_BASE=https://ark.cn-beijing.volces.com/api/v3\n```\n\n也可以继续使用设置页的通用 API Key 字段覆盖以上配置,而 API Base URL 将由系统自动管理。\n\n## Vertex AI\n\n```env\nAI_PROVIDER_FORMAT=vertex\nVERTEX_PROJECT_ID=your-gcp-project-id\nVERTEX_LOCATION=global\nGOOGLE_APPLICATION_CREDENTIALS=./gcp-service-account.json\n```\n\n\n `gemini-3-*` 系列模型需要 `VERTEX_LOCATION=global`。\n\n\n## LazyLLM(多厂商路由)\n\n将请求路由到不同国产 AI 厂商:\n\n```env\nAI_PROVIDER_FORMAT=lazyllm\nTEXT_MODEL_SOURCE=deepseek\nIMAGE_MODEL_SOURCE=doubao\nIMAGE_CAPTION_MODEL_SOURCE=qwen\n```\n\n设置对应厂商的 API Key:\n\n```env\nDOUBAO_API_KEY=your-key # 火山引擎/豆包\nDEEPSEEK_API_KEY=your-key # DeepSeek\nQWEN_API_KEY=your-key # 阿里云/通义千问\nGLM_API_KEY=your-key # 智谱 GLM\nSILICONFLOW_API_KEY=your-key # 硅基流动\nSENSENOVA_API_KEY=your-key # 商汤日日新\nMINIMAX_API_KEY=your-key # MiniMax\n```\n\nBanana Slides 会在运行时依赖中显式打包 LazyLLM 国内在线厂商 SDK:\n`volcengine-python-sdk[ark]`(豆包/火山)、`dashscope`(通义千问/万相)、`zhipuai`(智谱 GLM)。\nLazyLLM 源码提供了 `lazyllm install online-advanced`,但当前 PyPI wheel 不一定把该组发布为标准 extra;Docker 和预构建镜像因此以显式依赖为准。\n\n## AIHubMix(推荐中转)\n\n[AIHubMix](https://api.inferera.com/?aff=17EC) 是推荐的 API 中转服务,同时支持 Gemini 和 OpenAI 两种接口格式,且能稳定进行高并发文生图操作。[点击此处申请 AIHubMix API Key](https://api.inferera.com/?aff=17EC)。\n\n获取 API Key 时,打开 AIHubMix 官网并登录或注册账号,进入 **Console** 控制台,先在左侧 **Account → Top Up** 完成充值;充值后进入 **Develop → API Keys** 页面点击 **Add key** 创建密钥,然后复制到本项目设置页或 `.env`。\n\n```env\nAI_PROVIDER_FORMAT=openai\nOPENAI_API_KEY=your-aihubmix-key\nOPENAI_API_BASE=https://api.inferera.com/v1\n```\n\n## MinerU(PDF 解析)\n\n[MinerU](https://mineru.net) 提供高质量的 PDF 解析服务,用于参考文件上传时的内容提取。[点击此处申请 MinerU Token](https://mineru.net/apiManage/token)。\n\n```env\nMINERU_API_BASE=https://mineru.net\nMINERU_TOKEN=your-mineru-token\n```\n\n## 百度 API Key\n\n配置百度 API Key 以获得更好的可编辑 PPTX 导出效果(有充足免费额度):\n\n```env\nBAIDU_API_KEY=your-baidu-api-key\n```\n\n从百度智能云[申请 IAM API Key](https://console.bce.baidu.com/iam/#/iam/apikey/list)。\n\n## 运行时设置覆盖\n\n以上所有配置也可通过网页设置页面进行配置。通过设置页面配置的参数会存储在数据库中,优先级高于 `.env`。点击\"还原默认设置\"可恢复为 `.env` 中的值。\n"}}, "frontend/src/pages/Settings.tsx": {"828bf6dfa535083b4fa6f5bed097655847fe26e5": {"t": 1785768283.0871956, "n": 101454, "h": "0ac4edcc0f36191b", "c": "import React, { useCallback, useEffect, useRef, useState } from 'react';\nimport { useLocation, useNavigate } from 'react-router-dom';\nimport { useTranslation } from 'react-i18next';\nimport { Home, Key, Image, Zap, Save, RotateCcw, Globe, FileText, Brain, ArrowUp, HelpCircle, Link2, ChevronDown, Volume2, Info, RefreshCw, CheckCircle, Lightbulb } from 'lucide-react';\nimport { useT } from '@/hooks/useT';\nimport { appVersion } from '@/utils/appVersion';\nimport { isDesktop } from '@/utils';\nimport { startOpenAIOAuthMonitor } from '@/utils/openaiOAuthMonitor';\nimport { DataStorageSettings } from '@/components/settings/DataStorageSettings';\n\n// 组件内翻译\nconst settingsI18n = {\n zh: {\n nav: { backToHome: '返回首页' },\n settings: {\n title: \"系统设置\",\n subtitle: \"配置应用的各项参数\",\n sections: {\n appearance: \"外观设置\", language: \"界面语言\", apiConfig: \"默认 API 配置\",\n apiConfigDesc: \"下方模型未单独指定提供商时,将使用此处的配置\",\n modelConfig: \"模型配置\", mineruConfig: \"MinerU 配置\", imageConfig: \"图像生成配置\",\n performanceConfig: \"性能配置\", outputLanguage: \"输出语言设置\",\n textReasoning: \"文本推理模式\", imageReasoning: \"图像推理模式\",\n baiduOcr: \"百度配置\", serviceTest: \"服务测试\", lazyllmConfig: \"LazyLLM 厂商配置\",\n vendorApiKeys: \"厂商 API Key 配置\",\n advancedSettings: \"高级设置\",\n elevenlabs: \"ElevenLabs 语音合成\",\n about: \"关于\"\n },\n about: {\n version: \"当前版本\",\n source: \"GitHub 项目\",\n checkUpdate: \"检查更新\",\n checking: \"检查中...\",\n upToDate: \"您当前已是最新版本\",\n updateAvailable: \"有版本更新:{{version}}\",\n unknown: \"无法判断当前是否为最新版本\",\n failed: \"检查更新失败\",\n resultTitle: \"检查更新结果\",\n close: \"关闭\",\n },\n openaiOAuth: {\n title: \"OpenAI 账号连接\",\n description: \"通过 OAuth 登录 OpenAI 账号,无需手动输入 API Key 即可使用 OpenAI 的模型(如 GPT Image)\",\n loginBtn: \"Login with OpenAI\",\n disconnectBtn: \"断开连接\",\n connected: \"已连接\",\n disconnected: \"未连接\",\n account: \"账号\",\n connecting: \"连接中...\",\n disconnecting: \"断开中...\",\n connectFailed: \"连接失败\",\n popupBlocked: \"登录窗口被浏览器拦截,请允许弹出窗口后重试\",\n connectTimeout: \"登录等待超时,请重试或使用手动回调方式连接\",\n disconnectFailed: \"断开失败\",\n disconnectSuccess: \"已断开 OpenAI 账号\",\n hint: \"连接后,可在上方模型配置中选择 Codex 作为提供商,使用你的 OpenAI 账号额度\",\n availableModels: \"可用模型\",\n selectModel: \"选择模型...\",\n loadingModels: \"正在加载可用模型...\",\n connectFirst: \"请先连接 OpenAI 账号\",\n manualCallbackLabel: \"登录后连接失败?\",\n manualCallbackHint: \"请复制弹窗浏览器地址栏中的完整地址,粘贴到下方即可完成连接\",\n manualCallbackPlaceholder: \"粘贴回调地址...\",\n manualCallbackSubmit: \"提交\",\n manualCallbackSuccess: \"连接成功\",\n callbackPortBusy: \"检测到本机 1455 端口被占用,请登录后复制弹窗地址栏中的完整地址并粘贴到下方。\",\n },\n theme: { label: \"主题模式\", light: \"浅色\", dark: \"深色\", system: \"跟随系统\" },\n language: { label: \"界面语言\", zh: \"中文\", en: \"English\" },\n fields: {\n aiProviderFormat: \"AI 提供商格式\",\n aiProviderFormatDesc: \"选择 API 请求格式,影响后端如何构造和发送请求。保存设置后生效。\",\n openaiFormat: \"OpenAI 格式\", geminiFormat: \"Gemini 格式\", lazyllmFormat: \"LazyLLM 格式\",\n apiBaseUrl: \"API Base URL\", apiBaseUrlPlaceholder: \"https://api.example.com\",\n apiBaseUrlDesc: \"设置大模型提供商 API 的基础 URL\",\n apiKey: \"API Key\", apiKeyPlaceholder: \"输入新的 API Key\",\n apiKeyDesc: \"留空则保持当前设置不变,输入新值则更新\",\n apiKeySet: \"已设置(长度: {{length}})\",\n textModel: \"文本大模型\", textModelPlaceholder: \"留空使用环境变量配置 (如: gemini-3-flash-preview)\",\n textModelDesc: \"用于生成大纲、描述等文本内容的模型名称\",\n imageModel: \"图像生成模型\", imageModelPlaceholder: \"留空使用环境变量配置 (如: imagen-3.0-generate-001)\",\n imageModelDesc: \"用于生成页面图片的模型名称\",\n imageCaptionModel: \"图片识别模型\", imageCaptionModelPlaceholder: \"留空使用环境变量配置 (如: gemini-3-flash-preview)\",\n imageCaptionModelDesc: \"用于识别参考文件中的图片并生成描述\",\n mineruApiBase: \"MinerU API Base\", mineruApiBasePlaceholder: \"留空使用环境变量配置 (如: https://mineru.net)\",\n mineruApiBaseDesc: \"MinerU 服务地址,用于解析参考文件\",\n mineruToken: \"MinerU Token\", mineruTokenPlaceholder: \"输入新的 MinerU Token\",\n mineruTokenDesc: \"留空则保持当前设置不变,输入新值则更新\",\n imageResolution: \"图像清晰度(某些OpenAI格式中转调整该值无效)\",\n imageResolutionDesc: \"更高的清晰度会生成更详细的图像,但需要更长时间\",\n enableImageQualityControl: \"启用质量控制模式\",\n enableImageQualityControlDesc: \"开启后,每张图生成后会先检查乱码文字、低质量插画和描述偏离;不通过会自动重试,最终失败时不会落库为新版本\",\n descriptionGenerationMode: \"描述生成模式\", descriptionGenerationModeDesc: \"流式模式通过一次 AI 调用逐页生成,体验更流畅;并行模式为每页独立调用 AI,速度更快\",\n descriptionGenerationModeStreaming: \"流式\", descriptionGenerationModeParallel: \"并行\",\n maxDescriptionWorkers: \"描述生成最大并发数\", maxDescriptionWorkersDesc: \"并行模式下同时生成描述的最大工作线程数 (1-20),越大速度越快\",\n maxImageWorkers: \"图像生成最大并发数\", maxImageWorkersDesc: \"同时生成图像的最大工作线程数 (1-20),越大速度越快\",\n defaultOutputLanguage: \"默认输出语言\", defaultOutputLanguageDesc: \"AI 生成内容时使用的默认语言\",\n enableTextReasoning: \"启用文本推理\", enableTextReasoningDesc: \"开启后,文本生成(大纲、描述等)会使用 extended thinking 进行深度推理\",\n textThinkingBudget: \"文本思考负载\", textThinkingBudgetDesc: \"文本推理的思考 token 预算 (1-8192),数值越大推理越深入\",\n enableImageReasoning: \"启用图像推理\", enableImageReasoningDesc: \"开启后,图像生成会使用思考链模式,可能获得更好的构图效果\",\n imageThinkingBudget: \"图像思考负载\", imageThinkingBudgetDesc: \"图像推理的思考 token 预算 (1-8192),数值越大推理越深入\",\n baiduOcrApiKey: \"百度 API Key\", baiduOcrApiKeyPlaceholder: \"输入百度 API Key\",\n baiduOcrApiKeyDesc: \"用于可编辑 PPTX 导出时的文字识别功能,留空则保持当前设置不变\",\n elevenLabsEnabled: \"启用 ElevenLabs 语音合成\",\n elevenLabsEnabledDesc: \"开启后,视频导出将使用 ElevenLabs 代替 edge-tts 生成旁白音频,音质更自然\",\n elevenLabsApiKey: \"ElevenLabs API Key\", elevenLabsApiKeyPlaceholder: \"输入 ElevenLabs API Key\",\n elevenLabsApiKeyDesc: \"留空则保持当前设置不变,API Key 可在 ElevenLabs 控制台获取\",\n applyLink: \",请点击此处申请\",\n textModelSource: \"文本模型提供商格式\", textModelSourceDesc: \"选择文本生成使用的提供商格式\", textModelSourcePlaceholder: \"-- 请选择 --\",\n imageModelSource: \"图片模型提供商格式\", imageModelSourceDesc: \"选择图片生成使用的提供商格式\", imageModelSourcePlaceholder: \"-- 请选择 --\",\n imageCaptionModelSource: \"图片识别模型提供商格式\", imageCaptionModelSourceDesc: \"选择图片识别使用的提供商格式\", imageCaptionModelSourcePlaceholder: \"-- 请选择 --\",\n vendorApiKey: \"{{vendor}} API Key\", vendorApiKeyPlaceholder: \"输入 {{vendor}} API Key\",\n vendorApiKeyDesc: \"留空则保持当前设置不变,输入新值则更新\",\n vendorApiKeySet: \"已设置(长度: {{length}})\",\n selectPlaceholder: \"-- 请选择 --\",\n modelProvider: \"提供商\", modelProviderDesc: \"为此模型选择独立的提供商,不选则使用上方默认配置\",\n modelProviderPlaceholder: \"-- 使用默认配置 --\",\n perModelApiBaseUrl: \"API Base URL\", perModelApiBaseUrlPlaceholder: \"留空使用默认 Base URL\",\n perModelApiKey: \"API Key\", perModelApiKeyPlaceholder: \"输入 API Key\",\n perModelApiKeyDesc: \"留空则保持当前设置不变\",\n perModelApiKeySet: \"已设置(长度: {{length}})\",\n imageApiProtocol: \"图片 API 协议\",\n imageApiProtocolDesc: \"选择图片生成使用的 API 路径。自动检测根据模型名判断,也可强制指定\",\n imageApiProtocolAuto: \"自动检测\",\n imageApiProtocolImages: \"images.generate\",\n imageApiProtocolChat: \"chat.completions\",\n },\n apiKeyHelp: {\n title: \"如何获取 API 密钥\",\n step1: \"打开 {{link}},登录或注册账号\",\n step2: \"进入 Console 控制台,先在左侧 Account 区域点击「Top Up」完成充值\",\n step3: \"充值后在左侧 Develop 区域点击「API Keys」\",\n step4: \"在 API Keys 页面点击「Add key」生成新的 API Key,并复制到本页\",\n linkLabel: \"访问 AIHubMix 官网 →\",\n copyLink: \"复制链接\",\n },\n apiKeyTip: { before: \"若需快速配置或稳定高并发生图,可选择 \", linkLabel: \"AIHubMix 申请 API Key\", after: \"\" },\n volcenginePromo: {\n title: \"火山 AgentPlans API Key 配置\",\n body: \"官方活动页正在提供 Agent Plan / Coding Plan 限时折扣、豆包模型资源包和免费 Tokens 领取等活动。\",\n cta: \"点击链接抢购\",\n copy: \"复制链接\",\n guideLink: \"火山 AgentPlans\",\n applyModels: \"一键填写推荐模型\",\n },\n volcengineKeyHelp: {\n title: \"订阅并获取火山 AgentPlans API Key\",\n step1: \"打开官方活动页,订阅\",\n step2: \"进入 Agent Plan 控制台\",\n step3: \"在 Agent Plan 控制台创建专属 API Key\",\n step4: \"回到本页填写 Agent Plan 专属 API Key\",\n },\n doubaoVolcenginePromo: {\n title: \"豆包 / 火山方舟 API Key 配置\",\n body: \"活动提供豆包图像创作模型 5.0、豆包大模型资源包、Agent Plan / Coding Plan 限时折扣和免费 Tokens 领取等活动;\",\n cta: \"点击链接抢购\",\n copy: \"复制链接\",\n guideLink: \"火山引擎官方活动页\",\n applyModels: \"一键填写推荐模型\",\n tokenTitle: \"免费 Tokens 额度领取流程\",\n tokenStep1: \"登录火山引擎账号并打开官方活动页\",\n tokenStep2: \"点击活动页的「立即领取」\",\n tokenStep3: \"进入火山方舟控制台,点击「开通服务」并完成「一键授权」\",\n tokenStep4: \"单个模型可领取 50 万 Tokens,累计额度以活动页展示为准;调用时仍需填写普通方舟 API Key\",\n },\n doubaoKeyHelp: {\n title: \"领取额度并获取普通方舟 API Key\",\n step1: \"打开官方活动页,注册或登录火山引擎账号\",\n step2: \"需要免费 Tokens 时,点击活动页的「立即领取」,进入火山方舟控制台完成「开通服务」和「一键授权」\",\n step3: \"在火山方舟控制台开通需要调用的豆包模型,并前往 API Key 管理页面创建普通方舟 API Key\",\n step4: \"回到本页填写普通方舟 API Key;Agent/Coding Plan 专属 Key 不适用\",\n },\n serviceTest: {\n title: \"服务测试\", description: \"提前验证关键服务配置是否可用,避免使用期间异常。\",\n tip: \"提示:图像生成测试可能需要数分钟(取决于模型),请耐心等待。\",\n startTest: \"开始测试\", testing: \"测试中...\", testTimeout: \"测试超时,请重试\", testFailed: \"测试失败\",\n tests: {\n baiduOcr: { title: \"Baidu OCR 服务\", description: \"识别测试图片文字,验证 BAIDU_API_KEY 配置\" },\n textModel: { title: \"文本生成模型\", description: \"发送短提示词,验证文本模型与 API 配置\" },\n captionModel: { title: \"图片识别模型\", description: \"生成测试图片并请求模型输出描述\" },\n baiduInpaint: { title: \"Baidu 图像修复\", description: \"使用测试图片执行修复,验证百度 inpaint 服务\" },\n imageModel: { title: \"图像生成模型\", description: \"基于测试图片生成演示文稿背景图(固定分辨率,可能需要 20-40 秒)\" },\n mineruPdf: { title: \"MinerU 解析 PDF\", description: \"上传测试 PDF 并等待解析结果返回(可能需要 30-60 秒)\" }\n },\n results: {\n recognizedText: \"识别结果:{{text}}\", modelReply: \"模型回复:{{reply}}\",\n captionDesc: \"识别描述:{{caption}}\", imageSize: \"输出尺寸:{{width}}x{{height}}\",\n parsePreview: \"解析预览:{{preview}}\"\n }\n },\n actions: { save: \"保存设置\", saving: \"保存中...\", resetToDefault: \"重置为默认配置\" },\n messages: {\n loadFailed: \"加载设置失败\", saveSuccess: \"设置保存成功\", saveFailed: \"保存设置失败\",\n resetConfirm: \"将把大模型、图像生成和并发等所有配置恢复为环境默认值,已保存的自定义设置将丢失,确定继续吗?\",\n resetTitle: \"确认重置为默认配置\", resetSuccess: \"设置已重置\", resetFailed: \"重置设置失败\",\n testServiceTip: \"建议在本页底部进行服务测试,验证关键配置\",\n resetConfirmBtn: \"确定重置\", resetCancelBtn: \"取消\", unknownError: \"未知错误\",\n testSuccess: \"测试成功\"\n }\n }\n },\n en: {\n nav: { backToHome: 'Back to Home' },\n settings: {\n title: \"Settings\",\n subtitle: \"Configure application parameters\",\n sections: {\n appearance: \"Appearance\", language: \"Interface Language\", apiConfig: \"Default API Configuration\",\n apiConfigDesc: \"Used as fallback when a model below has no provider specified\",\n modelConfig: \"Model Configuration\", mineruConfig: \"MinerU Configuration\", imageConfig: \"Image Generation Configuration\",\n performanceConfig: \"Performance Configuration\", outputLanguage: \"Output Language Settings\",\n textReasoning: \"Text Reasoning Mode\", imageReasoning: \"Image Reasoning Mode\",\n baiduOcr: \"Baidu Configuration\", serviceTest: \"Service Test\", lazyllmConfig: \"LazyLLM Provider Configuration\",\n vendorApiKeys: \"Vendor API Key Configuration\",\n advancedSettings: \"Advanced Settings\",\n elevenlabs: \"ElevenLabs Text-to-Speech\",\n about: \"About\"\n },\n about: {\n version: \"Current Version\",\n source: \"GitHub Project\",\n checkUpdate: \"Check for Updates\",\n checking: \"Checking...\",\n upToDate: \"You're currently on the latest version\",\n updateAvailable: \"Version update available: {{version}}\",\n unknown: \"Unable to determine whether this is the latest version\",\n failed: \"Failed to check for updates\",\n resultTitle: \"Update Check Result\",\n close: \"Close\",\n },\n openaiOAuth: {\n title: \"OpenAI Account\",\n description: \"Log in with your OpenAI account via OAuth to use OpenAI models (e.g. GPT Image) without entering an API key\",\n loginBtn: \"Login with OpenAI\",\n disconnectBtn: \"Disconnect\",\n connected: \"Connected\",\n disconnected: \"Not connected\",\n account: \"Account\",\n connecting: \"Connecting...\",\n disconnecting: \"Disconnecting...\",\n connectFailed: \"Connection failed\",\n popupBlocked: \"The login window was blocked. Allow popups and try again.\",\n connectTimeout: \"Login timed out. Try again or use the manual callback option.\",\n disconnectFailed: \"Disconnect failed\",\n disconnectSuccess: \"OpenAI account disconnected\",\n hint: \"When connected, select Codex as the provider in model configuration above to use your OpenAI account credits\",\n availableModels: \"Available Models\",\n selectModel: \"Select a model...\",\n loadingModels: \"Loading available models...\",\n connectFirst: \"Please connect your OpenAI account first\",\n manualCallbackLabel: \"Connection failed after login?\",\n manualCallbackHint: \"Copy the full URL from the popup's address bar and paste it below to complete the connection\",\n manualCallbackPlaceholder: \"Paste callback URL...\",\n manualCallbackSubmit: \"Submit\",\n manualCallbackSuccess: \"Connected successfully\",\n callbackPortBusy: \"Port 1455 is already in use. After logging in, copy the full popup address-bar URL and paste it below.\",\n },\n theme: { label: \"Theme\", light: \"Light\", dark: \"Dark\", system: \"System\" },\n language: { label: \"Interface Language\", zh: \"中文\", en: \"English\" },\n fields: {\n aiProviderFormat: \"AI Provider Format\",\n aiProviderFormatDesc: \"Select API request format, affects how backend constructs and sends requests. Takes effect after saving.\",\n openaiFormat: \"OpenAI Format\", geminiFormat: \"Gemini Format\", lazyllmFormat: \"LazyLLM Format\",\n apiBaseUrl: \"API Base URL\", apiBaseUrlPlaceholder: \"https://api.example.com\",\n apiBaseUrlDesc: \"Set the base URL for the LLM provider API\",\n apiKey: \"API Key\", apiKeyPlaceholder: \"Enter new API Key\",\n apiKeyDesc: \"Leave empty to keep current setting, enter new value to update\",\n apiKeySet: \"Set (length: {{length}})\",\n textModel: \"Text Model\", textModelPlaceholder: \"Leave empty to use env config (e.g., gemini-3-flash-preview)\",\n textModelDesc: \"Model name for generating outlines, descriptions, etc.\",\n imageModel: \"Image Generation Model\", imageModelPlaceholder: \"Leave empty to use env config (e.g., imagen-3.0-generate-001)\",\n imageModelDesc: \"Model name for generating page images\",\n imageCaptionModel: \"Image Caption Model\", imageCaptionModelPlaceholder: \"Leave empty to use env config (e.g., gemini-3-flash-preview)\",\n imageCaptionModelDesc: \"Model for recognizing images in reference files and generating descriptions\",\n mineruApiBase: \"MinerU API Base\", mineruApiBasePlaceholder: \"Leave empty to use env config (e.g., https://mineru.net)\",\n mineruApiBaseDesc: \"MinerU service address for parsing reference files\",\n mineruToken: \"MinerU Token\", mineruTokenPlaceholder: \"Enter new MinerU Token\",\n mineruTokenDesc: \"Leave empty to keep current setting, enter new value to update\",\n imageResolution: \"Image Resolution (may not work with some OpenAI format proxies)\",\n imageResolutionDesc: \"Higher resolution generates more detailed images but takes longer\",\n enableImageQualityControl: \"Enable Quality Control\",\n enableImageQualityControlDesc: \"When enabled, each generated image is reviewed for garbled text, low-quality illustration, and prompt mismatch before it is saved; rejected images retry automatically and failed attempts are not saved as versions\",\n descriptionGenerationMode: \"Description Generation Mode\", descriptionGenerationModeDesc: \"Streaming mode generates all pages in a single AI call for a smoother experience; Parallel mode calls AI independently per page for faster speed\",\n descriptionGenerationModeStreaming: \"Streaming\", descriptionGenerationModeParallel: \"Parallel\",\n maxDescriptionWorkers: \"Max Description Workers\", maxDescriptionWorkersDesc: \"Maximum concurrent workers for description generation in parallel mode (1-20), higher is faster\",\n maxImageWorkers: \"Max Image Workers\", maxImageWorkersDesc: \"Maximum concurrent workers for image generation (1-20), higher is faster\",\n defaultOutputLanguage: \"Default Output Language\", defaultOutputLanguageDesc: \"Default language for AI-generated content\",\n enableTextReasoning: \"Enable Text Reasoning\", enableTextReasoningDesc: \"When enabled, text generation uses extended thinking for deeper reasoning\",\n textThinkingBudget: \"Text Thinking Budget\", textThinkingBudgetDesc: \"Token budget for text reasoning (1-8192), higher values enable deeper reasoning\",\n enableImageReasoning: \"Enable Image Reasoning\", enableImageReasoningDesc: \"When enabled, image generation uses chain-of-thought mode for better composition\",\n imageThinkingBudget: \"Image Thinking Budget\", imageThinkingBudgetDesc: \"Token budget for image reasoning (1-8192), higher values enable deeper reasoning\",\n baiduOcrApiKey: \"Baidu API Key\", baiduOcrApiKeyPlaceholder: \"Enter Baidu API Key\",\n baiduOcrApiKeyDesc: \"For text recognition in editable PPTX export, leave empty to keep current setting\",\n elevenLabsEnabled: \"Enable ElevenLabs Text-to-Speech\",\n elevenLabsEnabledDesc: \"When enabled, video export uses ElevenLabs instead of edge-tts for narration audio, providing more natural voice quality\",\n elevenLabsApiKey: \"ElevenLabs API Key\", elevenLabsApiKeyPlaceholder: \"Enter ElevenLabs API Key\",\n elevenLabsApiKeyDesc: \"Leave empty to keep current setting. Get your API key from the ElevenLabs dashboard\",\n applyLink: \", click here to apply\",\n textModelSource: \"Text Model Provider Format\", textModelSourceDesc: \"Select the provider format for text generation\", textModelSourcePlaceholder: \"-- Select --\",\n imageModelSource: \"Image Model Provider Format\", imageModelSourceDesc: \"Select the provider format for image generation\", imageModelSourcePlaceholder: \"-- Select --\",\n imageCaptionModelSource: \"Image Caption Model Provider Format\", imageCaptionModelSourceDesc: \"Select the provider format for image captioning\", imageCaptionModelSourcePlaceholder: \"-- Select --\",\n vendorApiKey: \"{{vendor}} API Key\", vendorApiKeyPlaceholder: \"Enter {{vendor}} API Key\",\n vendorApiKeyDesc: \"Leave empty to keep current setting, enter new value to update\",\n vendorApiKeySet: \"Set (length: {{length}})\",\n selectPlaceholder: \"-- Select --\",\n modelProvider: \"Provider\", modelProviderDesc: \"Select an independent provider for this model, leave empty to use default config\",\n modelProviderPlaceholder: \"-- Use default config --\",\n perModelApiBaseUrl: \"API Base URL\", perModelApiBaseUrlPlaceholder: \"Leave empty to use default Base URL\",\n perModelApiKey: \"API Key\", perModelApiKeyPlaceholder: \"Enter API Key\",\n perModelApiKeyDesc: \"Leave empty to keep current setting\",\n perModelApiKeySet: \"Set (length: {{length}})\",\n imageApiProtocol: \"Image API Protocol\",\n imageApiProtocolDesc: \"Select the API path for image generation. Auto detects by model name, or force a specific path\",\n imageApiProtocolAuto: \"Auto detect\",\n imageApiProtocolImages: \"images.generate\",\n imageApiProtocolChat: \"chat.completions\",\n },\n apiKeyHelp: {\n title: \"How to get an API key\",\n step1: \"Open {{link}}, then sign in or create an account\",\n step2: \"Go to Console and first choose Account → Top Up in the left sidebar to add credits\",\n step3: \"After topping up, choose Develop → API Keys in the left sidebar\",\n step4: \"Click \\\"Add key\\\" on the API Keys page to create a new API key, then copy it into this page\",\n linkLabel: \"Open AIHubMix →\",\n copyLink: \"Copy link\",\n },\n apiKeyTip: { before: \"For quick setup or stable high-concurrency image generation, get an API key from \", linkLabel: \"AIHubMix\", after: \"\" },\n volcenginePromo: {\n title: \"Volcengine AgentPlans API Key Setup\",\n body: \"The official campaign page includes limited-time discounts for Agent Plan and Coding Plan, Doubao model bundles, and free Tokens claims.\",\n cta: \"Open ModelArk\",\n copy: \"Copy link\",\n guideLink: \"Volcengine AgentPlans\",\n applyModels: \"Fill recommended models\",\n },\n volcengineKeyHelp: {\n title: \"Subscribe and get a Volcengine AgentPlans API Key\",\n step1: \"Open the official campaign page and subscribe to ModelArk Agent Plan\",\n step2: \"Go to the Agent Plan console\",\n step3: \"Create a dedicated API Key in the Agent Plan console\",\n step4: \"Return here and enter the dedicated Agent Plan API Key\",\n },\n doubaoVolcenginePromo: {\n title: \"Doubao / ModelArk API Key Setup\",\n body: \"Doubao uses the standard ModelArk API. The official campaign page includes Doubao image creation model 5.0, Doubao model bundles, limited-time discounts for Agent Plan and Coding Plan, and free Tokens claims; enter a standard ModelArk API Key here, not a dedicated Agent/Coding Plan key.\",\n cta: \"Open ModelArk\",\n copy: \"Copy link\",\n guideLink: \"official Volcengine campaign page\",\n applyModels: \"Fill recommended models\",\n tokenTitle: \"Free Tokens quota claim flow\",\n tokenStep1: \"Sign in to Volcengine and open the official campaign page\",\n tokenStep2: \"Click \\\"Claim now\\\" on the campaign page\",\n tokenStep3: \"Go to the ModelArk console, click \\\"Activate service\\\", and complete one-click authorization\",\n tokenStep4: \"Each model can claim 500K Tokens; total quota follows the campaign page. Calls still use a standard ModelArk API Key\",\n },\n doubaoKeyHelp: {\n title: \"Claim quota and get a standard ModelArk API Key\",\n step1: \"Open the official campaign page and sign in to Volcengine\",\n step2: \"To claim free Tokens, click \\\"Claim now\\\" on the campaign page, then activate service and complete one-click authorization in the ModelArk console\",\n step3: \"Activate the Doubao model services you need in ModelArk, then open API Key management and create a standard ModelArk API Key\",\n step4: \"Return here and enter the standard ModelArk API Key; dedicated Agent/Coding Plan keys do not apply\",\n },\n serviceTest: {\n title: \"Service Test\", description: \"Verify key service configurations before use to avoid issues.\",\n tip: \"Tip: Image generation tests may take several minutes depending on the model, please be patient.\",\n startTest: \"Start Test\", testing: \"Testing...\", testTimeout: \"Test timeout, please retry\", testFailed: \"Test failed\",\n tests: {\n baiduOcr: { title: \"Baidu OCR Service\", description: \"Recognize text in test image, verify BAIDU_API_KEY configuration\" },\n textModel: { title: \"Text Generation Model\", description: \"Send short prompt to verify text model and API configuration\" },\n captionModel: { title: \"Image Caption Model\", description: \"Generate test image and request model to output description\" },\n baiduInpaint: { title: \"Baidu Image Inpainting\", description: \"Use test image for inpainting, verify Baidu inpaint service\" },\n imageModel: { title: \"Image Generation Model\", description: \"Generate presentation background from test image (fixed resolution, may take 20-40 seconds)\" },\n mineruPdf: { title: \"MinerU PDF Parsing\", description: \"Upload test PDF and wait for parsing result (may take 30-60 seconds)\" }\n },\n results: {\n recognizedText: \"Recognized: {{text}}\", modelReply: \"Model reply: {{reply}}\",\n captionDesc: \"Caption: {{caption}}\", imageSize: \"Output size: {{width}}x{{height}}\",\n parsePreview: \"Parse preview: {{preview}}\"\n }\n },\n actions: { save: \"Save Settings\", saving: \"Saving...\", resetToDefault: \"Reset to Default\" },\n messages: {\n loadFailed: \"Failed to load settings\", saveSuccess: \"Settings saved successfully\", saveFailed: \"Failed to save settings\",\n resetConfirm: \"This will reset all configurations (LLM, image generation, concurrency, etc.) to environment defaults. Custom settings will be lost. Continue?\",\n resetTitle: \"Confirm Reset to Default\", resetSuccess: \"Settings reset successfully\", resetFailed: \"Failed to reset settings\",\n testServiceTip: \"It's recommended to test services at the bottom of this page to verify configurations\",\n resetConfirmBtn: \"Confirm Reset\", resetCancelBtn: \"Cancel\", unknownError: \"Unknown error\",\n testSuccess: \"Test passed\"\n }\n }\n }\n};\nimport { Button, Input, Card, Loading, Modal, useToast, useConfirm } from '@/components/shared';\nimport * as api from '@/api/endpoints';\nimport type { OutputLanguage, UpdateCheckInfo } from '@/api/endpoints';\nimport { OUTPUT_LANGUAGE_OPTIONS } from '@/api/endpoints';\nimport type { Settings as SettingsType } from '@/types';\n\n// 配置项类型定义\ntype FieldType = 'text' | 'password' | 'number' | 'select' | 'buttons' | 'switch';\n\ninterface FieldConfig {\n key: keyof typeof initialFormData;\n label: string;\n type: FieldType;\n placeholder?: string;\n description?: string;\n sensitiveField?: boolean; // 是否为敏感字段(如 API Key)\n lengthKey?: keyof SettingsType; // 用于显示已有长度的 key(如 api_key_length)\n options?: { value: string; label: string }[]; // select 类型的选项\n min?: number;\n max?: number;\n link?: string; // 申请链接 URL\n}\n\ninterface SectionConfig {\n title: string;\n icon: React.ReactNode;\n fields: FieldConfig[];\n}\n\ntype TestStatus = 'idle' | 'loading' | 'success' | 'error';\n\ninterface ServiceTestState {\n status: TestStatus;\n message?: string;\n detail?: string;\n}\n\nconst INFERERA_AFFILIATE_URL = 'https://api.inferera.com/?aff=17EC';\nconst VOLCENGINE_AGENTPLANS_CN_URL = 'https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=banana-slides';\nconst VOLCENGINE_AGENTPLANS_EN_URL = 'https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=banana-slides&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=banana-slides';\n\n// LazyLLM 支持的厂商列表\nconst LAZYLLM_SOURCES = [\n { value: 'qwen', label: 'Qwen (通义千问)' },\n { value: 'doubao', label: 'Doubao (火山引擎)' },\n { value: 'deepseek', label: 'DeepSeek' },\n { value: 'glm', label: 'GLM (智谱)' },\n { value: 'siliconflow', label: 'SiliconFlow' },\n { value: 'sensenova', label: 'SenseNova (商汤)' },\n { value: 'minimax', label: 'MiniMax' },\n { value: 'openai', label: 'OpenAI' },\n { value: 'kimi', label: 'Kimi' },\n];\n\n// 所有可用的提供商选项(Gemini/OpenAI/Codex + LazyLLM 厂商)\nconst getAllProviderSources = (isZh: boolean) => [\n { value: 'gemini', label: 'Gemini' },\n { value: 'openai', label: 'OpenAI' },\n { value: 'volcengine', label: isZh ? '* 火山 AgentPlans' : '* Volcengine AgentPlans' },\n { value: 'doubao', label: isZh ? '* Doubao (豆包)' : '* Doubao' },\n { value: 'codex', label: 'Codex (OpenAI OAuth)' },\n ...LAZYLLM_SOURCES.filter(s => s.value !== 'openai' && s.value !== 'doubao'), // avoid duplicate promoted providers\n];\n\n// 需要 API Key + Base URL 的提供商(非 LazyLLM 厂商)\nconst API_KEY_PROVIDERS = new Set(['gemini', 'openai', 'volcengine']);\nconst FIXED_BASE_URL_PROVIDERS = new Set(['volcengine']);\nconst VOLCENGINE_RECOMMENDED_MODELS = {\n text: 'doubao-seed-2-1-pro-260628',\n caption: 'doubao-seed-2-1-pro-260628',\n image: 'doubao-seedream-5-0-260128',\n};\n\n// LazyLLM 厂商名集合\nconst LAZYLLM_VENDOR_SET = new Set(LAZYLLM_SOURCES.map(s => s.value));\n\n// 初始表单数据\nconst initialFormData = {\n ai_provider_format: 'gemini' as string,\n api_base_url: '',\n api_key: '',\n text_model: '',\n image_model: '',\n image_caption_model: '',\n mineru_api_base: '',\n mineru_token: '',\n image_resolution: '2K',\n enable_image_quality_control: false,\n max_description_workers: 5,\n max_image_workers: 8,\n output_language: 'zh' as OutputLanguage,\n // 推理模式配置(分别控制文本和图像)\n enable_text_reasoning: false,\n text_thinking_budget: 1024,\n enable_image_reasoning: false,\n image_thinking_budget: 1024,\n baidu_api_key: '',\n // LazyLLM 配置\n text_model_source: '',\n image_model_source: '',\n image_caption_model_source: '',\n lazyllm_api_keys: {} as Record,\n // Per-model API credentials (for gemini/openai per-model overrides)\n text_api_key: '',\n text_api_base_url: '',\n image_api_key: '',\n image_api_base_url: '',\n image_caption_api_key: '',\n image_caption_api_base_url: '',\n openai_image_api_protocol: 'auto',\n // ElevenLabs TTS\n elevenlabs_api_key: '',\n};\n\nconst isLazyllmVendor = (vendor: string) =>\n LAZYLLM_VENDOR_SET.has(vendor) && vendor !== 'openai';\n\n// When backend returns \"lazyllm\", infer specific vendor from configured keys\nconst resolveLazyllmVendor = (format: string, keysInfo?: Record): string => {\n if (format !== 'lazyllm') return format;\n if (keysInfo) {\n const vendor = LAZYLLM_SOURCES.find(s => isLazyllmVendor(s.value) && keysInfo[s.value]);\n if (vendor) return vendor.value;\n }\n return LAZYLLM_SOURCES.find(s => isLazyllmVendor(s.value))?.value || 'deepseek';\n};\n\nconst GlobalVendorKeyInput: React.FC<{\n vendor: string; formData: typeof initialFormData;\n setFormData: React.Dispatch>;\n settings: SettingsType | null; t: ReturnType;\n}> = ({ vendor, formData, setFormData, settings, t }) => {\n const vendorLabel = LAZYLLM_SOURCES.find(s => s.value === vendor)?.label || vendor.toUpperCase();\n const keyLength = settings?.lazyllm_api_keys_info?.[vendor] || 0;\n const placeholder = keyLength > 0\n ? t('settings.fields.vendorApiKeySet', { length: keyLength })\n : t('settings.fields.vendorApiKeyPlaceholder', { vendor: vendorLabel });\n return (\n
\n {\n setFormData(prev => ({\n ...prev,\n lazyllm_api_keys: { ...prev.lazyllm_api_keys, [vendor]: e.target.value }\n }));\n }}\n />\n

{t('settings.fields.vendorApiKeyDesc')}

\n
\n );\n};\n\ntype SettingsTranslator = ReturnType;\n\nfunction getLatestVersion(info: UpdateCheckInfo): string {\n const sha = info.latest?.sha;\n if (sha) {\n return sha.length > 7 ? sha.slice(0, 7) : sha;\n }\n return info.latest?.tag || '';\n}\n\nfunction formatUpdateMessage(t: SettingsTranslator, info: UpdateCheckInfo): string {\n if (info.status === 'up_to_date') return t('settings.about.upToDate');\n if (info.status === 'update_available') return t('settings.about.updateAvailable', { version: getLatestVersion(info) });\n return t('settings.about.unknown');\n}\n\nexport const SettingsAbout: React.FC<{ t: SettingsTranslator }> = ({ t }) => {\n const [checkingUpdate, setCheckingUpdate] = useState(false);\n const [updateInfo, setUpdateInfo] = useState(null);\n const [updateError, setUpdateError] = useState('');\n const [updateDialogOpen, setUpdateDialogOpen] = useState(false);\n\n const handleCheckUpdate = async () => {\n setCheckingUpdate(true);\n setUpdateError('');\n try {\n const response = await api.checkForUpdates();\n setUpdateInfo(response.data || null);\n setUpdateDialogOpen(true);\n } catch (error: any) {\n setUpdateInfo(null);\n setUpdateError(error?.response?.data?.error?.message || error?.message || t('settings.about.failed'));\n setUpdateDialogOpen(true);\n } finally {\n setCheckingUpdate(false);\n }\n };\n\n const showSuccessCheck = updateInfo?.status === 'up_to_date';\n\n return (\n <>\n
\n

\n \n {t('settings.sections.about')}\n

\n
\n
\n
\n {t('settings.about.version')}: {appVersion.display}\n
\n \n {t('settings.about.source')}\n \n {updateInfo && (\n
\n
{formatUpdateMessage(t, updateInfo)}
\n
\n )}\n {updateError && (\n
\n {t('settings.about.failed')}: {updateError}\n
\n )}\n
\n }\n onClick={handleCheckUpdate}\n loading={checkingUpdate}\n >\n {checkingUpdate ? t('settings.about.checking') : t('settings.about.checkUpdate')}\n \n
\n
\n\n setUpdateDialogOpen(false)} title={t('settings.about.resultTitle')}>\n
\n {updateInfo && (\n
\n
\n {showSuccessCheck && (\n \n )}\n {updateInfo.update_available && (\n \n )}\n

\n {formatUpdateMessage(t, updateInfo)}\n

\n
\n
\n )}\n {updateError && (\n

\n {t('settings.about.failed')}: {updateError}\n

\n )}\n
\n \n
\n
\n
\n \n );\n};\n\nconst formDataFromSettings = (data: SettingsType): typeof initialFormData => {\n const providerFormat = resolveLazyllmVendor(data.ai_provider_format || 'gemini', data.lazyllm_api_keys_info).toLowerCase();\n const textModelSource = (data.text_model_source || '').toLowerCase();\n const imageModelSource = (data.image_model_source || '').toLowerCase();\n const imageCaptionModelSource = (data.image_caption_model_source || '').toLowerCase();\n\n return {\n ai_provider_format: providerFormat,\n api_base_url: FIXED_BASE_URL_PROVIDERS.has(providerFormat) ? '' : (data.api_base_url || ''),\n api_key: '',\n image_resolution: data.image_resolution || '2K',\n enable_image_quality_control: data.enable_image_quality_control ?? false,\n max_description_workers: data.max_description_workers || 5,\n max_image_workers: data.max_image_workers || 8,\n text_model: data.text_model || '',\n image_model: data.image_model || '',\n mineru_api_base: data.mineru_api_base || '',\n mineru_token: '',\n image_caption_model: data.image_caption_model || '',\n output_language: data.output_language || 'zh',\n enable_text_reasoning: data.enable_text_reasoning || false,\n text_thinking_budget: data.text_thinking_budget || 1024,\n enable_image_reasoning: data.enable_image_reasoning || false,\n image_thinking_budget: data.image_thinking_budget || 1024,\n baidu_api_key: '',\n text_model_source: textModelSource,\n image_model_source: imageModelSource,\n image_caption_model_source: imageCaptionModelSource,\n lazyllm_api_keys: {},\n text_api_key: '',\n text_api_base_url: FIXED_BASE_URL_PROVIDERS.has(textModelSource) ? '' : (data.text_api_base_url || ''),\n image_api_key: '',\n image_api_base_url: FIXED_BASE_URL_PROVIDERS.has(imageModelSource) ? '' : (data.image_api_base_url || ''),\n image_caption_api_key: '',\n image_caption_api_base_url: FIXED_BASE_URL_PROVIDERS.has(imageCaptionModelSource) ? '' : (data.image_caption_api_base_url || ''),\n openai_image_api_protocol: data.openai_image_api_protocol || 'auto',\n elevenlabs_api_key: '',\n };\n};\n\n// Settings 组件 - 纯嵌入模式(可复用)\nexport const Settings: React.FC = () => {\n const t = useT(settingsI18n);\n const { i18n } = useTranslation();\n const isZh = i18n.language?.startsWith('zh') ?? true;\n const { show, ToastContainer } = useToast();\n const { confirm, ConfirmDialog } = useConfirm();\n\n const copyToClipboard = (text: string) => {\n if (navigator.clipboard) {\n navigator.clipboard.writeText(text);\n } else {\n const textarea = document.createElement('textarea');\n textarea.value = text;\n textarea.style.position = 'fixed';\n textarea.style.opacity = '0';\n document.body.appendChild(textarea);\n textarea.select();\n document.execCommand('copy');\n document.body.removeChild(textarea);\n }\n show({ message: '链接已复制到剪贴板', type: 'success' });\n };\n\n const [settings, setSettings] = useState(null);\n const [isLoading, setIsLoading] = useState(true);\n const [isSaving, setIsSaving] = useState(false);\n const [formData, setFormData] = useState(initialFormData);\n const [serviceTestStates, setServiceTestStates] = useState>({});\n const [oauthConnecting, setOauthConnecting] = useState(false);\n const [manualCallbackUrl, setManualCallbackUrl] = useState('');\n const [manualCallbackOpen, setManualCallbackOpen] = useState(false);\n const [manualCallbackSubmitting, setManualCallbackSubmitting] = useState(false);\n const [advancedOpen, setAdvancedOpen] = useState(false);\n const oauthMonitorStopRef = useRef<(() => void) | null>(null);\n const oauthAttemptRef = useRef(0);\n const allProviderSources = getAllProviderSources(isZh);\n const volcengineAgentPlansUrl = isZh ? VOLCENGINE_AGENTPLANS_CN_URL : VOLCENGINE_AGENTPLANS_EN_URL;\n const volcengineLogoUrl = isZh ? '/volcengine/huoshan.png' : '/volcengine/byteplus.png';\n const usesVolcengineCampaignPromo = formData.ai_provider_format === 'volcengine' || formData.ai_provider_format === 'doubao';\n const activeVolcenginePromoKey = formData.ai_provider_format === 'doubao'\n ? 'settings.doubaoVolcenginePromo'\n : 'settings.volcenginePromo';\n const activeApiKeyHelpKey = formData.ai_provider_format === 'volcengine'\n ? 'settings.volcengineKeyHelp'\n : formData.ai_provider_format === 'doubao'\n ? 'settings.doubaoKeyHelp'\n : 'settings.apiKeyHelp';\n const stopOAuthMonitor = useCallback(() => {\n oauthAttemptRef.current += 1;\n oauthMonitorStopRef.current?.();\n oauthMonitorStopRef.current = null;\n }, []);\n\n useEffect(() => {\n return () => {\n stopOAuthMonitor();\n };\n }, [stopOAuthMonitor]);\n\n useEffect(() => {\n if (settings) {\n try {\n sessionStorage.setItem('banana-settings', JSON.stringify(settings));\n } catch (error) {\n console.warn('Failed to persist settings in sessionStorage:', error);\n }\n }\n }, [settings]);\n\n const applyOAuthStatus = useCallback((connected: boolean, accountId: string | null) => {\n setSettings(prev => {\n if (!prev) return prev;\n return {\n ...prev,\n openai_oauth_connected: connected,\n openai_oauth_account_id: accountId,\n };\n });\n }, []);\n\n const handleOAuthLogin = async () => {\n stopOAuthMonitor();\n const attemptId = oauthAttemptRef.current;\n setOauthConnecting(true);\n try {\n const resp = await api.getOpenAIOAuthUrl();\n if (attemptId !== oauthAttemptRef.current) return;\n if (resp.success && resp.data?.auth_url) {\n if (resp.data.callback_server_available === false) {\n setManualCallbackOpen(true);\n show({ message: t('settings.openaiOAuth.callbackPortBusy'), type: 'warning' });\n }\n const popup = window.open(resp.data.auth_url, 'openai-oauth', 'width=600,height=700');\n if ((!popup || popup.closed) && !isDesktop) {\n setOauthConnecting(false);\n show({ message: t('settings.openaiOAuth.popupBlocked'), type: 'error' });\n return;\n }\n\n const monitor = startOpenAIOAuthMonitor({\n desktop: isDesktop,\n popup,\n getStatus: async () => {\n const statusResp = await api.getOpenAIOAuthStatus();\n return statusResp.success && statusResp.data ? statusResp.data : null;\n },\n onConnected: status => {\n oauthMonitorStopRef.current = null;\n setOauthConnecting(false);\n applyOAuthStatus(true, status.account_id || null);\n show({ message: t('settings.openaiOAuth.manualCallbackSuccess'), type: 'success' });\n },\n onFailure: (reason, message) => {\n oauthMonitorStopRef.current = null;\n setOauthConnecting(false);\n const errorMessage = reason === 'timeout'\n ? t('settings.openaiOAuth.connectTimeout')\n : message || t('settings.openaiOAuth.connectFailed');\n show({ message: errorMessage, type: 'error' });\n },\n });\n oauthMonitorStopRef.current = monitor.stop;\n } else {\n setOauthConnecting(false);\n show({ message: t('settings.openaiOAuth.connectFailed'), type: 'error' });\n }\n } catch {\n if (attemptId !== oauthAttemptRef.current) return;\n stopOAuthMonitor();\n setOauthConnecting(false);\n show({ message: t('settings.openaiOAuth.connectFailed'), type: 'error' });\n }\n };\n\n const handleOAuthDisconnect = async () => {\n try {\n const resp = await api.disconnectOpenAIOAuth();\n if (resp.success) {\n setSettings(prev => prev ? {\n ...prev,\n openai_oauth_connected: false,\n openai_oauth_account_id: null,\n } : prev);\n show({ message: t('settings.openaiOAuth.disconnectSuccess'), type: 'success' });\n }\n } catch {\n show({ message: t('settings.openaiOAuth.disconnectFailed'), type: 'error' });\n }\n };\n\n const handleManualCallback = async () => {\n if (!manualCallbackUrl.trim()) return;\n setManualCallbackSubmitting(true);\n try {\n const resp = await api.submitOAuthManualCallback(manualCallbackUrl.trim());\n if (resp.success) {\n stopOAuthMonitor();\n setOauthConnecting(false);\n setManualCallbackUrl('');\n setManualCallbackOpen(false);\n applyOAuthStatus(true, resp.data?.account_id || null);\n show({ message: t('settings.openaiOAuth.manualCallbackSuccess'), type: 'success' });\n } else {\n show({ message: t('settings.openaiOAuth.connectFailed'), type: 'error' });\n }\n } catch {\n show({ message: t('settings.openaiOAuth.connectFailed'), type: 'error' });\n } finally {\n setManualCallbackSubmitting(false);\n }\n };\n\n // 配置驱动的表单区块定义(使用翻译)\n const settingsSections: SectionConfig[] = [\n // Global API config & Model config are rendered separately above\n {\n title: t('settings.sections.mineruConfig'),\n icon: ,\n fields: [\n {\n key: 'mineru_api_base',\n label: t('settings.fields.mineruApiBase'),\n type: 'text',\n placeholder: t('settings.fields.mineruApiBasePlaceholder'),\n description: t('settings.fields.mineruApiBaseDesc'),\n },\n {\n key: 'mineru_token',\n label: t('settings.fields.mineruToken'),\n type: 'password',\n placeholder: t('settings.fields.mineruTokenPlaceholder'),\n sensitiveField: true,\n lengthKey: 'mineru_token_length',\n description: t('settings.fields.mineruTokenDesc'),\n link: 'https://mineru.net/apiManage/token',\n },\n ],\n },\n {\n title: t('settings.sections.imageConfig'),\n icon: ,\n fields: [\n {\n key: 'image_resolution',\n label: t('settings.fields.imageResolution'),\n type: 'select',\n description: t('settings.fields.imageResolutionDesc'),\n options: [\n { value: '1K', label: '1K (1024px)' },\n { value: '2K', label: '2K (2048px)' },\n { value: '4K', label: '4K (4096px)' },\n ],\n },\n {\n key: 'enable_image_quality_control',\n label: t('settings.fields.enableImageQualityControl'),\n type: 'switch',\n description: t('settings.fields.enableImageQualityControlDesc'),\n },\n ],\n },\n {\n title: t('settings.sections.performanceConfig'),\n icon: ,\n fields: [\n {\n key: 'max_description_workers',\n label: t('settings.fields.maxDescriptionWorkers'),\n type: 'number',\n min: 1,\n max: 20,\n description: t('settings.fields.maxDescriptionWorkersDesc'),\n },\n {\n key: 'max_image_workers',\n label: t('settings.fields.maxImageWorkers'),\n type: 'number',\n min: 1,\n max: 20,\n description: t('settings.fields.maxImageWorkersDesc'),\n },\n ],\n },\n {\n title: t('settings.sections.outputLanguage'),\n icon: ,\n fields: [\n {\n key: 'output_language',\n label: t('settings.fields.defaultOutputLanguage'),\n type: 'buttons',\n description: t('settings.fields.defaultOutputLanguageDesc'),\n options: OUTPUT_LANGUAGE_OPTIONS,\n },\n ],\n },\n {\n title: t('settings.sections.textReasoning'),\n icon: ,\n fields: [\n {\n key: 'enable_text_reasoning',\n label: t('settings.fields.enableTextReasoning'),\n type: 'switch',\n description: t('settings.fields.enableTextReasoningDesc'),\n },\n {\n key: 'text_thinking_budget',\n label: t('settings.fields.textThinkingBudget'),\n type: 'number',\n min: 1,\n max: 8192,\n description: t('settings.fields.textThinkingBudgetDesc'),\n },\n ],\n },\n {\n title: t('settings.sections.imageReasoning'),\n icon: ,\n fields: [\n {\n key: 'enable_image_reasoning',\n label: t('settings.fields.enableImageReasoning'),\n type: 'switch',\n description: t('settings.fields.enableImageReasoningDesc'),\n },\n {\n key: 'image_thinking_budget',\n label: t('settings.fields.imageThinkingBudget'),\n type: 'number',\n min: 1,\n max: 8192,\n description: t('settings.fields.imageThinkingBudgetDesc'),\n },\n ],\n },\n {\n title: t('settings.sections.baiduOcr'),\n icon: ,\n fields: [\n {\n key: 'baidu_api_key',\n label: t('settings.fields.baiduOcrApiKey'),\n type: 'password',\n placeholder: t('settings.fields.baiduOcrApiKeyPlaceholder'),\n sensitiveField: true,\n lengthKey: 'baidu_api_key_length',\n description: t('settings.fields.baiduOcrApiKeyDesc'),\n link: 'https://console.bce.baidu.com/iam/#/iam/apikey/list',\n },\n ],\n },\n {\n title: t('settings.sections.elevenlabs'),\n icon: ,\n fields: [\n {\n key: 'elevenlabs_api_key',\n label: t('settings.fields.elevenLabsApiKey'),\n type: 'password',\n placeholder: t('settings.fields.elevenLabsApiKeyPlaceholder'),\n sensitiveField: true,\n lengthKey: 'elevenlabs_api_key_length',\n description: t('settings.fields.elevenLabsApiKeyDesc'),\n link: 'https://elevenlabs.io/app/settings/api-keys',\n },\n ],\n },\n ];\n\n useEffect(() => {\n loadSettings();\n }, []);\n\n const loadSettings = async () => {\n setIsLoading(true);\n try {\n const response = await api.getSettings();\n if (response.data) {\n setSettings(response.data);\n setFormData(formDataFromSettings(response.data));\n }\n } catch (error: any) {\n console.error('加载设置失败:', error);\n show({\n message: t('settings.messages.loadFailed') + ': ' + (error?.message || t('settings.messages.unknownError')),\n type: 'error'\n });\n } finally {\n setIsLoading(false);\n }\n };\n\n const markOpenAIOAuthDisconnected = () => {\n setSettings(prev => {\n if (!prev) return prev;\n return {\n ...prev,\n openai_oauth_connected: false,\n openai_oauth_account_id: null,\n };\n });\n };\n\n const handleSave = async () => {\n setIsSaving(true);\n try {\n const {\n api_key, mineru_token, baidu_api_key, elevenlabs_api_key, lazyllm_api_keys,\n text_api_key, image_api_key, image_caption_api_key,\n ...otherData\n } = formData;\n const payload: Parameters[0] = {\n ...otherData,\n ai_provider_format: otherData.ai_provider_format,\n };\n\n // Only send sensitive fields if user entered a new value\n if (api_key) payload.api_key = api_key;\n if (mineru_token) payload.mineru_token = mineru_token;\n if (baidu_api_key) payload.baidu_api_key = baidu_api_key;\n if (elevenlabs_api_key) payload.elevenlabs_api_key = elevenlabs_api_key;\n if (text_api_key) payload.text_api_key = text_api_key;\n if (image_api_key) payload.image_api_key = image_api_key;\n if (image_caption_api_key) payload.image_caption_api_key = image_caption_api_key;\n\n // Send lazyllm API keys (only non-empty values)\n const nonEmptyKeys = Object.fromEntries(\n Object.entries(lazyllm_api_keys).filter(([, v]) => v)\n );\n if (Object.keys(nonEmptyKeys).length > 0) {\n payload.lazyllm_api_keys = nonEmptyKeys;\n }\n\n const response = await api.updateSettings(payload);\n if (response.data) {\n setSettings(response.data);\n show({ message: t('settings.messages.saveSuccess'), type: 'success' });\n show({ message: t('settings.messages.testServiceTip'), type: 'info' });\n // Clear all sensitive fields after save\n setFormData(prev => ({\n ...prev,\n api_key: '', mineru_token: '', baidu_api_key: '', elevenlabs_api_key: '',\n lazyllm_api_keys: {},\n text_api_key: '', image_api_key: '', image_caption_api_key: '',\n }));\n }\n } catch (error: any) {\n console.error('保存设置失败:', error);\n show({\n message: t('settings.messages.saveFailed') + ': ' + (error?.response?.data?.error?.message || error?.message || t('settings.messages.unknownError')),\n type: 'error'\n });\n } finally {\n setIsSaving(false);\n }\n };\n\n const handleReset = () => {\n confirm(\n t('settings.messages.resetConfirm'),\n async () => {\n setIsSaving(true);\n try {\n const response = await api.resetSettings();\n if (response.data) {\n setSettings(response.data);\n setFormData(formDataFromSettings(response.data));\n show({ message: t('settings.messages.resetSuccess'), type: 'success' });\n }\n } catch (error: any) {\n console.error('重置设置失败:', error);\n show({\n message: t('settings.messages.resetFailed') + ': ' + (error?.message || t('settings.messages.unknownError')),\n type: 'error'\n });\n } finally {\n setIsSaving(false);\n }\n },\n {\n title: t('settings.messages.resetTitle'),\n confirmText: t('settings.messages.resetConfirmBtn'),\n cancelText: t('settings.messages.resetCancelBtn'),\n variant: 'warning',\n }\n );\n };\n\n const handleFieldChange = (key: string, value: any) => {\n setFormData(prev => {\n const next = { ...prev, [key]: value };\n\n if (key === 'ai_provider_format') {\n if (FIXED_BASE_URL_PROVIDERS.has(value)) {\n next.api_base_url = '';\n }\n }\n\n const perModelBaseKeys: Record = {\n text_model_source: 'text_api_base_url',\n image_model_source: 'image_api_base_url',\n image_caption_model_source: 'image_caption_api_base_url',\n };\n const apiBaseKey = perModelBaseKeys[key];\n if (apiBaseKey) {\n if (FIXED_BASE_URL_PROVIDERS.has(value)) {\n next[apiBaseKey] = '';\n }\n }\n\n return next;\n });\n };\n\n const applyVolcengineRecommendedModels = () => {\n const provider = formData.ai_provider_format === 'volcengine' ? 'volcengine' : 'doubao';\n setFormData(prev => ({\n ...prev,\n text_model: VOLCENGINE_RECOMMENDED_MODELS.text,\n image_caption_model: VOLCENGINE_RECOMMENDED_MODELS.caption,\n image_model: VOLCENGINE_RECOMMENDED_MODELS.image,\n text_model_source: provider,\n image_caption_model_source: provider,\n image_model_source: provider,\n text_api_base_url: FIXED_BASE_URL_PROVIDERS.has(provider) ? '' : prev.text_api_base_url,\n image_caption_api_base_url: FIXED_BASE_URL_PROVIDERS.has(provider) ? '' : prev.image_caption_api_base_url,\n image_api_base_url: FIXED_BASE_URL_PROVIDERS.has(provider) ? '' : prev.image_api_base_url,\n openai_image_api_protocol: 'images',\n }));\n };\n\n const updateServiceTest = (key: string, nextState: ServiceTestState) => {\n setServiceTestStates(prev => ({ ...prev, [key]: nextState }));\n };\n\n const handleServiceTest = async (\n key: string,\n action: (settings?: any) => Promise,\n formatDetail: (data: any) => string\n ) => {\n updateServiceTest(key, { status: 'loading' });\n try {\n // 准备测试时要使用的设置(包括未保存的修改)\n const testSettings: any = {};\n\n // 只传递用户已填写的非空值\n if (formData.api_key) testSettings.api_key = formData.api_key;\n if (formData.api_base_url) testSettings.api_base_url = formData.api_base_url;\n if (formData.ai_provider_format) {\n testSettings.ai_provider_format = formData.ai_provider_format;\n }\n if (formData.text_model) testSettings.text_model = formData.text_model;\n if (formData.image_model) testSettings.image_model = formData.image_model;\n if (formData.image_caption_model) testSettings.image_caption_model = formData.image_caption_model;\n if (formData.mineru_api_base) testSettings.mineru_api_base = formData.mineru_api_base;\n if (formData.mineru_token) testSettings.mineru_token = formData.mineru_token;\n if (formData.baidu_api_key) testSettings.baidu_api_key = formData.baidu_api_key;\n if (formData.image_resolution) testSettings.image_resolution = formData.image_resolution;\n\n // Per-model provider source overrides (always send, even empty, to clear saved values)\n testSettings.text_model_source = formData.text_model_source || '';\n testSettings.image_model_source = formData.image_model_source || '';\n testSettings.image_caption_model_source = formData.image_caption_model_source || '';\n\n // Per-model API credentials\n if (formData.text_api_key) testSettings.text_api_key = formData.text_api_key;\n if (formData.text_api_base_url) testSettings.text_api_base_url = formData.text_api_base_url;\n if (formData.image_api_key) testSettings.image_api_key = formData.image_api_key;\n if (formData.image_api_base_url) testSettings.image_api_base_url = formData.image_api_base_url;\n if (formData.image_caption_api_key) testSettings.image_caption_api_key = formData.image_caption_api_key;\n if (formData.image_caption_api_base_url) testSettings.image_caption_api_base_url = formData.image_caption_api_base_url;\n\n // 推理模式设置\n if (formData.enable_text_reasoning !== undefined) {\n testSettings.enable_text_reasoning = formData.enable_text_reasoning;\n }\n if (formData.text_thinking_budget !== undefined) {\n testSettings.text_thinking_budget = formData.text_thinking_budget;\n }\n if (formData.enable_image_reasoning !== undefined) {\n testSettings.enable_image_reasoning = formData.enable_image_reasoning;\n }\n if (formData.image_thinking_budget !== undefined) {\n testSettings.image_thinking_budget = formData.image_thinking_budget;\n }\n\n // 启动异步测试,获取任务ID\n const response = await action(testSettings);\n const taskId = response.data.task_id;\n\n // isActive tracks whether this test round is still pending — avoids stale closure\n let isActive = true;\n // eslint-disable-next-line prefer-const\n let pollInterval: ReturnType;\n const finish = (nextState: ServiceTestState, toastMsg: string, toastType: 'success' | 'error') => {\n if (!isActive) return;\n isActive = false;\n clearInterval(pollInterval);\n updateServiceTest(key, nextState);\n show({ message: toastMsg, type: toastType });\n };\n\n // 开始轮询任务状态\n pollInterval = setInterval(async () => {\n try {\n const statusResponse = await api.getTestStatus(taskId);\n const statusData = statusResponse?.data;\n if (!statusData) {\n throw new Error(t('settings.serviceTest.testFailed'));\n }\n const taskStatus = statusData.status;\n\n if (taskStatus === 'COMPLETED') {\n const detail = formatDetail(statusData.result || {});\n const message = statusData.message || t('settings.messages.testSuccess');\n finish({ status: 'success', message, detail }, message, 'success');\n } else if (taskStatus === 'FAILED') {\n const errorMessage = statusData.error || t('settings.serviceTest.testFailed');\n if (statusData.openai_oauth_disconnected) {\n markOpenAIOAuthDisconnected();\n }\n finish({ status: 'error', message: errorMessage }, `${t('settings.serviceTest.testFailed')}: ${errorMessage}`, 'error');\n }\n // 如果是 PENDING 或 PROCESSING,继续轮询\n } catch (pollError: any) {\n const errorMessage = pollError?.response?.data?.error?.message || pollError?.message || t('settings.serviceTest.testFailed');\n finish({ status: 'error', message: errorMessage }, `${t('settings.serviceTest.testFailed')}: ${errorMessage}`, 'error');\n }\n }, 2000); // 每2秒轮询一次\n\n // 设置最大轮询时间(2分钟)\n setTimeout(() => {\n finish({ status: 'error', message: t('settings.serviceTest.testTimeout') }, t('settings.serviceTest.testTimeout'), 'error');\n }, 600000); // 10 分钟,覆盖 gpt-image-2 等慢模型的生成时间\n\n } catch (error: any) {\n const errorMessage = error?.response?.data?.error?.message || error?.message || t('common.unknownError');\n updateServiceTest(key, { status: 'error', message: errorMessage });\n show({ message: `${t('settings.serviceTest.testFailed')}: ${errorMessage}`, type: 'error' });\n }\n };\n\n const renderField = (field: FieldConfig) => {\n const value = formData[field.key] as string | number | boolean;\n\n if (field.type === 'buttons' && field.options) {\n return (\n
\n \n
\n {field.options.map((option) => (\n handleFieldChange(field.key, option.value)}\n className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${\n value === option.value\n ? option.value === 'openai'\n ? 'bg-gradient-to-r from-sky-500 to-blue-600 text-white shadow-md'\n : option.value === 'lazyllm'\n ? 'bg-gradient-to-r from-amber-500 to-orange-600 text-white shadow-md'\n : 'bg-gradient-to-r from-emerald-500 to-green-600 text-white shadow-md'\n : 'bg-white dark:bg-background-secondary border border-gray-200 dark:border-border-primary text-gray-700 dark:text-foreground-secondary hover:bg-gray-50 dark:hover:bg-background-hover hover:border-gray-300 dark:hover:border-gray-500'\n }`}\n >\n {option.label}\n \n ))}\n
\n {field.description && (\n

{field.description}

\n )}\n
\n );\n }\n\n if (field.type === 'select' && field.options) {\n return (\n
\n \n handleFieldChange(field.key, e.target.value)}\n className=\"w-full h-10 px-4 rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary focus:outline-none focus:ring-2 focus:ring-banana-500 focus:border-transparent\"\n >\n {!(value as string) && (\n \n )}\n {field.options.map((option) => (\n \n ))}\n \n {field.description && (\n

{field.description}

\n )}\n
\n );\n }\n\n // switch 类型 - 开关切换\n if (field.type === 'switch') {\n const isEnabled = Boolean(value);\n return (\n
\n
\n \n handleFieldChange(field.key, !isEnabled)}\n className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-banana-500 focus:ring-offset-2 ${\n isEnabled ? 'bg-banana-500' : 'bg-gray-200'\n }`}\n >\n \n \n
\n {field.description && (\n

{field.description}

\n )}\n
\n );\n }\n\n // text, password, number 类型\n const placeholder = field.sensitiveField && settings && field.lengthKey && (settings[field.lengthKey] as number) > 0\n ? t('settings.fields.apiKeySet', { length: settings[field.lengthKey] as string | number })\n : field.placeholder || '';\n\n // 判断是否禁用(思考负载字段在对应开关关闭时禁用)\n let isDisabled = false;\n if (field.key === 'text_thinking_budget') {\n isDisabled = !formData.enable_text_reasoning;\n } else if (field.key === 'image_thinking_budget') {\n isDisabled = !formData.enable_image_reasoning;\n }\n\n return (\n
\n {\n const newValue = field.type === 'number' \n ? parseInt(e.target.value) || (field.min ?? 0)\n : e.target.value;\n handleFieldChange(field.key, newValue);\n }}\n min={field.min}\n max={field.max}\n disabled={isDisabled}\n />\n {(field.description || field.link) && (\n

\n {field.description}\n {field.link && (\n {t('settings.fields.applyLink')}\n )}\n

\n )}\n
\n );\n };\n\n // 模型配置项定义:每种模型类型的 key、source key、api key/base key、标签等\n const modelConfigItems = [\n {\n modelKey: 'text_model' as keyof typeof initialFormData,\n sourceKey: 'text_model_source' as keyof typeof initialFormData,\n apiKeyKey: 'text_api_key' as keyof typeof initialFormData,\n apiBaseKey: 'text_api_base_url' as keyof typeof initialFormData,\n apiKeyLengthKey: 'text_api_key_length' as keyof SettingsType,\n label: t('settings.fields.textModel'),\n placeholder: t('settings.fields.textModelPlaceholder'),\n description: t('settings.fields.textModelDesc'),\n sourceLabel: t('settings.fields.textModelSource'),\n },\n {\n modelKey: 'image_model' as keyof typeof initialFormData,\n sourceKey: 'image_model_source' as keyof typeof initialFormData,\n apiKeyKey: 'image_api_key' as keyof typeof initialFormData,\n apiBaseKey: 'image_api_base_url' as keyof typeof initialFormData,\n apiKeyLengthKey: 'image_api_key_length' as keyof SettingsType,\n label: t('settings.fields.imageModel'),\n placeholder: t('settings.fields.imageModelPlaceholder'),\n description: t('settings.fields.imageModelDesc'),\n sourceLabel: t('settings.fields.imageModelSource'),\n },\n {\n modelKey: 'image_caption_model' as keyof typeof initialFormData,\n sourceKey: 'image_caption_model_source' as keyof typeof initialFormData,\n apiKeyKey: 'image_caption_api_key' as keyof typeof initialFormData,\n apiBaseKey: 'image_caption_api_base_url' as keyof typeof initialFormData,\n apiKeyLengthKey: 'image_caption_api_key_length' as keyof SettingsType,\n label: t('settings.fields.imageCaptionModel'),\n placeholder: t('settings.fields.imageCaptionModelPlaceholder'),\n description: t('settings.fields.imageCaptionModelDesc'),\n sourceLabel: t('settings.fields.imageCaptionModelSource'),\n },\n ];\n\n // 渲染单个模型配置组(模型名 + 提供商选择 + 条件凭证)\n const renderModelConfigGroup = (item: typeof modelConfigItems[0]) => {\n const sourceValue = formData[item.sourceKey] as string;\n const isApiKeyProvider = API_KEY_PROVIDERS.has(sourceValue);\n const isFixedApiBaseProvider = FIXED_BASE_URL_PROVIDERS.has(sourceValue);\n const isLazyllm = sourceValue && isLazyllmVendor(sourceValue);\n // 'openai' in source dropdown means OpenAI format (API key provider), not lazyllm openai vendor\n // lazyllm openai vendor is handled separately\n\n return (\n
\n {/* 模型名称 */}\n handleFieldChange(item.modelKey, e.target.value)}\n />\n {item.description && (\n

{item.description}

\n )}\n\n {/* 提供商选择 */}\n
\n \n handleFieldChange(item.sourceKey, e.target.value)}\n className=\"w-full h-10 px-4 rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary focus:outline-none focus:ring-2 focus:ring-banana-500 focus:border-transparent\"\n >\n \n {allProviderSources.map((option) => (\n \n {option.label}{option.value === 'codex' && !settings?.openai_oauth_connected ? ` (${t('settings.openaiOAuth.disconnected')})` : ''}\n \n ))}\n \n

\n {t('settings.fields.modelProviderDesc')}\n

\n
\n\n {/* Gemini/OpenAI 提供商:显示 API Key,固定 Base URL 的提供商隐藏 Base URL 输入 */}\n {isApiKeyProvider && (\n
\n {!isFixedApiBaseProvider && (\n handleFieldChange(item.apiBaseKey, e.target.value)}\n />\n )}\n
\n 0\n ? t('settings.fields.perModelApiKeySet', { length: settings[item.apiKeyLengthKey] as number })\n : t('settings.fields.perModelApiKeyPlaceholder')\n }\n value={formData[item.apiKeyKey] as string}\n onChange={(e) => handleFieldChange(item.apiKeyKey, e.target.value)}\n />\n

\n {t('settings.fields.perModelApiKeyDesc')}\n

\n
\n
\n )}\n\n {/* Image API Protocol: for image model when effective provider is OpenAI-compatible */}\n {item.sourceKey === 'image_model_source' && (\n sourceValue === 'openai'\n || sourceValue === 'volcengine'\n || (!sourceValue && ['openai', 'volcengine'].includes(formData.ai_provider_format))\n ) && (\n
\n \n handleFieldChange('openai_image_api_protocol', e.target.value)}\n className=\"w-full h-10 px-4 rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary focus:outline-none focus:ring-2 focus:ring-banana-500 focus:border-transparent\"\n >\n \n \n \n \n

\n {t('settings.fields.imageApiProtocolDesc')}\n

\n
\n )}\n\n {/* LazyLLM 厂商:显示厂商 API Key */}\n {isLazyllm && (() => {\n const vendorLabel = LAZYLLM_SOURCES.find(s => s.value === sourceValue)?.label || sourceValue.toUpperCase();\n const keyLength = settings?.lazyllm_api_keys_info?.[sourceValue] || 0;\n const placeholder = keyLength > 0\n ? t('settings.fields.vendorApiKeySet', { length: keyLength })\n : t('settings.fields.vendorApiKeyPlaceholder', { vendor: vendorLabel });\n return (\n
\n {\n setFormData(prev => ({\n ...prev,\n lazyllm_api_keys: { ...prev.lazyllm_api_keys, [sourceValue]: e.target.value }\n }));\n }}\n />\n

\n {t('settings.fields.vendorApiKeyDesc')}\n

\n
\n );\n })()}\n
\n );\n };\n\n if (isLoading) {\n return (\n
\n \n
\n );\n }\n\n return (\n <>\n \n {ConfirmDialog}\n
\n {/* 默认 API 配置区块 */}\n
\n

\n \n {t('settings.sections.apiConfig')}\n

\n

{t('settings.sections.apiConfigDesc')}

\n
\n {/* 提供商下拉 */}\n
\n \n handleFieldChange('ai_provider_format', e.target.value)}\n className=\"w-full h-10 px-4 rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary focus:outline-none focus:ring-2 focus:ring-banana-500 focus:border-transparent\"\n >\n {allProviderSources.map((option) => (\n \n {option.label}{option.value === 'codex' && !settings?.openai_oauth_connected ? ` (${t('settings.openaiOAuth.disconnected')})` : ''}\n \n ))}\n \n

{t('settings.fields.aiProviderFormatDesc')}

\n
\n\n {/* Gemini/OpenAI: API Key;固定 Base URL 的提供商隐藏 Base URL 输入 */}\n {API_KEY_PROVIDERS.has(formData.ai_provider_format) && (\n
\n {!FIXED_BASE_URL_PROVIDERS.has(formData.ai_provider_format) && (\n <>\n handleFieldChange('api_base_url', e.target.value)}\n />\n

{t('settings.fields.apiBaseUrlDesc')}

\n \n )}\n
\n 0\n ? t('settings.fields.apiKeySet', { length: settings.api_key_length })\n : t('settings.fields.apiKeyPlaceholder')\n }\n value={formData.api_key}\n onChange={(e) => handleFieldChange('api_key', e.target.value)}\n />\n

{t('settings.fields.apiKeyDesc')}

\n
\n
\n )}\n\n {/* LazyLLM 厂商: 厂商 API Key */}\n {isLazyllmVendor(formData.ai_provider_format) && (\n \n )}\n
\n\n {usesVolcengineCampaignPromo ? (\n
\n
\n \n \n \n
\n

\n {t(`${activeVolcenginePromoKey}.title`)}\n

\n

\n {t(`${activeVolcenginePromoKey}.body`)}{' '}\n \n {t(`${activeVolcenginePromoKey}.cta`)}\n \n

\n
\n

\n \n {t(`${activeApiKeyHelpKey}.title`)}\n

\n
    \n
  1. \n {t(`${activeApiKeyHelpKey}.step1`)}{' '}\n \n \n {t(`${activeVolcenginePromoKey}.guideLink`)}\n \n copyToClipboard(volcengineAgentPlansUrl)}\n className=\"text-xs px-2 py-0.5 rounded transition-colors bg-amber-100 hover:bg-amber-200 dark:bg-amber-900/30 dark:hover:bg-amber-900/50 text-amber-800 dark:text-amber-300\"\n >\n {t(`${activeVolcenginePromoKey}.copy`)}\n \n \n
  2. \n
  3. {t(`${activeApiKeyHelpKey}.step2`)}
  4. \n
  5. {t(`${activeApiKeyHelpKey}.step3`)}
  6. \n
  7. {t(`${activeApiKeyHelpKey}.step4`)}
  8. \n
\n
\n
\n \n {t(`${activeVolcenginePromoKey}.applyModels`)}\n \n
\n
\n
\n
\n ) : (\n
\n

\n {t('settings.apiKeyTip.before')}\n {t('settings.apiKeyTip.linkLabel')}\n {t('settings.apiKeyTip.after')}\n

\n
\n )}\n\n {/* API Key 获取指南 */}\n {!usesVolcengineCampaignPromo && (\n
\n

\n \n {t(`${activeApiKeyHelpKey}.title`)}\n

\n
    \n
  1. \n {t('settings.apiKeyHelp.step1', { link: '{{link}}' }).split('{{link}}')[0]}\n \n \n {t('settings.apiKeyHelp.linkLabel')}\n \n copyToClipboard(INFERERA_AFFILIATE_URL)}\n className=\"text-xs px-2 py-0.5 bg-blue-100 hover:bg-blue-200 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded transition-colors\"\n >\n {t('settings.apiKeyHelp.copyLink')}\n \n \n {t('settings.apiKeyHelp.step1', { link: '{{link}}' }).split('{{link}}')[1]}\n
  2. \n
  3. {t('settings.apiKeyHelp.step2')}
  4. \n
  5. {t('settings.apiKeyHelp.step3')}
  6. \n
  7. {t('settings.apiKeyHelp.step4')}
  8. \n
\n
\n )}\n
\n\n {/* 模型配置区块 */}\n
\n

\n \n {t('settings.sections.modelConfig')}\n

\n
\n {modelConfigItems.map(renderModelConfigGroup)}\n
\n
\n\n {/* 其余配置区块(配置驱动,排除性能配置和推理模式) */}\n
\n {settingsSections.filter((section) =>\n section.title !== t('settings.sections.performanceConfig') &&\n section.title !== t('settings.sections.textReasoning') &&\n section.title !== t('settings.sections.imageReasoning')\n ).map((section) => (\n
\n

\n {section.icon}\n {section.title}\n

\n
\n {section.fields.map((field) => renderField(field))}\n
\n
\n ))}\n
\n\n {/* 高级设置(折叠区域) */}\n
\n setAdvancedOpen(!advancedOpen)}\n className=\"w-full flex items-center justify-between px-0 py-3 text-left hover:opacity-80 transition-opacity\"\n >\n \n {t('settings.sections.advancedSettings')}\n \n \n \n {advancedOpen && (\n
\n {isDesktop && }\n\n {/* OpenAI OAuth 连接区块 */}\n
\n

\n \n {t('settings.openaiOAuth.title')}\n

\n

{t('settings.openaiOAuth.description')}

\n
\n
\n
\n
\n
\n \n {settings?.openai_oauth_connected ? t('settings.openaiOAuth.connected') : t('settings.openaiOAuth.disconnected')}\n \n {settings?.openai_oauth_connected && settings?.openai_oauth_account_id && (\n \n ({settings.openai_oauth_account_id})\n \n )}\n
\n
\n
\n {settings?.openai_oauth_connected ? (\n \n {t('settings.openaiOAuth.disconnectBtn')}\n \n ) : (\n \n {oauthConnecting ? t('settings.openaiOAuth.connecting') : t('settings.openaiOAuth.loginBtn')}\n \n )}\n
\n
\n

{t('settings.openaiOAuth.hint')}

\n {!settings?.openai_oauth_connected && (\n
\n setManualCallbackOpen(v => !v)}\n className=\"text-xs text-blue-600 dark:text-blue-400 hover:underline\"\n >\n {t('settings.openaiOAuth.manualCallbackLabel')}\n \n {manualCallbackOpen && (\n
\n

{t('settings.openaiOAuth.manualCallbackHint')}

\n
\n setManualCallbackUrl(e.target.value)}\n placeholder={t('settings.openaiOAuth.manualCallbackPlaceholder')}\n className=\"flex-1 px-3 py-1.5 text-xs border border-gray-300 dark:border-border-primary rounded-md bg-white dark:bg-background-secondary text-gray-900 dark:text-foreground-primary placeholder-gray-400\"\n />\n \n {t('settings.openaiOAuth.manualCallbackSubmit')}\n \n
\n
\n )}\n
\n )}\n
\n
\n\n {/* 并发性能配置 + 推理模式 */}\n {settingsSections.filter((section) =>\n section.title === t('settings.sections.performanceConfig') ||\n section.title === t('settings.sections.textReasoning') ||\n section.title === t('settings.sections.imageReasoning')\n ).map((section) => (\n
\n

\n {section.icon}\n {section.title}\n

\n
\n {section.fields.map((field) => renderField(field))}\n
\n
\n ))}\n
\n )}\n
\n\n {/* 服务测试区 */}\n
\n

\n \n {t('settings.serviceTest.title')}\n

\n

\n {t('settings.serviceTest.description')}\n

\n
\n

\n \n {t('settings.serviceTest.tip')}\n

\n
\n
\n {[\n {\n key: 'baidu-ocr',\n titleKey: 'settings.serviceTest.tests.baiduOcr.title',\n descriptionKey: 'settings.serviceTest.tests.baiduOcr.description',\n resultKey: 'settings.serviceTest.results.recognizedText',\n action: api.testBaiduOcr,\n formatDetail: (data: any) => (data?.recognized_text ? t('settings.serviceTest.results.recognizedText', { text: data.recognized_text }) : ''),\n },\n {\n key: 'text-model',\n titleKey: 'settings.serviceTest.tests.textModel.title',\n descriptionKey: 'settings.serviceTest.tests.textModel.description',\n resultKey: 'settings.serviceTest.results.modelReply',\n action: api.testTextModel,\n formatDetail: (data: any) => (data?.reply ? t('settings.serviceTest.results.modelReply', { reply: data.reply }) : ''),\n },\n {\n key: 'caption-model',\n titleKey: 'settings.serviceTest.tests.captionModel.title',\n descriptionKey: 'settings.serviceTest.tests.captionModel.description',\n resultKey: 'settings.serviceTest.results.captionDesc',\n action: api.testCaptionModel,\n formatDetail: (data: any) => (data?.caption ? t('settings.serviceTest.results.captionDesc', { caption: data.caption }) : ''),\n },\n {\n key: 'baidu-inpaint',\n titleKey: 'settings.serviceTest.tests.baiduInpaint.title',\n descriptionKey: 'settings.serviceTest.tests.baiduInpaint.description',\n resultKey: 'settings.serviceTest.results.imageSize',\n action: api.testBaiduInpaint,\n formatDetail: (data: any) => (data?.image_size ? t('settings.serviceTest.results.imageSize', { width: data.image_size[0], height: data.image_size[1] }) : ''),\n },\n {\n key: 'image-model',\n titleKey: 'settings.serviceTest.tests.imageModel.title',\n descriptionKey: 'settings.serviceTest.tests.imageModel.description',\n resultKey: 'settings.serviceTest.results.imageSize',\n action: api.testImageModel,\n formatDetail: (data: any) => (data?.image_size ? t('settings.serviceTest.results.imageSize', { width: data.image_size[0], height: data.image_size[1] }) : ''),\n },\n {\n key: 'mineru-pdf',\n titleKey: 'settings.serviceTest.tests.mineruPdf.title',\n descriptionKey: 'settings.serviceTest.tests.mineruPdf.description',\n resultKey: 'settings.serviceTest.results.parsePreview',\n action: api.testMineruPdf,\n formatDetail: (data: any) => (data?.content_preview ? t('settings.serviceTest.results.parsePreview', { preview: data.content_preview }) : data?.message || ''),\n },\n ].map((item) => {\n const testState = serviceTestStates[item.key] || { status: 'idle' as TestStatus };\n const isLoadingTest = testState.status === 'loading';\n return (\n \n
\n
\n
{t(item.titleKey)}
\n
{t(item.descriptionKey)}
\n
\n handleServiceTest(item.key, item.action, item.formatDetail)}\n >\n {isLoadingTest ? t('settings.serviceTest.testing') : t('settings.serviceTest.startTest')}\n \n
\n {testState.status === 'success' && (\n

\n {testState.message}{testState.detail ? `|${testState.detail}` : ''}\n

\n )}\n {testState.status === 'error' && (\n

\n {testState.message}\n

\n )}\n
\n );\n })}\n
\n
\n\n {/* 操作按钮 */}\n
\n }\n onClick={handleReset}\n disabled={isSaving}\n >\n {t('settings.actions.resetToDefault')}\n \n }\n onClick={handleSave}\n loading={isSaving}\n >\n {isSaving ? t('settings.actions.saving') : t('settings.actions.save')}\n \n
\n\n \n
\n \n );\n};\n\n// SettingsPage 组件 - 完整页面包装\nconst SCROLL_SHOW_THRESHOLD = 300;\n\nexport const SettingsPage: React.FC = () => {\n const navigate = useNavigate();\n const location = useLocation();\n const t = useT(settingsI18n);\n const [showTop, setShowTop] = useState(false);\n const hasInAppBackHistory = typeof window !== 'undefined' && typeof window.history.state?.idx === 'number'\n ? window.history.state.idx > 0\n : false;\n const canNavigateBack = hasInAppBackHistory || Boolean((location.state as { from?: string } | null)?.from);\n\n const handleBack = () => {\n if (canNavigateBack) {\n navigate(-1);\n return;\n }\n navigate('/');\n };\n\n useEffect(() => {\n const onScroll = () => setShowTop(window.scrollY > SCROLL_SHOW_THRESHOLD);\n window.addEventListener('scroll', onScroll, { passive: true });\n return () => window.removeEventListener('scroll', onScroll);\n }, []);\n\n return (\n
\n
\n \n
\n {/* 顶部标题 */}\n
\n
\n }\n onClick={handleBack}\n className=\"mr-4\"\n >\n {t('nav.backToHome')}\n \n
\n

{t('settings.title')}

\n

\n {t('settings.subtitle')}\n

\n
\n
\n
\n\n \n
\n
\n
\n\n {showTop && (\n window.scrollTo({ top: 0, behavior: 'smooth' })}\n className=\"fixed bottom-6 right-6 p-3 rounded-full bg-banana-500 text-white shadow-lg hover:bg-banana-600 transition-all z-50\"\n >\n \n \n )}\n
\n );\n};\n"}}}}