Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/anionex_banana-slides.json
T

1 line
140 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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<div align=\"center\">\n\n<p>\n <img src=\"https://github.com/user-attachments/assets/81fe6816-44cc-4c61-97c7-f3c099650966\" alt=\"Banana Slides\" width=\"860\">\n</p>\n<p>\n <a href=\"https://trendshift.io/repositories/22056\" target=\"_blank\">\n <img src=\"https://trendshift.io/api/badge/repositories/22056\" alt=\"Anionex%2Fbanana-slides | Trendshift\" width=\"265\" height=\"58\">\n </a>\n <br>\n <a href=\"https://hellogithub.com/repository/Anionex/banana-slides\" target=\"_blank\">\n <img src=\"https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=c8a0ee51918e4353af08012b8472b85e&claim_uid=CtDTm2jbUHhVGBr&theme=neutral\" alt=\"Featured|HelloGitHub\" width=\"265\" height=\"58\">\n </a>\n</p>\n<p>\n <a href=\"#-项目缘起\"><b>简体中文</b></a>\n &nbsp;•&nbsp;\n <a href=\"README_EN.md\"><b>English</b></a>\n</p>\n<p>\n <a href=\"https://github.com/Anionex/banana-slides/stargazers\"><img src=\"https://img.shields.io/github/stars/Anionex/banana-slides?style=flat-square&color=FFD700\" alt=\"GitHub Stars\"></a>\n <a href=\"https://github.com/Anionex/banana-slides/network\"><img src=\"https://img.shields.io/github/forks/Anionex/banana-slides?style=flat-square&color=FFD700\" alt=\"GitHub Forks\"></a>\n <a href=\"https://github.com/Anionex/banana-slides/watchers\"><img src=\"https://img.shields.io/github/watchers/Anionex/banana-slides?style=flat-square&color=FFD700\" alt=\"GitHub Watchers\"></a>\n <a href=\"https://github.com/Anionex/banana-slides/releases/tag/v0.9.0-rc.2\"><img src=\"https://img.shields.io/badge/version-v0.9.0--rc.2-44cc11?style=flat-square\" alt=\"Version\"></a>\n <a href=\"https://github.com/Anionex/banana-slides/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/Anionex/banana-slides?color=0055aa&style=flat-square\" alt=\"License\"></a>\n <br>\n <img src=\"https://img.shields.io/badge/Docker-Build-4A90D9?logo=docker&logoColor=white&style=flat-square\" alt=\"Docker Build\">\n <a href=\"https://deepwiki.com/Anionex/banana-slides\"><img src=\"./assets/badge-deepwiki-flat.svg\" alt=\"Ask DeepWiki\"></a>\n</p>\n\n<p>\n <b>一个基于 nano banana pro 🍌 的原生 AI PPT 生成应用</b><br>\n <b>在几分钟内从想法到演示文稿,无需繁琐排版、口头提出修改,迈向真正的 \"Vibe PPT\"</b>\n</p>\n<p>\n <a href=\"https://bananaslides.online/\"><b>🚀 在线 Demo</b></a>\n &nbsp;|&nbsp;\n <a href=\"https://docs.bananaslides.online/\"><b>📖 文档</b></a>\n &nbsp;|&nbsp;\n <a href=\"https://github.com/Anionex/banana-slides/releases/tag/v0.9.0-rc.2\"><b>💻 桌面版 RC2</b></a>\n &nbsp;|&nbsp;\n <a href=\"https://github.com/Anionex/banana-slides#-%E4%BD%BF%E7%94%A8%E6%96%B9%E6%B3%95\"><b>部署方法</b></a>\n</p>\n<p>\n 如果该项目对你有用,欢迎 <b>Star 🌟</b> & <b>Fork 🍴</b>\n</p>\n\n</div>\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<p>\n <b>🎯目标: 降低 PPT 制作门槛,让每个人都能快速创作出美观专业的演示文稿</b>\n</p>\n\n\n## 🎨 结果案例\n\n\n<div align=\"center\">\n\n| | |\n|:---:|:---:|\n| <img src=\"https://github.com/user-attachments/assets/d58ce3f7-bcec-451d-a3b9-ca3c16223644\" width=\"500\" alt=\"案例3\"> | <img src=\"https://github.com/user-attachments/assets/c64cd952-2cdf-4a92-8c34-0322cbf3de4e\" width=\"500\" alt=\"案例2\"> |\n| **软件开发最佳实践** | **DeepSeek-V3.2技术展示** |\n| <img src=\"https://github.com/user-attachments/assets/383eb011-a167-4343-99eb-e1d0568830c7\" width=\"500\" alt=\"案例4\"> | <img src=\"https://github.com/user-attachments/assets/1a63afc9-ad05-4755-8480-fc4aa64987f1\" width=\"500\" alt=\"案例1\"> |\n| **预制菜智能产线装备研发和产业化** | **钱的演变:从贝壳到纸币的旅程** |\n\n</div>\n\n更多可见<a href=\"https://github.com/Anionex/banana-slides/issues/2\" > 使用案例 </a>\n\n\n## 🎯 功能介绍\n\n### 1. 灵活多样的创作路径\n支持**想法**、**大纲**、**页面描述**三种起步方式,满足不同创作习惯。\n- **一句话生成**:输入一个主题,AI 自动生成结构清晰的大纲和逐页内容描述。\n- **自然语言编辑**:支持以 Vibe 形式口头修改大纲或描述(如\"把第三页改成案例分析\"),AI 实时响应调整。\n- **大纲/描述模式**:既可一键批量生成,也可手动调整细节。\n\n<img width=\"2000\" height=\"1125\" alt=\"image\" src=\"https://github.com/user-attachments/assets/7fc1ecc6-433d-4157-b4ca-95fcebac66ba\" />\n\n\n### 2. 强大的素材解析能力\n- **多格式支持**:上传 PDF/Docx/MD/Txt 等文件,后台自动解析内容。\n- **智能提取**:自动识别文本中的关键点、图片链接和图表信息,为生成提供丰富素材。\n- **图片自动入库**:文档解析出的图片会随参考文件关联项目后自动进入项目素材库,后续可直接复用。\n- **风格参考**:支持上传参考图片或模板,定制 PPT 风格。\n\n<img width=\"1920\" height=\"1080\" alt=\"文件解析与素材处理\" src=\"https://github.com/user-attachments/assets/8cda1fd2-2369-4028-b310-ea6604183936\" />\n\n### 3. \"Vibe\" 式自然语言修改\n不再受限于复杂的菜单按钮,直接通过**自然语言**下达修改指令。\n- **局部重绘**:对不满意的区域进行口头式修改(如\"把这个图换成饼图\")。\n- **整页优化**:基于 nano banana pro🍌 生成高清、风格统一的页面。\n\n<img width=\"2000\" height=\"1125\" alt=\"image\" src=\"https://github.com/user-attachments/assets/929ba24a-996c-4f6d-9ec6-818be6b08ea3\" />\n\n\n### 4. 开箱即用的格式导出\n- **多格式支持**:一键导出标准 **PPTX** 或 **PDF** 文件。\n- **播放设置**:导出 PPTX 前可开启页面切换动画,支持淡入淡出等经典效果\n- **完美适配**:默认 16:9 比例,排版无需二次调整,直接演示。\n\n<img width=\"1000\" alt=\"image\" src=\"https://github.com/user-attachments/assets/3e54bbba-88be-4f69-90a1-02e875c25420\" />\n<img width=\"1748\" height=\"538\" alt=\"PPT与PDF导出\" src=\"https://github.com/user-attachments/assets/647eb9b1-d0b6-42cb-a898-378ebe06c984\" />\n\n### 5. 可自由编辑的pptx导出(Beta迭代中)\n- **导出图像为高还原度、背景干净的、可自由编辑图像和文字的PPT页面**\n- 相关更新见 https://github.com/Anionex/banana-slides/issues/121\n<img width=\"1000\" alt=\"image\" src=\"https://github.com/user-attachments/assets/a85d2d48-1966-4800-a4bf-73d17f914062\" />\n\n### 6. 一键导出讲解视频\n- **一键将幻灯片转换为带 AI 语音旁白和字幕的讲解视频(MP4)**\n- AI 自动根据页面描述和内容生成口语化旁白\n- 支持配置多种表达风格、多语言和多种音色\n\n<br>\n\n**🌟和notebooklm slide deck功能对比**\n| 功能 | notebooklm | 本项目 | \n| --- | --- | --- |\n| 页数上限 | 15页 | **无限制** | \n| 二次编辑 | 提示词修改 | **框选编辑+口头编辑** |\n| 素材添加 | 生成后无法添加 | **生成后自由添加** |\n| 导出格式 | 支持导出为 PDF、(不可编辑图片)pptx | **导出为PDF、(图片or可编辑)pptx、讲解视频** |\n| 水印 | 免费版有水印 | **无水印,自由增删元素** |\n\n> 注:随着新功能添加,对比可能过时\n\n\n\n## 🗺️ 开发计划\n\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<details>\n <summary>📒 Windows/Mac用户说明</summary>\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</details>\n\n0. **克隆代码仓库**\n```bash\ngit clone https://github.com/Anionex/banana-slides\ncd banana-slides\n```\n\n1. **配置环境变量**\n\n创建 `.env` 文件(参考 `.env.example`):\n```bash\ncp .env.example .env\n```\n\n**(可选, 也可以启动后在用户界面配置,[教程可点击此处](https://ziy68cvfvu3.feishu.cn/wiki/GiNawdmpiinSRqkGspocqEWAnkh?from=from_copylink ))** 编辑 `.env` 文件,配置必要的环境变量:\n\n<details>\n<summary>点击展开详情</summary>\n \n> **项目中大模型接口以AIHubMix平台格式为标准,推荐使用 [AIHubMix(点击此处可直接访问)](https://api.inferera.com/?aff=17EC) 获取API密钥,减小迁移成本**<br>\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</details>\n\n\n**使用新版可编辑导出配置方法,获得更好的可编辑导出效果**: 需在[百度智能云平台](https://console.bce.baidu.com/iam/#/iam/apikey/list)(点击此处进入)中获取API KEY,填写在.env文件中的BAIDU_API_KEY字段(有充足的免费使用额度)。详见https://github.com/Anionex/banana-slides/issues/121 中的说明\n\n\n<details>\n <summary>📒 Vertex AI 配置指南(适用于 GCP 用户)</summary>\n\nGoogle Cloud Vertex AI 允许通过 GCP 服务账户调用 Gemini 模型,新用户可使用赠金额度。配置步骤:\n\n1. 前往 [GCP Console](https://console.cloud.google.com/),创建一个服务账户并下载 JSON 格式的密钥文件\n2. 将密钥文件保存为项目根目录下的 `gcp-service-account.json`\n3. 在 `.env` 中设置:\n ```env\n AI_PROVIDER_FORMAT=vertex\n VERTEX_PROJECT_ID=your-gcp-project-id\n VERTEX_LOCATION=global\n ```\n4. 如果使用 Docker 部署,还需要在 `docker-compose.yml` 中取消相关注释,将密钥文件挂载到容器内并设置 `GOOGLE_APPLICATION_CREDENTIALS` 环境变量。\n\n> `gemini-3-*` 系列模型要求 `VERTEX_LOCATION=global`\n\n</details>\n\n2. **启动服务**\n\n**⚡ 使用预构建镜像(推荐)**\n\n项目在 Docker Hub 提供了构建好的前端和后端镜像(同步主分支最新版本),可以跳过本地构建步骤,实现快速部署:\n\n```bash\n# 使用预构建镜像启动(无需从头构建)\ndocker compose -f docker-compose.prod.yml up -d\n```\n\n镜像名称:\n- `anoinex/banana-slides-frontend:latest`\n- `anoinex/banana-slides-backend:latest`\n\n启动后可在应用内进入 **设置 → 关于 → 检查更新**,应用会根据当前版本 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<img width=\"312\" alt=\"image\" src=\"https://github.com/user-attachments/assets/165f7d53-1d42-47c7-840b-7740a4717181\" />\n\n\n\n\n\n\n欢迎关注作者的社交媒体,我会分享有关本项目和有关AI的信息:\n\n<p>\n <a href=\"https://x.com/anion_ex\"><img src=\"https://img.shields.io/badge/X-@anion__ex-000000?style=flat-square&logo=x&logoColor=white\" alt=\"X (Twitter)\"></a>\n <a href=\"https://www.xiaohongshu.com/user/profile/62e8f580000000001902fc9d\"><img src=\"https://img.shields.io/badge/小红书-Anion-FF2442?style=flat-square&logo=xiaohongshu&logoColor=white\" alt=\"小红书\"></a>\n <a href=\"https://space.bilibili.com/477162339\"><img src=\"https://img.shields.io/badge/Bilibili-Anion-00A1D6?style=flat-square&logo=bilibili&logoColor=white\" alt=\"Bilibili\"></a>\n</p>\n\n\n\n\n\n## **🔧 常见问题**\n可见[官网文档](https://docs.bananaslides.online/zh/faq)\n\n也可以直接到 DeepWiki 提问 \n<a href=\"https://deepwiki.com/Anionex/banana-slides\"><img src=\"https://deepwiki.com/badge.svg\" alt=\"Ask DeepWiki\"></a>\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<h2>🚀 Sponsor / 赞助 </h2>\n<br>\n<div align=\"center\">\n<a href=\"https://api.inferera.com/?aff=17EC\">\n <img src=\"./assets/logo_aihubmix.png\" alt=\"AIHubMix\" style=\"height:48px;\">\n</a>\n<p>感谢AIHubMix对本项目的赞助</p>\n</div>\n\n\n<div align=\"center\">\n<a href=\"中文链接\">\n <img src=\"./assets/huoshan.png\" alt=\"火山引擎\" width=\"150\"/ >\n <p>感谢<strong>火山引擎 </strong>赞助了本项目<br>\n 方舟 Agent Plan 限时2.5折订阅,<a href=\"https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=banana-slides\">点击链接抢购</a></p>\n</a>\n</div>\n\n<!-- 注意,英文README使用这个版本: -->\n<!--\n<div align=\"center\">\n<a href=\"英文链接\">\n <img src=\"./assets/byteplus.png\" alt=\"BytePlus\" width=\"150\"/ >\n <p> Thanks to Dola seed for sponsoring this project! Register via <a href=\"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\">this link</a> to get 500,000 tokens of free inference quota per model. </p>\n</a>\n</div>\n-->\n\n\n<div align=\"center\">\n\n <br>\n\n<a href=\"https://api.chatfire.site/login?inviteCode=A15CD6A0\"><img width=\"200\" alt=\"image\" src=\"https://github.com/user-attachments/assets/d6bd255f-ba2c-4ea3-bd90-fef292fc3397\" />\n</a>\n\n感谢AI火宝对本项目的赞助\n \n</div>\n\n\n## 致谢\n\n- 项目贡献者们:\n\n[![Contributors](https://contrib.rocks/image?repo=Anionex/banana-slides)](https://github.com/Anionex/banana-slides/graphs/contributors)\n\n- [Linux.do](https://linux.do/): 新的理想型社区\n \n## 赞赏\n\n开源不易🙏如果本项目对你有价值,欢迎请开发者喝杯咖啡☕️\n\n<img width=\"240\" alt=\"image\" src=\"https://github.com/user-attachments/assets/fd7a286d-711b-445e-aecf-43e3fe356473\" />\n\n感谢以下朋友对项目的无偿赞助支持:\n> @雅俗共赏、@曹峥、@以年观日、@John、@胡yun星Ethan, @azazo1、@刘聪NLP、@🍟、@苍何、@万瑾、@biubiu、@law、@方源、@寒松Falcon、@刘星宇&小陀螺AIGC\n> 如对赞助列表有疑问,可<a href=\"mailto:davidyang042@gmail.com\">联系作者</a>\n \n## 📈 项目统计\n\n<a href=\"https://www.star-history.com/#Anionex/banana-slides&type=Timeline&legend=top-left\">\n\n <picture>\n\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=Anionex/banana-slides&type=Timeline&theme=dark&legend=top-left\" />\n\n <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=Anionex/banana-slides&type=Timeline&legend=top-left\" />\n\n <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=Anionex/banana-slides&type=Timeline&legend=top-left\" />\n\n </picture>\n\n</a>\n\n<br>\n"}}, "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<Warning>\n Gemini API 免费层仅支持文本生成,不支持图片生成。\n</Warning>\n\n## OpenAI 兼容\n\n```env\nAI_PROVIDER_FORMAT=openai\nOPENAI_API_KEY=your-api-key\nOPENAI_API_BASE=https://api.openai.com/v1\n```\n\n## 火山 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<Tip>\n `gemini-3-*` 系列模型需要 `VERTEX_LOCATION=global`。\n</Tip>\n\n## LazyLLM(多厂商路由)\n\n将请求路由到不同国产 AI 厂商:\n\n```env\nAI_PROVIDER_FORMAT=lazyllm\nTEXT_MODEL_SOURCE=deepseek\nIMAGE_MODEL_SOURCE=doubao\nIMAGE_CAPTION_MODEL_SOURCE=qwen\n```\n\n设置对应厂商的 API Key:\n\n```env\nDOUBAO_API_KEY=your-key # 火山引擎/豆包\nDEEPSEEK_API_KEY=your-key # DeepSeek\nQWEN_API_KEY=your-key # 阿里云/通义千问\nGLM_API_KEY=your-key # 智谱 GLM\nSILICONFLOW_API_KEY=your-key # 硅基流动\nSENSENOVA_API_KEY=your-key # 商汤日日新\nMINIMAX_API_KEY=your-key # MiniMax\n```\n\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<string, string>,\n // Per-model API credentials (for gemini/openai per-model overrides)\n text_api_key: '',\n text_api_base_url: '',\n image_api_key: '',\n image_api_base_url: '',\n image_caption_api_key: '',\n image_caption_api_base_url: '',\n 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, number>): string => {\n if (format !== 'lazyllm') return format;\n if (keysInfo) {\n const vendor = LAZYLLM_SOURCES.find(s => isLazyllmVendor(s.value) && keysInfo[s.value]);\n if (vendor) return vendor.value;\n }\n return LAZYLLM_SOURCES.find(s => isLazyllmVendor(s.value))?.value || 'deepseek';\n};\n\nconst GlobalVendorKeyInput: React.FC<{\n vendor: string; formData: typeof initialFormData;\n setFormData: React.Dispatch<React.SetStateAction<typeof initialFormData>>;\n settings: SettingsType | null; t: ReturnType<typeof useT>;\n}> = ({ vendor, formData, setFormData, settings, t }) => {\n const vendorLabel = LAZYLLM_SOURCES.find(s => s.value === vendor)?.label || vendor.toUpperCase();\n const keyLength = settings?.lazyllm_api_keys_info?.[vendor] || 0;\n const placeholder = keyLength > 0\n ? t('settings.fields.vendorApiKeySet', { length: keyLength })\n : t('settings.fields.vendorApiKeyPlaceholder', { vendor: vendorLabel });\n return (\n <div className=\"pl-3 border-l-2 border-amber-300 dark:border-amber-600\">\n <Input\n label={t('settings.fields.vendorApiKey', { vendor: vendorLabel })}\n type=\"password\"\n placeholder={placeholder}\n value={formData.lazyllm_api_keys[vendor] || ''}\n onChange={(e) => {\n setFormData(prev => ({\n ...prev,\n lazyllm_api_keys: { ...prev.lazyllm_api_keys, [vendor]: e.target.value }\n }));\n }}\n />\n <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">{t('settings.fields.vendorApiKeyDesc')}</p>\n </div>\n );\n};\n\ntype SettingsTranslator = ReturnType<typeof useT>;\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<UpdateCheckInfo | null>(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 <div className=\"pt-4 border-t border-gray-200 dark:border-border-primary\">\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-foreground-primary mb-3 flex items-center\">\n <Info size={20} />\n <span className=\"ml-2\">{t('settings.sections.about')}</span>\n </h2>\n <div className=\"flex flex-col gap-3 text-sm text-gray-600 dark:text-foreground-tertiary sm:flex-row sm:items-start sm:justify-between\">\n <div className=\"space-y-1\">\n <div title={appVersion.detail} aria-label={`${t('settings.about.version')} ${appVersion.detail}`}>\n {t('settings.about.version')}: {appVersion.display}\n </div>\n <a\n href=\"https://github.com/Anionex/banana-slides\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"inline-flex items-center gap-1 text-banana-700 dark:text-banana hover:underline\"\n >\n {t('settings.about.source')}\n </a>\n {updateInfo && (\n <div className={updateInfo.update_available ? 'text-orange-600 dark:text-orange-400' : 'text-gray-600 dark:text-foreground-tertiary'}>\n <div>{formatUpdateMessage(t, updateInfo)}</div>\n </div>\n )}\n {updateError && (\n <div className=\"text-red-600 dark:text-red-400\">\n {t('settings.about.failed')}: {updateError}\n </div>\n )}\n </div>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n icon={<RefreshCw size={16} className={checkingUpdate ? 'animate-spin' : ''} />}\n onClick={handleCheckUpdate}\n loading={checkingUpdate}\n >\n {checkingUpdate ? t('settings.about.checking') : t('settings.about.checkUpdate')}\n </Button>\n </div>\n </div>\n\n <Modal isOpen={updateDialogOpen} onClose={() => setUpdateDialogOpen(false)} title={t('settings.about.resultTitle')}>\n <div className=\"space-y-4\">\n {updateInfo && (\n <div className=\"space-y-2 text-sm text-gray-700 dark:text-foreground-secondary\">\n <div className=\"flex flex-col items-center gap-3 py-2 text-center\">\n {showSuccessCheck && (\n <CheckCircle\n size={44}\n data-testid=\"update-success-icon\"\n className=\"text-green-600 dark:text-green-400\"\n aria-hidden=\"true\"\n />\n )}\n {updateInfo.update_available && (\n <ArrowUp\n size={44}\n data-testid=\"update-available-icon\"\n className=\"text-orange-600 dark:text-orange-400\"\n aria-hidden=\"true\"\n />\n )}\n <p className={updateInfo.update_available\n ? 'text-xl font-semibold text-orange-600 dark:text-orange-400'\n : 'text-xl font-semibold text-gray-900 dark:text-foreground-primary'\n }>\n {formatUpdateMessage(t, updateInfo)}\n </p>\n </div>\n </div>\n )}\n {updateError && (\n <p className=\"text-sm text-red-600 dark:text-red-400\">\n {t('settings.about.failed')}: {updateError}\n </p>\n )}\n <div className=\"flex justify-end\">\n <Button variant=\"secondary\" size=\"sm\" onClick={() => setUpdateDialogOpen(false)}>\n {t('settings.about.close')}\n </Button>\n </div>\n </div>\n </Modal>\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<SettingsType | null>(null);\n const [isLoading, setIsLoading] = useState(true);\n const [isSaving, setIsSaving] = useState(false);\n const [formData, setFormData] = useState(initialFormData);\n const [serviceTestStates, setServiceTestStates] = useState<Record<string, ServiceTestState>>({});\n 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: <FileText size={20} />,\n fields: [\n {\n key: 'mineru_api_base',\n label: t('settings.fields.mineruApiBase'),\n type: 'text',\n placeholder: t('settings.fields.mineruApiBasePlaceholder'),\n description: t('settings.fields.mineruApiBaseDesc'),\n },\n {\n key: 'mineru_token',\n label: t('settings.fields.mineruToken'),\n type: 'password',\n placeholder: t('settings.fields.mineruTokenPlaceholder'),\n sensitiveField: true,\n lengthKey: 'mineru_token_length',\n description: t('settings.fields.mineruTokenDesc'),\n link: 'https://mineru.net/apiManage/token',\n },\n ],\n },\n {\n title: t('settings.sections.imageConfig'),\n icon: <Image size={20} />,\n fields: [\n {\n key: 'image_resolution',\n label: t('settings.fields.imageResolution'),\n type: 'select',\n description: t('settings.fields.imageResolutionDesc'),\n options: [\n { value: '1K', label: '1K (1024px)' },\n { value: '2K', label: '2K (2048px)' },\n { value: '4K', label: '4K (4096px)' },\n ],\n },\n {\n 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: <Zap size={20} />,\n fields: [\n {\n key: 'max_description_workers',\n label: t('settings.fields.maxDescriptionWorkers'),\n type: 'number',\n min: 1,\n max: 20,\n description: t('settings.fields.maxDescriptionWorkersDesc'),\n },\n {\n key: 'max_image_workers',\n label: t('settings.fields.maxImageWorkers'),\n type: 'number',\n min: 1,\n max: 20,\n description: t('settings.fields.maxImageWorkersDesc'),\n },\n ],\n },\n {\n title: t('settings.sections.outputLanguage'),\n icon: <Globe size={20} />,\n fields: [\n {\n key: 'output_language',\n label: t('settings.fields.defaultOutputLanguage'),\n type: 'buttons',\n description: t('settings.fields.defaultOutputLanguageDesc'),\n options: OUTPUT_LANGUAGE_OPTIONS,\n },\n ],\n },\n {\n title: t('settings.sections.textReasoning'),\n icon: <Brain size={20} />,\n fields: [\n {\n key: 'enable_text_reasoning',\n label: t('settings.fields.enableTextReasoning'),\n type: 'switch',\n description: t('settings.fields.enableTextReasoningDesc'),\n },\n {\n key: 'text_thinking_budget',\n label: t('settings.fields.textThinkingBudget'),\n type: 'number',\n min: 1,\n max: 8192,\n description: t('settings.fields.textThinkingBudgetDesc'),\n },\n ],\n },\n {\n title: t('settings.sections.imageReasoning'),\n icon: <Brain size={20} />,\n fields: [\n {\n key: 'enable_image_reasoning',\n label: t('settings.fields.enableImageReasoning'),\n type: 'switch',\n description: t('settings.fields.enableImageReasoningDesc'),\n },\n {\n key: 'image_thinking_budget',\n label: t('settings.fields.imageThinkingBudget'),\n type: 'number',\n min: 1,\n max: 8192,\n description: t('settings.fields.imageThinkingBudgetDesc'),\n },\n ],\n },\n {\n title: t('settings.sections.baiduOcr'),\n icon: <FileText size={20} />,\n fields: [\n {\n key: 'baidu_api_key',\n label: t('settings.fields.baiduOcrApiKey'),\n type: 'password',\n placeholder: t('settings.fields.baiduOcrApiKeyPlaceholder'),\n sensitiveField: true,\n lengthKey: 'baidu_api_key_length',\n description: t('settings.fields.baiduOcrApiKeyDesc'),\n link: 'https://console.bce.baidu.com/iam/#/iam/apikey/list',\n },\n ],\n },\n {\n title: t('settings.sections.elevenlabs'),\n icon: <Volume2 size={20} />,\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<typeof api.updateSettings>[0] = {\n ...otherData,\n ai_provider_format: otherData.ai_provider_format,\n };\n\n // Only send sensitive fields if user entered a new value\n if (api_key) payload.api_key = api_key;\n if (mineru_token) payload.mineru_token = mineru_token;\n if (baidu_api_key) payload.baidu_api_key = baidu_api_key;\n if (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<string, 'text_api_base_url' | 'image_api_base_url' | 'image_caption_api_base_url'> = {\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<any>,\n formatDetail: (data: any) => string\n ) => {\n updateServiceTest(key, { status: 'loading' });\n try {\n // 准备测试时要使用的设置(包括未保存的修改)\n const testSettings: any = {};\n\n // 只传递用户已填写的非空值\n if (formData.api_key) testSettings.api_key = formData.api_key;\n if (formData.api_base_url) testSettings.api_base_url = formData.api_base_url;\n if (formData.ai_provider_format) {\n testSettings.ai_provider_format = formData.ai_provider_format;\n }\n if (formData.text_model) testSettings.text_model = formData.text_model;\n if (formData.image_model) testSettings.image_model = formData.image_model;\n if (formData.image_caption_model) testSettings.image_caption_model = formData.image_caption_model;\n if (formData.mineru_api_base) testSettings.mineru_api_base = formData.mineru_api_base;\n if (formData.mineru_token) testSettings.mineru_token = formData.mineru_token;\n if (formData.baidu_api_key) testSettings.baidu_api_key = formData.baidu_api_key;\n if (formData.image_resolution) testSettings.image_resolution = formData.image_resolution;\n\n // Per-model provider source overrides (always send, even empty, to clear saved values)\n testSettings.text_model_source = formData.text_model_source || '';\n testSettings.image_model_source = formData.image_model_source || '';\n testSettings.image_caption_model_source = formData.image_caption_model_source || '';\n\n // Per-model API credentials\n if (formData.text_api_key) testSettings.text_api_key = formData.text_api_key;\n if (formData.text_api_base_url) testSettings.text_api_base_url = formData.text_api_base_url;\n if (formData.image_api_key) testSettings.image_api_key = formData.image_api_key;\n if (formData.image_api_base_url) testSettings.image_api_base_url = formData.image_api_base_url;\n if (formData.image_caption_api_key) testSettings.image_caption_api_key = formData.image_caption_api_key;\n if (formData.image_caption_api_base_url) testSettings.image_caption_api_base_url = formData.image_caption_api_base_url;\n\n // 推理模式设置\n if (formData.enable_text_reasoning !== undefined) {\n testSettings.enable_text_reasoning = formData.enable_text_reasoning;\n }\n if (formData.text_thinking_budget !== undefined) {\n testSettings.text_thinking_budget = formData.text_thinking_budget;\n }\n if (formData.enable_image_reasoning !== undefined) {\n testSettings.enable_image_reasoning = formData.enable_image_reasoning;\n }\n if (formData.image_thinking_budget !== undefined) {\n testSettings.image_thinking_budget = formData.image_thinking_budget;\n }\n\n // 启动异步测试,获取任务ID\n const response = await action(testSettings);\n const taskId = response.data.task_id;\n\n // 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<typeof setInterval>;\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 <div key={field.key}>\n <label className=\"block text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-2\">\n {field.label}\n </label>\n <div className=\"flex flex-wrap gap-2\">\n {field.options.map((option) => (\n <button\n key={option.value}\n type=\"button\"\n onClick={() => handleFieldChange(field.key, option.value)}\n className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${\n value === option.value\n ? option.value === 'openai'\n ? 'bg-gradient-to-r from-sky-500 to-blue-600 text-white shadow-md'\n : option.value === 'lazyllm'\n ? 'bg-gradient-to-r from-amber-500 to-orange-600 text-white shadow-md'\n : 'bg-gradient-to-r from-emerald-500 to-green-600 text-white shadow-md'\n : 'bg-white dark:bg-background-secondary border border-gray-200 dark:border-border-primary text-gray-700 dark:text-foreground-secondary hover:bg-gray-50 dark:hover:bg-background-hover hover:border-gray-300 dark:hover:border-gray-500'\n }`}\n >\n {option.label}\n </button>\n ))}\n </div>\n {field.description && (\n <p className=\"mt-1 text-xs text-gray-500 dark:text-foreground-tertiary\">{field.description}</p>\n )}\n </div>\n );\n }\n\n if (field.type === 'select' && field.options) {\n return (\n <div key={field.key}>\n <label className=\"block text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-2\">\n {field.label}\n </label>\n <select\n value={value as string}\n onChange={(e) => handleFieldChange(field.key, e.target.value)}\n className=\"w-full h-10 px-4 rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary focus:outline-none focus:ring-2 focus:ring-banana-500 focus:border-transparent\"\n >\n {!(value as string) && (\n <option value=\"\" disabled>\n {field.placeholder || t('settings.fields.selectPlaceholder')}\n </option>\n )}\n {field.options.map((option) => (\n <option key={option.value} value={option.value}>\n {option.label}\n </option>\n ))}\n </select>\n {field.description && (\n <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">{field.description}</p>\n )}\n </div>\n );\n }\n\n // switch 类型 - 开关切换\n if (field.type === 'switch') {\n const isEnabled = Boolean(value);\n return (\n <div key={field.key}>\n <div className=\"flex items-center justify-between\">\n <label className=\"block text-sm font-medium text-gray-700 dark:text-foreground-secondary\">\n {field.label}\n </label>\n <button\n type=\"button\"\n onClick={() => handleFieldChange(field.key, !isEnabled)}\n className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-banana-500 focus:ring-offset-2 ${\n isEnabled ? 'bg-banana-500' : 'bg-gray-200'\n }`}\n >\n <span\n className={`inline-block h-4 w-4 transform rounded-full bg-white dark:bg-background-secondary transition-transform ${\n isEnabled ? 'translate-x-6' : 'translate-x-1'\n }`}\n />\n </button>\n </div>\n {field.description && (\n <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">{field.description}</p>\n )}\n </div>\n );\n }\n\n // text, password, number 类型\n const placeholder = field.sensitiveField && settings && field.lengthKey && (settings[field.lengthKey] as number) > 0\n ? t('settings.fields.apiKeySet', { length: settings[field.lengthKey] as string | number })\n : field.placeholder || '';\n\n // 判断是否禁用(思考负载字段在对应开关关闭时禁用)\n let isDisabled = false;\n if (field.key === 'text_thinking_budget') {\n isDisabled = !formData.enable_text_reasoning;\n } else if (field.key === 'image_thinking_budget') {\n isDisabled = !formData.enable_image_reasoning;\n }\n\n return (\n <div key={field.key} className={isDisabled ? 'opacity-50' : ''}>\n <Input\n label={field.label}\n type={field.type === 'number' ? 'number' : field.type}\n placeholder={placeholder}\n value={value as string | number}\n onChange={(e) => {\n const newValue = field.type === 'number' \n ? parseInt(e.target.value) || (field.min ?? 0)\n : e.target.value;\n handleFieldChange(field.key, newValue);\n }}\n min={field.min}\n max={field.max}\n disabled={isDisabled}\n />\n {(field.description || field.link) && (\n <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">\n {field.description}\n {field.link && (\n <a href={field.link} target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-banana-500 hover:underline\">{t('settings.fields.applyLink')}</a>\n )}\n </p>\n )}\n </div>\n );\n };\n\n // 模型配置项定义:每种模型类型的 key、source key、api key/base key、标签等\n const modelConfigItems = [\n {\n modelKey: 'text_model' as keyof typeof initialFormData,\n sourceKey: 'text_model_source' as keyof typeof initialFormData,\n apiKeyKey: 'text_api_key' as keyof typeof initialFormData,\n apiBaseKey: 'text_api_base_url' as keyof typeof initialFormData,\n apiKeyLengthKey: 'text_api_key_length' as keyof SettingsType,\n label: t('settings.fields.textModel'),\n placeholder: t('settings.fields.textModelPlaceholder'),\n description: t('settings.fields.textModelDesc'),\n sourceLabel: t('settings.fields.textModelSource'),\n },\n {\n modelKey: 'image_model' as keyof typeof initialFormData,\n sourceKey: 'image_model_source' as keyof typeof initialFormData,\n apiKeyKey: 'image_api_key' as keyof typeof initialFormData,\n apiBaseKey: 'image_api_base_url' as keyof typeof initialFormData,\n apiKeyLengthKey: 'image_api_key_length' as keyof SettingsType,\n label: t('settings.fields.imageModel'),\n placeholder: t('settings.fields.imageModelPlaceholder'),\n description: t('settings.fields.imageModelDesc'),\n sourceLabel: t('settings.fields.imageModelSource'),\n },\n {\n modelKey: 'image_caption_model' as keyof typeof initialFormData,\n sourceKey: 'image_caption_model_source' as keyof typeof initialFormData,\n apiKeyKey: 'image_caption_api_key' as keyof typeof initialFormData,\n apiBaseKey: 'image_caption_api_base_url' as keyof typeof initialFormData,\n apiKeyLengthKey: 'image_caption_api_key_length' as keyof SettingsType,\n label: t('settings.fields.imageCaptionModel'),\n placeholder: t('settings.fields.imageCaptionModelPlaceholder'),\n description: t('settings.fields.imageCaptionModelDesc'),\n sourceLabel: t('settings.fields.imageCaptionModelSource'),\n },\n ];\n\n // 渲染单个模型配置组(模型名 + 提供商选择 + 条件凭证)\n const renderModelConfigGroup = (item: typeof modelConfigItems[0]) => {\n const sourceValue = formData[item.sourceKey] as string;\n const isApiKeyProvider = API_KEY_PROVIDERS.has(sourceValue);\n const 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 <div key={item.modelKey} className=\"pb-6 border-b border-gray-200 dark:border-border-primary last:border-b-0 last:pb-0 space-y-3\">\n {/* 模型名称 */}\n <Input\n label={item.label}\n type=\"text\"\n placeholder={item.placeholder}\n value={formData[item.modelKey] as string}\n onChange={(e) => handleFieldChange(item.modelKey, e.target.value)}\n />\n {item.description && (\n <p className=\"-mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">{item.description}</p>\n )}\n\n {/* 提供商选择 */}\n <div>\n <label className=\"block text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-2\">\n {item.sourceLabel}\n </label>\n <select\n value={sourceValue}\n onChange={(e) => handleFieldChange(item.sourceKey, e.target.value)}\n className=\"w-full h-10 px-4 rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary focus:outline-none focus:ring-2 focus:ring-banana-500 focus:border-transparent\"\n >\n <option value=\"\">{t('settings.fields.modelProviderPlaceholder')}</option>\n {allProviderSources.map((option) => (\n <option\n key={option.value}\n value={option.value}\n disabled={option.value === 'codex' && !settings?.openai_oauth_connected}\n >\n {option.label}{option.value === 'codex' && !settings?.openai_oauth_connected ? ` (${t('settings.openaiOAuth.disconnected')})` : ''}\n </option>\n ))}\n </select>\n <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">\n {t('settings.fields.modelProviderDesc')}\n </p>\n </div>\n\n {/* Gemini/OpenAI 提供商:显示 API Key,固定 Base URL 的提供商隐藏 Base URL 输入 */}\n {isApiKeyProvider && (\n <div className=\"space-y-3 pl-3 border-l-2 border-banana-300 dark:border-banana-600\">\n {!isFixedApiBaseProvider && (\n <Input\n label={t('settings.fields.perModelApiBaseUrl')}\n type=\"text\"\n placeholder={t('settings.fields.perModelApiBaseUrlPlaceholder')}\n value={formData[item.apiBaseKey] as string}\n onChange={(e) => handleFieldChange(item.apiBaseKey, e.target.value)}\n />\n )}\n <div>\n <Input\n label={t('settings.fields.perModelApiKey')}\n type=\"password\"\n placeholder={\n settings && (settings[item.apiKeyLengthKey] as number) > 0\n ? t('settings.fields.perModelApiKeySet', { length: settings[item.apiKeyLengthKey] as number })\n : t('settings.fields.perModelApiKeyPlaceholder')\n }\n value={formData[item.apiKeyKey] as string}\n onChange={(e) => handleFieldChange(item.apiKeyKey, e.target.value)}\n />\n <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">\n {t('settings.fields.perModelApiKeyDesc')}\n </p>\n </div>\n </div>\n )}\n\n {/* 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 <div className=\"pl-3 border-l-2 border-banana-300 dark:border-banana-600\">\n <label className=\"block text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-2\">\n {t('settings.fields.imageApiProtocol')}\n </label>\n <select\n value={formData.openai_image_api_protocol}\n onChange={(e) => 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 <option value=\"auto\">{t('settings.fields.imageApiProtocolAuto')}</option>\n <option value=\"images\">{t('settings.fields.imageApiProtocolImages')}</option>\n <option value=\"chat\">{t('settings.fields.imageApiProtocolChat')}</option>\n </select>\n <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">\n {t('settings.fields.imageApiProtocolDesc')}\n </p>\n </div>\n )}\n\n {/* LazyLLM 厂商:显示厂商 API Key */}\n {isLazyllm && (() => {\n const vendorLabel = LAZYLLM_SOURCES.find(s => s.value === sourceValue)?.label || sourceValue.toUpperCase();\n const keyLength = settings?.lazyllm_api_keys_info?.[sourceValue] || 0;\n const placeholder = keyLength > 0\n ? t('settings.fields.vendorApiKeySet', { length: keyLength })\n : t('settings.fields.vendorApiKeyPlaceholder', { vendor: vendorLabel });\n return (\n <div className=\"pl-3 border-l-2 border-amber-300 dark:border-amber-600\">\n <Input\n label={t('settings.fields.vendorApiKey', { vendor: vendorLabel })}\n type=\"password\"\n placeholder={placeholder}\n value={formData.lazyllm_api_keys[sourceValue] || ''}\n onChange={(e) => {\n setFormData(prev => ({\n ...prev,\n lazyllm_api_keys: { ...prev.lazyllm_api_keys, [sourceValue]: e.target.value }\n }));\n }}\n />\n <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">\n {t('settings.fields.vendorApiKeyDesc')}\n </p>\n </div>\n );\n })()}\n </div>\n );\n };\n\n if (isLoading) {\n return (\n <div className=\"flex items-center justify-center py-12\">\n <Loading message={t('common.loading')} />\n </div>\n );\n }\n\n return (\n <>\n <ToastContainer />\n {ConfirmDialog}\n <div className=\"space-y-8\">\n {/* 默认 API 配置区块 */}\n <div data-testid=\"global-api-config-section\">\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-foreground-primary mb-1 flex items-center\">\n <Key size={20} />\n <span className=\"ml-2\">{t('settings.sections.apiConfig')}</span>\n </h2>\n <p className=\"text-sm text-gray-500 dark:text-foreground-tertiary mb-4\">{t('settings.sections.apiConfigDesc')}</p>\n <div className=\"space-y-3\">\n {/* 提供商下拉 */}\n <div>\n <label className=\"block text-sm font-medium text-gray-700 dark:text-foreground-secondary mb-2\">\n {t('settings.fields.aiProviderFormat')}\n </label>\n <select\n value={formData.ai_provider_format}\n onChange={(e) => handleFieldChange('ai_provider_format', e.target.value)}\n className=\"w-full h-10 px-4 rounded-lg border border-gray-200 dark:border-border-primary bg-white dark:bg-background-secondary focus:outline-none focus:ring-2 focus:ring-banana-500 focus:border-transparent\"\n >\n {allProviderSources.map((option) => (\n <option\n key={option.value}\n value={option.value}\n disabled={option.value === 'codex' && !settings?.openai_oauth_connected}\n >\n {option.label}{option.value === 'codex' && !settings?.openai_oauth_connected ? ` (${t('settings.openaiOAuth.disconnected')})` : ''}\n </option>\n ))}\n </select>\n <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">{t('settings.fields.aiProviderFormatDesc')}</p>\n </div>\n\n {/* Gemini/OpenAI: API Key;固定 Base URL 的提供商隐藏 Base URL 输入 */}\n {API_KEY_PROVIDERS.has(formData.ai_provider_format) && (\n <div className=\"space-y-3 pl-3 border-l-2 border-banana-300 dark:border-banana-600\">\n {!FIXED_BASE_URL_PROVIDERS.has(formData.ai_provider_format) && (\n <>\n <Input\n label={t('settings.fields.apiBaseUrl')}\n type=\"text\"\n placeholder={t('settings.fields.apiBaseUrlPlaceholder')}\n value={formData.api_base_url}\n onChange={(e) => handleFieldChange('api_base_url', e.target.value)}\n />\n <p className=\"-mt-2 text-sm text-gray-500 dark:text-foreground-tertiary\">{t('settings.fields.apiBaseUrlDesc')}</p>\n </>\n )}\n <div>\n <Input\n label={t('settings.fields.apiKey')}\n type=\"password\"\n placeholder={\n settings && (settings.api_key_length as number) > 0\n ? t('settings.fields.apiKeySet', { length: settings.api_key_length })\n : t('settings.fields.apiKeyPlaceholder')\n }\n value={formData.api_key}\n onChange={(e) => handleFieldChange('api_key', e.target.value)}\n />\n <p className=\"mt-1 text-sm text-gray-500 dark:text-foreground-tertiary\">{t('settings.fields.apiKeyDesc')}</p>\n </div>\n </div>\n )}\n\n {/* LazyLLM 厂商: 厂商 API Key */}\n {isLazyllmVendor(formData.ai_provider_format) && (\n <GlobalVendorKeyInput vendor={formData.ai_provider_format} formData={formData} setFormData={setFormData} settings={settings} t={t} />\n )}\n </div>\n\n {usesVolcengineCampaignPromo ? (\n <div className=\"mt-3 pl-4 border-l-4 border-amber-300 dark:border-amber-600\">\n <div className=\"flex flex-col gap-3 sm:flex-row sm:items-center\">\n <a href={volcengineAgentPlansUrl} target=\"_blank\" rel=\"noopener noreferrer\" className=\"shrink-0\">\n <img\n src={volcengineLogoUrl}\n alt={isZh ? '火山引擎' : 'BytePlus'}\n className=\"h-9 w-auto max-w-[160px] object-contain\"\n />\n </a>\n <div className=\"space-y-2\">\n <p className=\"text-sm font-medium text-gray-800 dark:text-foreground-primary\">\n {t(`${activeVolcenginePromoKey}.title`)}\n </p>\n <p className=\"text-sm text-gray-700 dark:text-foreground-secondary\">\n {t(`${activeVolcenginePromoKey}.body`)}{' '}\n <a href={volcengineAgentPlansUrl} target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-amber-700 hover:text-amber-800 dark:text-amber-300 dark:hover:text-amber-200 underline font-medium\">\n {t(`${activeVolcenginePromoKey}.cta`)}\n </a>\n </p>\n <div className=\"pt-1\">\n <p className=\"text-sm font-medium text-gray-800 dark:text-foreground-primary flex items-center gap-1.5\">\n <HelpCircle size={15} className=\"text-amber-500\" />\n {t(`${activeApiKeyHelpKey}.title`)}\n </p>\n <ol className=\"mt-1 text-sm text-gray-700 dark:text-foreground-secondary space-y-1 list-decimal list-inside\">\n <li>\n {t(`${activeApiKeyHelpKey}.step1`)}{' '}\n <span className=\"inline-flex items-center gap-2\">\n <a\n href={volcengineAgentPlansUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-amber-700 hover:text-amber-800 dark:text-amber-300 dark:hover:text-amber-200 underline font-medium\"\n >\n {t(`${activeVolcenginePromoKey}.guideLink`)}\n </a>\n <button\n onClick={() => 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 </button>\n </span>\n </li>\n <li>{t(`${activeApiKeyHelpKey}.step2`)}</li>\n <li>{t(`${activeApiKeyHelpKey}.step3`)}</li>\n <li>{t(`${activeApiKeyHelpKey}.step4`)}</li>\n </ol>\n </div>\n <div className=\"pt-1\">\n <Button\n variant=\"secondary\"\n size=\"sm\"\n onClick={applyVolcengineRecommendedModels}\n >\n {t(`${activeVolcenginePromoKey}.applyModels`)}\n </Button>\n </div>\n </div>\n </div>\n </div>\n ) : (\n <div className=\"mt-3 pl-4 border-l-4 border-blue-300 dark:border-blue-600\">\n <p className=\"text-sm text-gray-700 dark:text-foreground-secondary\">\n {t('settings.apiKeyTip.before')}\n <a href={INFERERA_AFFILIATE_URL} target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-blue-600 hover:text-blue-800 underline font-medium\">{t('settings.apiKeyTip.linkLabel')}</a>\n {t('settings.apiKeyTip.after')}\n </p>\n </div>\n )}\n\n {/* API Key 获取指南 */}\n {!usesVolcengineCampaignPromo && (\n <div className=\"mt-2 pl-4 border-l-4 border-blue-300 dark:border-blue-600\">\n <p className=\"text-sm font-medium text-gray-800 dark:text-foreground-primary flex items-center gap-1.5 mb-2\">\n <HelpCircle size={15} className=\"text-blue-500\" />\n {t(`${activeApiKeyHelpKey}.title`)}\n </p>\n <ol className=\"text-sm text-gray-700 dark:text-foreground-secondary space-y-1 list-decimal list-inside ml-1\">\n <li>\n {t('settings.apiKeyHelp.step1', { link: '{{link}}' }).split('{{link}}')[0]}\n <span className=\"inline-flex items-center gap-2\">\n <a\n href={INFERERA_AFFILIATE_URL}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-blue-600 hover:text-blue-800 underline font-medium\"\n >\n {t('settings.apiKeyHelp.linkLabel')}\n </a>\n <button\n onClick={() => 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 </button>\n </span>\n {t('settings.apiKeyHelp.step1', { link: '{{link}}' }).split('{{link}}')[1]}\n </li>\n <li>{t('settings.apiKeyHelp.step2')}</li>\n <li>{t('settings.apiKeyHelp.step3')}</li>\n <li>{t('settings.apiKeyHelp.step4')}</li>\n </ol>\n </div>\n )}\n </div>\n\n {/* 模型配置区块 */}\n <div>\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-foreground-primary mb-4 flex items-center\">\n <FileText size={20} />\n <span className=\"ml-2\">{t('settings.sections.modelConfig')}</span>\n </h2>\n <div className=\"space-y-4\">\n {modelConfigItems.map(renderModelConfigGroup)}\n </div>\n </div>\n\n {/* 其余配置区块(配置驱动,排除性能配置和推理模式) */}\n <div className=\"space-y-8\">\n {settingsSections.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 <div key={section.title}>\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-foreground-primary mb-4 flex items-center\">\n {section.icon}\n <span className=\"ml-2\">{section.title}</span>\n </h2>\n <div className=\"space-y-4\">\n {section.fields.map((field) => renderField(field))}\n </div>\n </div>\n ))}\n </div>\n\n {/* 高级设置(折叠区域) */}\n <div className=\"border-t border-gray-200 dark:border-border-primary pt-2\">\n <button\n type=\"button\"\n onClick={() => setAdvancedOpen(!advancedOpen)}\n className=\"w-full flex items-center justify-between px-0 py-3 text-left hover:opacity-80 transition-opacity\"\n >\n <span className=\"text-lg font-semibold text-gray-900 dark:text-foreground-primary\">\n {t('settings.sections.advancedSettings')}\n </span>\n <ChevronDown\n size={20}\n className={`text-gray-500 dark:text-foreground-tertiary transition-transform duration-200 ${advancedOpen ? 'rotate-180' : ''}`}\n />\n </button>\n {advancedOpen && (\n <div className=\"pb-4 space-y-8\">\n {isDesktop && <DataStorageSettings />}\n\n {/* OpenAI OAuth 连接区块 */}\n <div>\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-foreground-primary mb-1 flex items-center\">\n <Link2 size={20} />\n <span className=\"ml-2\">{t('settings.openaiOAuth.title')}</span>\n </h2>\n <p className=\"text-sm text-gray-500 dark:text-foreground-tertiary mb-4\">{t('settings.openaiOAuth.description')}</p>\n <div className=\"p-4 border border-gray-200 dark:border-border-primary rounded-lg\">\n <div className=\"flex items-center justify-between\">\n <div className=\"flex items-center gap-3\">\n <div className={`w-2.5 h-2.5 rounded-full ${settings?.openai_oauth_connected ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`} />\n <div>\n <span className=\"text-sm font-medium text-gray-700 dark:text-foreground-secondary\">\n {settings?.openai_oauth_connected ? t('settings.openaiOAuth.connected') : t('settings.openaiOAuth.disconnected')}\n </span>\n {settings?.openai_oauth_connected && settings?.openai_oauth_account_id && (\n <span className=\"ml-2 text-sm text-gray-500 dark:text-foreground-tertiary\">\n ({settings.openai_oauth_account_id})\n </span>\n )}\n </div>\n </div>\n <div>\n {settings?.openai_oauth_connected ? (\n <button\n onClick={handleOAuthDisconnect}\n className=\"px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors\"\n >\n {t('settings.openaiOAuth.disconnectBtn')}\n </button>\n ) : (\n <button\n onClick={handleOAuthLogin}\n disabled={oauthConnecting}\n className=\"px-4 py-2 text-sm font-medium text-white bg-gray-900 dark:bg-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors disabled:opacity-50\"\n >\n {oauthConnecting ? t('settings.openaiOAuth.connecting') : t('settings.openaiOAuth.loginBtn')}\n </button>\n )}\n </div>\n </div>\n <p className=\"mt-3 text-xs text-gray-500 dark:text-foreground-tertiary\">{t('settings.openaiOAuth.hint')}</p>\n {!settings?.openai_oauth_connected && (\n <div className=\"mt-3\">\n <button\n onClick={() => setManualCallbackOpen(v => !v)}\n className=\"text-xs text-blue-600 dark:text-blue-400 hover:underline\"\n >\n {t('settings.openaiOAuth.manualCallbackLabel')}\n </button>\n {manualCallbackOpen && (\n <div className=\"mt-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg\">\n <p className=\"text-xs text-amber-700 dark:text-amber-300 mb-2\">{t('settings.openaiOAuth.manualCallbackHint')}</p>\n <div className=\"flex gap-2\">\n <input\n type=\"text\"\n value={manualCallbackUrl}\n onChange={(e) => 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 <button\n onClick={handleManualCallback}\n disabled={manualCallbackSubmitting || !manualCallbackUrl.trim()}\n className=\"px-3 py-1.5 text-xs font-medium text-white bg-gray-900 dark:bg-white dark:text-gray-900 rounded-md hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors disabled:opacity-50\"\n >\n {t('settings.openaiOAuth.manualCallbackSubmit')}\n </button>\n </div>\n </div>\n )}\n </div>\n )}\n </div>\n </div>\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 <div key={section.title}>\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-foreground-primary mb-4 flex items-center\">\n {section.icon}\n <span className=\"ml-2\">{section.title}</span>\n </h2>\n <div className=\"space-y-4\">\n {section.fields.map((field) => renderField(field))}\n </div>\n </div>\n ))}\n </div>\n )}\n </div>\n\n {/* 服务测试区 */}\n <div className=\"space-y-4\">\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-foreground-primary mb-2 flex items-center\">\n <FileText size={20} />\n <span className=\"ml-2\">{t('settings.serviceTest.title')}</span>\n </h2>\n <p className=\"text-sm text-gray-500 dark:text-foreground-tertiary\">\n {t('settings.serviceTest.description')}\n </p>\n <div className=\"pl-4 border-l-4 border-yellow-300 dark:border-yellow-600\">\n <p className=\"text-sm text-gray-700 dark:text-foreground-secondary flex items-start gap-1.5\">\n <Lightbulb size={15} className=\"flex-shrink-0 mt-0.5\" />\n {t('settings.serviceTest.tip')}\n </p>\n </div>\n <div className=\"space-y-4\">\n {[\n {\n key: 'baidu-ocr',\n titleKey: 'settings.serviceTest.tests.baiduOcr.title',\n descriptionKey: 'settings.serviceTest.tests.baiduOcr.description',\n resultKey: 'settings.serviceTest.results.recognizedText',\n action: api.testBaiduOcr,\n formatDetail: (data: any) => (data?.recognized_text ? t('settings.serviceTest.results.recognizedText', { text: data.recognized_text }) : ''),\n },\n {\n key: 'text-model',\n titleKey: 'settings.serviceTest.tests.textModel.title',\n descriptionKey: 'settings.serviceTest.tests.textModel.description',\n resultKey: 'settings.serviceTest.results.modelReply',\n action: api.testTextModel,\n formatDetail: (data: any) => (data?.reply ? t('settings.serviceTest.results.modelReply', { reply: data.reply }) : ''),\n },\n {\n key: 'caption-model',\n titleKey: 'settings.serviceTest.tests.captionModel.title',\n descriptionKey: 'settings.serviceTest.tests.captionModel.description',\n resultKey: 'settings.serviceTest.results.captionDesc',\n action: api.testCaptionModel,\n formatDetail: (data: any) => (data?.caption ? t('settings.serviceTest.results.captionDesc', { caption: data.caption }) : ''),\n },\n {\n key: 'baidu-inpaint',\n titleKey: 'settings.serviceTest.tests.baiduInpaint.title',\n descriptionKey: 'settings.serviceTest.tests.baiduInpaint.description',\n resultKey: 'settings.serviceTest.results.imageSize',\n action: api.testBaiduInpaint,\n formatDetail: (data: any) => (data?.image_size ? t('settings.serviceTest.results.imageSize', { width: data.image_size[0], height: data.image_size[1] }) : ''),\n },\n {\n key: 'image-model',\n titleKey: 'settings.serviceTest.tests.imageModel.title',\n descriptionKey: 'settings.serviceTest.tests.imageModel.description',\n resultKey: 'settings.serviceTest.results.imageSize',\n action: api.testImageModel,\n formatDetail: (data: any) => (data?.image_size ? t('settings.serviceTest.results.imageSize', { width: data.image_size[0], height: data.image_size[1] }) : ''),\n },\n {\n key: 'mineru-pdf',\n titleKey: 'settings.serviceTest.tests.mineruPdf.title',\n descriptionKey: 'settings.serviceTest.tests.mineruPdf.description',\n resultKey: 'settings.serviceTest.results.parsePreview',\n action: api.testMineruPdf,\n formatDetail: (data: any) => (data?.content_preview ? t('settings.serviceTest.results.parsePreview', { preview: data.content_preview }) : data?.message || ''),\n },\n ].map((item) => {\n const testState = serviceTestStates[item.key] || { status: 'idle' as TestStatus };\n const isLoadingTest = testState.status === 'loading';\n return (\n <div\n key={item.key}\n className=\"py-4 border-b border-gray-200 dark:border-border-primary last:border-b-0 space-y-2\"\n >\n <div className=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n <div>\n <div className=\"text-base font-semibold text-gray-800 dark:text-foreground-primary\">{t(item.titleKey)}</div>\n <div className=\"text-sm text-gray-500 dark:text-foreground-tertiary\">{t(item.descriptionKey)}</div>\n </div>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n loading={isLoadingTest}\n onClick={() => handleServiceTest(item.key, item.action, item.formatDetail)}\n >\n {isLoadingTest ? t('settings.serviceTest.testing') : t('settings.serviceTest.startTest')}\n </Button>\n </div>\n {testState.status === 'success' && (\n <p className=\"text-sm text-green-600\">\n {testState.message}{testState.detail ? `|${testState.detail}` : ''}\n </p>\n )}\n {testState.status === 'error' && (\n <p className=\"text-sm text-red-600\">\n {testState.message}\n </p>\n )}\n </div>\n );\n })}\n </div>\n </div>\n\n {/* 操作按钮 */}\n <div className=\"flex items-center justify-between pt-4 border-t border-gray-200 dark:border-border-primary\">\n <Button\n variant=\"secondary\"\n icon={<RotateCcw size={18} />}\n onClick={handleReset}\n disabled={isSaving}\n >\n {t('settings.actions.resetToDefault')}\n </Button>\n <Button\n variant=\"primary\"\n icon={<Save size={18} />}\n onClick={handleSave}\n loading={isSaving}\n >\n {isSaving ? t('settings.actions.saving') : t('settings.actions.save')}\n </Button>\n </div>\n\n <SettingsAbout t={t} />\n </div>\n </>\n );\n};\n\n// SettingsPage 组件 - 完整页面包装\nconst SCROLL_SHOW_THRESHOLD = 300;\n\nexport const SettingsPage: React.FC = () => {\n const navigate = useNavigate();\n const 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 <div className=\"min-h-screen bg-gradient-to-br from-banana-50 dark:from-background-primary to-yellow-50 dark:to-background-primary\">\n <div className=\"container mx-auto px-4 py-8 max-w-4xl\">\n <Card className=\"p-6 md:p-8\">\n <div className=\"space-y-8\">\n {/* 顶部标题 */}\n <div className=\"flex items-center justify-between pb-6 border-b border-gray-200 dark:border-border-primary\">\n <div className=\"flex items-center\">\n <Button\n variant=\"secondary\"\n icon={<Home size={18} />}\n onClick={handleBack}\n className=\"mr-4\"\n >\n {t('nav.backToHome')}\n </Button>\n <div>\n <h1 className=\"text-2xl font-bold text-gray-900 dark:text-foreground-primary\">{t('settings.title')}</h1>\n <p className=\"text-sm text-gray-500 dark:text-foreground-tertiary mt-1\">\n {t('settings.subtitle')}\n </p>\n </div>\n </div>\n </div>\n\n <Settings />\n </div>\n </Card>\n </div>\n\n {showTop && (\n <button\n data-testid=\"back-to-top-button\"\n aria-label=\"Back to top\"\n title=\"Back to top\"\n onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}\n className=\"fixed bottom-6 right-6 p-3 rounded-full bg-banana-500 text-white shadow-lg hover:bg-banana-600 transition-all z-50\"\n >\n <ArrowUp size={20} />\n </button>\n )}\n </div>\n );\n};\n"}}}}