diff --git a/.gitignore b/.gitignore index 73aba2f..7d98d3f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ node_modules/ # 输出目录 output/ test_output/ +.playwright-cli/ temp_buid_note/ # 生成的文件 @@ -27,6 +28,16 @@ temp_buid_note/ *.pdf test_prompts.json +# README 演示素材需要随仓库保留,方便 clone 后直接查看效果 +!docs/ +!docs/assets/ +!docs/assets/*.png +!docs/assets/*.jpg +!docs/assets/*.jpeg +!docs/assets/*.gif +!docs/assets/*.mp4 +!docs/assets/*.webm + # IDE .vscode/ .idea/ @@ -43,4 +54,5 @@ Thumbs.db # 本地配置文件 config.yaml +.codex/ .kiro/ diff --git a/README.md b/README.md index 0a798c4..14dfafc 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,20 @@ 复刻 NotebookLM 的 AI PPT 功能,将论文、文档等资料自动转换为精美的 PPT 图片。 + + +[查看演示视频](docs/assets/aippt-demo.webm) + +演示视频覆盖上传 `doc/L9.md`、填写用户要求、生成并编辑设计大纲、确认逐页设计、生成 6 页 PPT、单页编辑、确认替换、导出 PDF/PPTX;模型等待阶段已做快进剪辑。 + ## ✨ 功能特性 -- 🎨 **AI 图片生成**:使用 AI 模型将文档内容转换为精美的 PPT 图片 -- 🌐 **WebUI 界面**:提供友好的 Web 界面,支持文件上传、实时预览、编辑和导出 -- 📝 **多格式支持**:支持 Markdown 文档输入,PDF/PPTX 格式导出 -- ✏️ **图生图编辑**:支持对生成的幻灯片进行二次编辑修改 +- 🎨 **逐页图片生成**:先生成可编辑设计大纲和逐页设计,再使用 AI 模型转换为 PPT 页面图片 +- 🌐 **PPT 工作台**:支持资料上传、模型配置、当前页大预览、缩略图列表、编辑历史和导出 +- 📝 **多格式解析**:支持 `.md/.txt/.pdf/.docx/.pptx` 输入,统一转 Markdown 后生成 +- ✏️ **整页图像编辑**:支持对每页幻灯片单独二次编辑、历史回退和确认替换 +- 🔀 **三模型角色**:支持 `prompt_model`、`image_model`、`edit_model` 分别配置 +- 🖼️ **图像结果兼容**:兼容 URL、Markdown 图片链接、data URL、`b64_json` 和纯 base64 - 💾 **状态持久化**:自动保存工作进度,支持会话恢复 ## 🚀 快速开始 @@ -56,10 +64,10 @@ cd web && npm install && npm run dev pip install -r requirements.txt # 基础用法 -python main.py -i doc/sample_paper.txt -n 5 +python main.py -i doc/L9.md -n 5 # 仅生成 Prompt -python main.py -i doc/sample_paper.txt -n 5 --prompt-only -o prompts.json +python main.py -i doc/L9.md -n 5 --prompt-only -o prompts.json # 从 Prompt 文件生成 python main.py --from-prompt prompts.json @@ -67,12 +75,15 @@ python main.py --from-prompt prompts.json ### 3. WebUI 使用流程 -1. **上传文档**:在左侧面板拖拽或点击上传 Markdown 文件 -2. **配置 API**:在中间面板填写 API Key 和 Base URL -3. **设置参数**:选择页数、清晰度、比例等生成参数 -4. **生成 PPT**:点击"开始生成"按钮,实时查看生成进度 -5. **预览编辑**:在右侧面板预览生成的幻灯片,点击可进行编辑 -6. **导出文件**:选择 PDF 或 PPTX 格式导出 +1. **上传文档**:在左侧面板拖拽或点击上传资料文件 +2. **配置模型**:在中间面板配置文本、生图和编辑模型 +3. **设置参数与要求**:选择页数、清晰度、比例、语言、风格、受众,并填写用户定制要求 +4. **确认设计**:先生成设计大纲,用户可编辑后确认,再生成逐页设计预览 +5. **生成 PPT**:确认逐页设计后生成 PPT 图片,实时查看进度 +6. **预览编辑**:在右侧面板预览生成的幻灯片,点击可进行单页编辑 +7. **导出文件**:选择 PDF 或 PPTX 格式导出 + +仓库内置演示资料为 `doc/L9.md`。该路径是仓库相对路径,clone 后可直接用于 WebUI 上传或命令行示例。 ## 📁 项目结构 @@ -83,6 +94,7 @@ OpenNotebookLM-AIPPT/ ├── web/ # React 前端 ├── tests/ # 测试 ├── doc/ # 输入文档目录 +│ └── L9.md # 默认演示资料 ├── config.yaml # 配置文件 ├── start.sh # 一键启动脚本 └── main.py # 命令行入口 @@ -91,7 +103,7 @@ OpenNotebookLM-AIPPT/ ## ⚙️ 配置说明 所有配置统一在 `config.yaml` 中管理,包括: -- API 配置(图像生成、文本生成) +- API 配置(文本 prompt、生图、编辑三角色模型) - PPT 默认配置(语言、风格、页数) - 超时和重试配置 @@ -101,11 +113,22 @@ OpenNotebookLM-AIPPT/ ```yaml api: - text: - format: "openai" - model: "gpt-4o" - base_url: "https://api.openai.com/v1" - api_key: "sk-xxx" + models: + prompt_model: + adapter: "openai_chat" + model: "gpt-4o" + base_url: "https://api.openai.com/v1" + api_key: "sk-xxx" + image_model: + adapter: "raw_chat_multimodal" + model: "gpt-image-2" + base_url: "https://api.example.com/v1" + api_key: "sk-xxx" + edit_model: + adapter: "raw_chat_multimodal" + model: "gpt-image-2" + base_url: "https://api.example.com/v1" + api_key: "sk-xxx" ``` ## 📤 输出结构 @@ -121,8 +144,8 @@ output/ppt_20241201_123456/ ## 📋 TODO -- [ ] 🔌 兼容更多 LLM API 接口 -- [ ] 💾 兼容多种输入格式,更多导出格式 +- [ ] 支持框选局部区域编辑 +- [ ] 增加更多 provider profile 模板 ## 📄 许可证 diff --git a/README_en.md b/README_en.md index 68b3063..67fa49c 100644 --- a/README_en.md +++ b/README_en.md @@ -4,12 +4,20 @@ Recreate NotebookLM's AI PPT feature to automatically convert papers, documents, and other materials into beautiful PPT images. + + +[Watch the demo video](docs/assets/aippt-demo.webm) + +The demo covers uploading `doc/L9.md`, entering custom requirements, generating and editing the design outline, confirming page designs, generating a 6-slide deck, editing one slide, confirming the replacement, and exporting PDF/PPTX. Model waiting time is fast-forwarded. + ## ✨ Features -- 🎨 **AI Image Generation**: Use AI models to convert document content into beautiful PPT images -- 🌐 **WebUI Interface**: User-friendly web interface with file upload, real-time preview, editing, and export -- 📝 **Multi-format Support**: Markdown document input, PDF/PPTX format export -- ✏️ **Image-to-Image Editing**: Support secondary editing of generated slides +- 🎨 **Per-slide image generation**: Create an editable outline and page designs before converting them into PPT page images +- 🌐 **PPT Workbench**: Upload sources, configure model roles, preview slides, edit pages, track history, and export +- 📝 **Multi-format parsing**: Supports `.md/.txt/.pdf/.docx/.pptx` input and converts content to Markdown +- ✏️ **Full-page image editing**: Edit each generated slide independently, revert history, and confirm replacements +- 🔀 **Three model roles**: Configure `prompt_model`, `image_model`, and `edit_model` separately +- 🖼️ **Image result compatibility**: Accepts URLs, Markdown image links, data URLs, `b64_json`, and raw base64 - 💾 **State Persistence**: Auto-save work progress with session recovery ## 🚀 Quick Start @@ -56,10 +64,10 @@ cd web && npm install && npm run dev pip install -r requirements.txt # Basic usage -python main.py -i doc/sample_paper.txt -n 5 +python main.py -i doc/L9.md -n 5 # Generate prompts only -python main.py -i doc/sample_paper.txt -n 5 --prompt-only -o prompts.json +python main.py -i doc/L9.md -n 5 --prompt-only -o prompts.json # Generate from prompt file python main.py --from-prompt prompts.json @@ -67,12 +75,15 @@ python main.py --from-prompt prompts.json ### 3. WebUI Usage Flow -1. **Upload Document**: Drag and drop or click to upload Markdown files in the left panel -2. **Configure API**: Fill in API Key and Base URL in the center panel -3. **Set Parameters**: Choose page count, resolution, aspect ratio, etc. -4. **Generate PPT**: Click "Start Generation" button and watch real-time progress -5. **Preview & Edit**: Preview generated slides in the right panel, click to edit -6. **Export**: Choose PDF or PPTX format to export +1. **Upload Document**: Drag and drop or click to upload a source file in the left panel +2. **Configure Models**: Configure text, image generation, and image editing model roles +3. **Set Parameters & Requirements**: Choose page count, resolution, aspect ratio, language, style, audience, and custom requirements +4. **Confirm Design**: Generate an editable outline, confirm it, then review the generated page designs +5. **Generate PPT**: Generate slide images after page-design confirmation and watch real-time progress +6. **Preview & Edit**: Preview generated slides in the right panel and edit a single page when needed +7. **Export**: Export to PDF or PPTX + +The built-in demo source is `doc/L9.md`. This is a repository-relative path, so a fresh clone can use it directly in the WebUI or CLI examples. ## 📁 Project Structure @@ -83,6 +94,7 @@ OpenNotebookLM-AIPPT/ ├── web/ # React frontend ├── tests/ # Tests ├── doc/ # Input documents directory +│ └── L9.md # Default demo source ├── config.yaml # Configuration file ├── start.sh # One-click startup script └── main.py # CLI entry point @@ -91,7 +103,7 @@ OpenNotebookLM-AIPPT/ ## ⚙️ Configuration All configurations are managed in `config.yaml`, including: -- API configuration (image generation, text generation) +- API configuration (`prompt_model`, `image_model`, `edit_model`) - PPT default settings (language, style, page count) - Timeout and retry settings @@ -101,11 +113,22 @@ See `config.example.yaml` for detailed configuration examples. ```yaml api: - text: - format: "openai" - model: "gpt-4o" - base_url: "https://api.openai.com/v1" - api_key: "sk-xxx" + models: + prompt_model: + adapter: "openai_chat" + model: "gpt-4o" + base_url: "https://api.openai.com/v1" + api_key: "sk-xxx" + image_model: + adapter: "raw_chat_multimodal" + model: "gpt-image-2" + base_url: "https://api.example.com/v1" + api_key: "sk-xxx" + edit_model: + adapter: "raw_chat_multimodal" + model: "gpt-image-2" + base_url: "https://api.example.com/v1" + api_key: "sk-xxx" ``` ## 📤 Output Structure @@ -121,8 +144,8 @@ output/ppt_20241201_123456/ ## 📋 TODO -- [ ] 🔌 Support more LLM API interfaces -- [ ] 💾 Support multiple input formats and export formats +- [ ] Support region selection for partial slide editing +- [ ] Add more provider profile templates ## 📄 License diff --git a/api/README.md b/api/README.md index e55f50a..c92138f 100644 --- a/api/README.md +++ b/api/README.md @@ -4,10 +4,11 @@ FastAPI 后端服务,为 WebUI 前端提供 PPT 生成、编辑和导出功能 ## 功能特性 -- **文件上传**: 支持上传 Markdown 文件作为输入材料 +- **文件上传**: 支持 `.md/.txt/.pdf/.docx/.pptx`,统一解析为 Markdown - **PPT 生成**: 基于输入材料生成 PPT 幻灯片,支持流式返回进度 - **图生图编辑**: 对单页幻灯片进行修改 - **导出功能**: 支持导出为 PDF 和 PPTX 格式 +- **模型路由**: 支持 prompt/image/edit 三角色模型 profile ## API 端点 @@ -22,7 +23,7 @@ POST /api/upload Content-Type: multipart/form-data 参数: -- file: Markdown 文件 (.md) +- file: 文档文件 (.md/.txt/.pdf/.docx/.pptx) 响应: { @@ -42,8 +43,20 @@ Content-Type: application/json { "content": "Markdown 内容", "config": { - "api_key": "your-api-key", - "base_url": "https://api.example.com", + "model_profiles": { + "prompt_model": { + "model": "DeepSeek-V4-Pro", + "base_url": "https://api.example.com/v1", + "api_key": "your-text-key", + "adapter": "openai_chat" + }, + "image_model": { + "model": "gpt-image-2", + "base_url": "https://api.example.com/v1", + "api_key": "your-image-key", + "adapter": "raw_chat_multimodal" + } + }, "page_count": 10, "quality": "1K", "aspect_ratio": "16:9" @@ -67,8 +80,11 @@ Content-Type: application/json "image_base64": "base64编码的图片", "instruction": "修改指令", "config": { - "api_key": "your-api-key", - "base_url": "https://api.example.com", + "model_profiles": { + "prompt_model": {"model": "DeepSeek-V4-Pro", "base_url": "https://api.example.com/v1", "api_key": "key", "adapter": "openai_chat"}, + "image_model": {"model": "gpt-image-2", "base_url": "https://api.example.com/v1", "api_key": "key", "adapter": "raw_chat_multimodal"}, + "edit_model": {"model": "gpt-image-2", "base_url": "https://api.example.com/v1", "api_key": "key", "adapter": "raw_chat_multimodal"} + }, "quality": "1K", "aspect_ratio": "16:9" } @@ -92,7 +108,8 @@ Content-Type: application/json "slides": [ {"image_base64": "base64编码的图片"} ], - "format": "pdf" | "pptx" + "format": "pdf" | "pptx", + "aspect_ratio": "16:9" | "4:3" } 响应: 文件下载 @@ -138,6 +155,7 @@ api/ │ ├── upload.py # 文件上传路由 │ ├── generate.py # PPT 生成路由 │ ├── edit.py # 图生图编辑路由 +│ ├── models.py # 模型 profile 路由 │ └── export.py # 导出路由 └── README.md ``` @@ -146,8 +164,9 @@ api/ API 服务器直接使用项目中现有的 Python 模块: -- `src.generator.PPTGenerator` - PPT 生成器 -- `src.client.AIClient` - AI 客户端 +- `src.model_router.ModelRouter` - 三角色模型路由 +- `src.image_result.ImageResultNormalizer` - URL/base64 图片结果规范化 +- `src.document_parser.DocumentParser` - 文档解析 - `src.exporter.PDFExporter` - PDF 导出器 - `src.config` - 配置管理 diff --git a/api/main.py b/api/main.py index cfcb36d..fd70d75 100644 --- a/api/main.py +++ b/api/main.py @@ -9,7 +9,7 @@ from pathlib import Path # 导入路由 -from .routes import upload, generate, edit, export +from .routes import upload, generate, edit, export, models # 创建 FastAPI 应用 app = FastAPI( @@ -39,6 +39,7 @@ async def health_check(): app.include_router(generate.router) app.include_router(edit.router) app.include_router(export.router) +app.include_router(models.router) # 静态文件服务(用于前端) # 检查 web/dist 目录是否存在,必须放在最后 diff --git a/api/models.py b/api/models.py index 239f271..dc0a531 100644 --- a/api/models.py +++ b/api/models.py @@ -35,12 +35,31 @@ class TextApiConfig(BaseModel): thinking_level: Optional[Literal["low", "high"]] = Field(None, description="思考深度") +class ModelProfileConfig(BaseModel): + """模型 profile 配置""" + id: Optional[str] = Field(None, description="Profile ID") + label: Optional[str] = Field(None, description="显示名称") + model: str = Field(..., description="模型名称") + base_url: str = Field(..., description="OpenAI-compatible Base URL") + api_key: str = Field(..., description="API Key") + adapter: str = Field("openai_chat", description="适配器") + + +class ModelProfilesConfig(BaseModel): + """三角色模型配置""" + prompt_model: ModelProfileConfig + image_model: ModelProfileConfig + edit_model: Optional[ModelProfileConfig] = None + + class GenerationConfig(BaseModel): """生成配置(完整版)""" # 图像模型配置 image: Optional[ImageApiConfig] = Field(None, description="图像模型配置") # 文本模型配置 text: Optional[TextApiConfig] = Field(None, description="文本模型配置") + # 新模型 profile 配置 + model_profiles: Optional[ModelProfilesConfig] = Field(None, description="三角色模型配置") # 向后兼容的简单配置 api_key: Optional[str] = Field(None, description="API 密钥(向后兼容)") base_url: Optional[str] = Field(None, description="API 基础 URL(向后兼容)") @@ -52,6 +71,7 @@ class GenerationConfig(BaseModel): language: str = Field("中文", description="输出语言") style: str = Field("现代简约商务风格", description="PPT 风格") target_audience: str = Field("专业人士", description="目标受众") + user_requirements: str = Field("", description="用户定制要求") def get_image_api_key(self) -> str: """获取图像模型 API 密钥""" @@ -106,6 +126,70 @@ class GenerationRequest(BaseModel): """生成请求""" content: str = Field(..., description="Markdown 内容") config: GenerationConfig + slide_prompts: Optional[List["ConfirmedSlidePrompt"]] = Field( + None, + description="用户确认后的逐页图像 prompt;存在时跳过文本生成阶段" + ) + + +class SlideOutlineData(BaseModel): + """用户可编辑的单页设计大纲""" + page: int = Field(..., ge=1) + title: str + narrative_goal: str + key_points: List[str] = Field(default_factory=list) + visual_direction: str + + +class DeckOutlineData(BaseModel): + """整套 PPT 设计大纲""" + title: str + user_requirements: str = "" + design_style: str + audience: str + slides: List[SlideOutlineData] + + +class ConfirmedSlidePrompt(BaseModel): + """用户确认后的单页设计和图像 prompt""" + page: int = Field(..., ge=1) + title: str + content_summary: str + display_content: Optional[str] = None + prompt: str + + +class OutlineRequest(BaseModel): + """设计大纲生成请求""" + content: str = Field(..., description="Markdown 内容") + config: GenerationConfig + + +class OutlineResponse(BaseModel): + """设计大纲响应""" + success: bool + outline: Optional[DeckOutlineData] = None + message: Optional[str] = None + + +class PromptPlanRequest(BaseModel): + """逐页设计和 prompt 生成请求""" + content: str = Field(..., description="Markdown 内容") + config: GenerationConfig + outline: DeckOutlineData + + +class PromptPlanResponse(BaseModel): + """逐页设计和 prompt 响应""" + success: bool + slide_prompts: Optional[List[ConfirmedSlidePrompt]] = None + message: Optional[str] = None + + +try: + GenerationRequest.model_rebuild() +except AttributeError: + GenerationRequest.update_forward_refs() class SlideData(BaseModel): @@ -144,8 +228,10 @@ class GenerationErrorEvent(BaseModel): class EditConfig(BaseModel): """编辑配置""" - api_key: str = Field(..., description="API 密钥") - base_url: str = Field(..., description="API 基础 URL") + api_key: Optional[str] = Field(None, description="API 密钥") + base_url: Optional[str] = Field(None, description="API 基础 URL") + model: str = Field("gpt-image-2", description="图像编辑模型名称") + model_profiles: Optional[ModelProfilesConfig] = Field(None, description="三角色模型配置") quality: Literal["1K", "2K", "4K"] = Field("1K", description="图片质量") aspect_ratio: Literal["16:9", "4:3"] = Field("16:9", description="图片比例") @@ -175,9 +261,17 @@ class ExportRequest(BaseModel): """导出请求""" slides: List[ExportSlide] format: Literal["pdf", "pptx"] = Field(..., description="导出格式") + aspect_ratio: Literal["16:9", "4:3"] = Field("16:9", description="导出画幅比例") class ExportResponse(BaseModel): """导出响应""" success: bool message: Optional[str] = None + + +class ModelProfilesResponse(BaseModel): + """脱敏模型 profile 响应""" + success: bool + profiles: Optional[dict] = None + message: Optional[str] = None diff --git a/api/profile_resolver.py b/api/profile_resolver.py new file mode 100644 index 0000000..03a1954 --- /dev/null +++ b/api/profile_resolver.py @@ -0,0 +1,112 @@ +""" +Helpers for resolving model profiles from API requests. +""" + +from typing import Any, Dict, Optional + +from src.model_profiles import ModelProfileSet, load_default_profiles, resolve_model_profiles + + +def _has_value(value: Optional[str]) -> bool: + return bool(value and value not in {"SET", "EMPTY"}) + + +def _has_complete_generation_profiles(config: Any) -> bool: + profiles = getattr(config, "model_profiles", None) + if not profiles: + return False + required = (profiles.prompt_model, profiles.image_model) + return all( + _has_value(profile.model) and _has_value(profile.base_url) and _has_value(profile.api_key) + for profile in required + ) + + +def _has_complete_legacy_profiles(config: Any) -> bool: + return bool( + getattr(config, "text", None) + and getattr(config, "image", None) + and _has_value(config.text.model) + and _has_value(config.text.base_url) + and _has_value(config.text.api_key) + and _has_value(config.image.model) + and _has_value(config.image.base_url) + and _has_value(config.image.api_key) + ) + + +def profiles_from_generation_config(config: Any) -> ModelProfileSet: + if _has_complete_generation_profiles(config): + return resolve_model_profiles(config.model_profiles.model_dump()) + + if _has_complete_legacy_profiles(config): + data: Dict[str, Any] = { + "prompt_model": { + "model": config.text.model, + "base_url": config.text.base_url, + "api_key": config.text.api_key, + "adapter": "openai_chat", + }, + "image_model": { + "model": config.image.model, + "base_url": config.image.base_url, + "api_key": config.image.api_key, + "adapter": "raw_chat_multimodal", + }, + } + return resolve_model_profiles(data) + + default_profiles = load_default_profiles() + if default_profiles: + return default_profiles + + api_key = config.get_image_api_key() + base_url = config.get_image_base_url() + if not api_key or not base_url: + raise ValueError("未配置可用模型,请先配置后端 profile 或在请求中提供模型配置") + + return resolve_model_profiles( + { + "prompt_model": { + "model": config.get_text_model(), + "base_url": config.get_text_base_url(), + "api_key": config.get_text_api_key(), + "adapter": "openai_chat", + }, + "image_model": { + "model": config.get_image_model(), + "base_url": base_url, + "api_key": api_key, + "adapter": "raw_chat_multimodal", + }, + } + ) + + +def profiles_from_edit_config(config: Any) -> ModelProfileSet: + if _has_complete_generation_profiles(config): + return resolve_model_profiles(config.model_profiles.model_dump()) + + default_profiles = load_default_profiles() + if default_profiles: + return default_profiles + + if not getattr(config, "api_key", None) or not getattr(config, "base_url", None): + raise ValueError("未配置可用编辑模型,请先配置后端 profile 或在请求中提供编辑模型配置") + + return resolve_model_profiles( + { + "prompt_model": { + "model": config.model, + "base_url": config.base_url, + "api_key": config.api_key, + "adapter": "openai_chat", + }, + "image_model": { + "model": config.model, + "base_url": config.base_url, + "api_key": config.api_key, + "adapter": "raw_chat_multimodal", + }, + } + ) diff --git a/api/routes/edit.py b/api/routes/edit.py index 3e4cd61..6490189 100644 --- a/api/routes/edit.py +++ b/api/routes/edit.py @@ -12,9 +12,9 @@ project_root = Path(__file__).parent.parent.parent sys.path.insert(0, str(project_root)) -from src.config import APIConfig -from src.client import AIClient +from src.model_router import ModelRouter from ..models import EditRequest, EditResponse +from ..profile_resolver import profiles_from_edit_config router = APIRouter(prefix="/api", tags=["edit"]) @@ -31,13 +31,7 @@ async def edit_image(request: EditRequest): EditResponse: 包含编辑后的图片 """ try: - # 配置 API - api_config = APIConfig() - api_config.image_api_key = request.config.api_key - api_config.image_base_url = request.config.base_url - - # 创建 AI 客户端 - client = AIClient(api_config) + client = ModelRouter(profiles_from_edit_config(request.config)) # 解码 base64 图片并保存到临时文件 image_data = base64.b64decode(request.image_base64) diff --git a/api/routes/export.py b/api/routes/export.py index 0e7c58f..6ae7ec4 100644 --- a/api/routes/export.py +++ b/api/routes/export.py @@ -59,7 +59,7 @@ async def export_presentation(request: ExportRequest): elif request.format == "pptx": output_path = temp_dir / "presentation.pptx" - _export_pptx(image_paths, str(output_path)) + _export_pptx(image_paths, str(output_path), aspect_ratio=request.aspect_ratio) return FileResponse( path=str(output_path), @@ -80,7 +80,7 @@ async def export_presentation(request: ExportRequest): ) -def _export_pptx(image_paths: list, output_path: str): +def _export_pptx(image_paths: list, output_path: str, aspect_ratio: str = "16:9"): """ 导出为 PPTX 格式 @@ -97,9 +97,13 @@ def _export_pptx(image_paths: list, output_path: str): # 创建演示文稿 prs = Presentation() - # 设置幻灯片尺寸为 16:9 - prs.slide_width = Inches(10) - prs.slide_height = Inches(5.625) + # 设置幻灯片尺寸 + if aspect_ratio == "4:3": + prs.slide_width = Inches(10) + prs.slide_height = Inches(7.5) + else: + prs.slide_width = Inches(10) + prs.slide_height = Inches(5.625) # 添加每一页 for image_path in image_paths: diff --git a/api/routes/generate.py b/api/routes/generate.py index a99f6c4..d7c88a8 100644 --- a/api/routes/generate.py +++ b/api/routes/generate.py @@ -17,16 +17,69 @@ project_root = Path(__file__).parent.parent.parent sys.path.insert(0, str(project_root)) -from src.config import APIConfig, PPTConfig -from src.client import AIClient +from src.config import PPTConfig +from src.model_router import ModelRouter from src.prompt_generator import PromptGenerator -from ..models import GenerationRequest +from src.models import DeckOutline, PromptData, SlidePrompt +from ..models import GenerationRequest, OutlineRequest, OutlineResponse, PromptPlanRequest, PromptPlanResponse +from ..profile_resolver import profiles_from_generation_config router = APIRouter(prefix="/api", tags=["generate"]) +def _model_to_dict(model): + if hasattr(model, "model_dump"): + return model.model_dump() + return model.dict() + + +def _model_validate(model_cls, data): + if hasattr(model_cls, "model_validate"): + return model_cls.model_validate(data) + return model_cls.parse_obj(data) + + +def _to_ppt_config(config) -> PPTConfig: + ppt_config = PPTConfig() + ppt_config.num_pages = config.page_count + ppt_config.quality = config.quality + ppt_config.aspect_ratio = config.aspect_ratio + ppt_config.language = config.language + ppt_config.style = config.style + ppt_config.target_audience = config.target_audience + ppt_config.user_requirements = config.user_requirements + return ppt_config + + +def _prompt_data_from_confirmed(request: GenerationRequest, ppt_config: PPTConfig) -> PromptData: + slide_prompts = request.slide_prompts or [] + if len(slide_prompts) != ppt_config.num_pages: + raise ValueError(f"确认后的逐页设计数量不匹配: 期望{ppt_config.num_pages}页,实际{len(slide_prompts)}页") + + pages = [item.page for item in slide_prompts] + expected = list(range(1, ppt_config.num_pages + 1)) + if pages != expected: + raise ValueError(f"确认后的逐页设计页码必须连续为 {expected},实际为 {pages}") + + return PromptData( + slide_prompts=[ + SlidePrompt( + page=item.page, + title=item.title, + content_summary=item.content_summary, + prompt=item.prompt, + display_content=item.display_content or item.content_summary, + ) + for item in slide_prompts + ], + config=ppt_config.to_dict(), + source_material=request.content[:2000], + user_requirements=ppt_config.user_requirements or "", + ) + + def generate_single_slide_sync( - client: AIClient, + client: ModelRouter, slide_prompt, output_path: str, config: PPTConfig, @@ -70,54 +123,33 @@ async def generate_stream(request: GenerationRequest) -> AsyncGenerator[str, Non 使用 Server-Sent Events (SSE) 格式返回进度和结果 """ try: - # 配置 API - 支持新的完整配置结构 - api_config = APIConfig() - - # 检查是否使用新的配置结构 - if request.config.image and request.config.text: - # 新的完整配置结构 - api_config.image_api_key = request.config.image.api_key - api_config.image_base_url = request.config.image.base_url - api_config.image_model = request.config.image.model - api_config.text_api_key = request.config.text.api_key - api_config.text_base_url = request.config.text.base_url - api_config.text_model = request.config.text.model - api_config.text_api_format = request.config.text.format - api_config.text_thinking_level = request.config.text.thinking_level - else: - # 向后兼容的简单配置 - api_config.image_api_key = request.config.get_image_api_key() - api_config.image_base_url = request.config.get_image_base_url() - api_config.text_api_key = request.config.get_text_api_key() - api_config.text_base_url = request.config.get_text_base_url() - + profiles = profiles_from_generation_config(request.config) + # 配置 PPT - ppt_config = PPTConfig() - ppt_config.num_pages = request.config.page_count - ppt_config.quality = request.config.quality - ppt_config.aspect_ratio = request.config.aspect_ratio - ppt_config.language = request.config.language - ppt_config.style = request.config.style - ppt_config.target_audience = request.config.target_audience + ppt_config = _to_ppt_config(request.config) # 创建客户端和 Prompt 生成器 - client = AIClient(api_config) + client = ModelRouter(profiles) prompt_generator = PromptGenerator(client) # 发送开始事件 yield f"data: {json.dumps({'type': 'progress', 'data': {'status': 'started', 'current': 0, 'total': 0, 'message': '开始生成 PPT'}})}\n\n" - # 生成 Prompt - yield f"data: {json.dumps({'type': 'progress', 'data': {'status': 'generating_prompts', 'current': 0, 'total': 0, 'message': '正在生成 Prompt...'}})}\n\n" - - # 在线程池中运行同步的 prompt 生成 loop = asyncio.get_event_loop() - prompt_data = await loop.run_in_executor( - None, - prompt_generator.generate, - request.content, - ppt_config - ) + + if request.slide_prompts: + yield f"data: {json.dumps({'type': 'progress', 'data': {'status': 'prompts_ready', 'current': 0, 'total': len(request.slide_prompts), 'message': '已使用确认后的逐页设计,准备生成图片'}})}\n\n" + prompt_data = _prompt_data_from_confirmed(request, ppt_config) + else: + # 兼容旧流程:没有确认结果时仍可自动生成 prompt + yield f"data: {json.dumps({'type': 'progress', 'data': {'status': 'generating_prompts', 'current': 0, 'total': 0, 'message': '正在生成 Prompt...'}})}\n\n" + + prompt_data = await loop.run_in_executor( + None, + prompt_generator.generate, + request.content, + ppt_config + ) total_slides = len(prompt_data.slide_prompts) @@ -232,3 +264,57 @@ async def generate_ppt(request: GenerationRequest): "X-Accel-Buffering": "no" } ) + + +@router.post("/generate-outline", response_model=OutlineResponse) +async def generate_outline(request: OutlineRequest): + """生成用户可编辑的 PPT 设计大纲""" + try: + profiles = profiles_from_generation_config(request.config) + client = ModelRouter(profiles) + prompt_generator = PromptGenerator(client) + ppt_config = _to_ppt_config(request.config) + + loop = asyncio.get_event_loop() + outline = await loop.run_in_executor( + None, + prompt_generator.generate_outline, + request.content, + ppt_config, + ) + return OutlineResponse(success=True, outline=_model_to_dict(outline)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"设计大纲生成失败: {str(e)}") + + +@router.post("/generate-prompts", response_model=PromptPlanResponse) +async def generate_prompts(request: PromptPlanRequest): + """根据用户确认后的大纲生成逐页设计和图像 prompt""" + try: + profiles = profiles_from_generation_config(request.config) + client = ModelRouter(profiles) + prompt_generator = PromptGenerator(client) + ppt_config = _to_ppt_config(request.config) + outline = _model_validate(DeckOutline, _model_to_dict(request.outline)) + + loop = asyncio.get_event_loop() + prompt_data = await loop.run_in_executor( + None, + prompt_generator.generate_prompts_from_outline, + request.content, + outline, + ppt_config, + ) + slide_prompts = [ + { + "page": slide.page, + "title": slide.title, + "content_summary": slide.content_summary, + "display_content": slide.display_content or slide.content_summary, + "prompt": slide.prompt, + } + for slide in prompt_data.slide_prompts + ] + return PromptPlanResponse(success=True, slide_prompts=slide_prompts) + except Exception as e: + raise HTTPException(status_code=500, detail=f"逐页设计生成失败: {str(e)}") diff --git a/api/routes/models.py b/api/routes/models.py new file mode 100644 index 0000000..5aa43e8 --- /dev/null +++ b/api/routes/models.py @@ -0,0 +1,39 @@ +""" +模型 profile 路由 +""" + +from fastapi import APIRouter, HTTPException + +from src.config_writer import save_model_profiles_to_config +from src.model_profiles import load_default_profiles +from src.model_profiles import resolve_model_profiles +from ..models import ModelProfilesConfig, ModelProfilesResponse + +router = APIRouter(prefix="/api", tags=["models"]) + + +@router.get("/model-profiles", response_model=ModelProfilesResponse) +async def get_model_profiles(): + profiles = load_default_profiles() + if not profiles: + return ModelProfilesResponse( + success=False, + profiles=None, + message="未找到后端模型配置", + ) + return ModelProfilesResponse(success=True, profiles=profiles.to_public_dict()) + + +@router.put("/model-profiles", response_model=ModelProfilesResponse) +async def update_model_profiles(config: ModelProfilesConfig): + try: + profile_data = config.model_dump() + save_model_profiles_to_config(profile_data) + profiles = resolve_model_profiles(profile_data) + return ModelProfilesResponse( + success=True, + profiles=profiles.to_public_dict(), + message="模型配置已保存到本地 config.yaml", + ) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"保存模型配置失败: {exc}") diff --git a/api/routes/upload.py b/api/routes/upload.py index 9c99c18..03beadc 100644 --- a/api/routes/upload.py +++ b/api/routes/upload.py @@ -7,16 +7,21 @@ - 返回文件内容 """ +import tempfile +from pathlib import Path + from fastapi import APIRouter, UploadFile, File, HTTPException from ..models import UploadResponse +from src.document_parser import DocumentParser router = APIRouter(prefix="/api", tags=["upload"]) # 最大文件大小限制 (10MB) -MAX_FILE_SIZE = 10 * 1024 * 1024 +MAX_FILE_SIZE = 50 * 1024 * 1024 +SUPPORTED_EXTENSIONS = {".md", ".markdown", ".txt", ".pdf", ".docx", ".pptx"} -def validate_markdown_file(filename: str) -> bool: +def validate_supported_file(filename: str) -> bool: """ 验证文件是否为 Markdown 文件 @@ -32,7 +37,7 @@ def validate_markdown_file(filename: str) -> bool: """ if not filename: return False - return filename.lower().endswith('.md') + return Path(filename).suffix.lower() in SUPPORTED_EXTENSIONS @router.post("/upload", response_model=UploadResponse) @@ -55,10 +60,10 @@ async def upload_file(file: UploadFile = File(...)): HTTPException 500: 服务器内部错误 """ # 验证文件类型 - if not validate_markdown_file(file.filename): + if not validate_supported_file(file.filename): raise HTTPException( status_code=400, - detail="仅支持 .md 文件格式" + detail="仅支持 .md/.txt/.pdf/.docx/.pptx 文件格式" ) try: @@ -73,15 +78,22 @@ async def upload_file(file: UploadFile = File(...)): detail=f"文件大小超过限制 (最大 {MAX_FILE_SIZE // 1024 // 1024}MB)" ) - # 解码文件内容 - content_str = content.decode('utf-8') + suffix = Path(file.filename).suffix.lower() + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as temp_file: + temp_file.write(content) + temp_path = Path(temp_file.name) + + try: + content_str = DocumentParser().parse(temp_path).normalized_markdown + finally: + temp_path.unlink(missing_ok=True) return UploadResponse( success=True, content=content_str, filename=file.filename, file_size=file_size, - message="文件上传成功" + message="文件上传并解析成功" ) except UnicodeDecodeError: diff --git a/config.example.yaml b/config.example.yaml index 1ab3840..6282d55 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -3,27 +3,24 @@ # API 配置 api: - # 图像生成 API 配置(必填) - image: - api_key: "your-image-api-key" - base_url: "your-base_url" - model: "gemini-3-pro-image-preview" - - # 文本生成 API 配置 - text: - # API 格式: "gemini" 或 "openai" - format: "gemini" - model: "gemini-3-pro-preview" - - # 思考深度配置(仅支持 Gemini 3+ 系列模型) - # 可选值: "low", "high", null(不使用思考功能) - thinking_level: "high" - - # 如果使用与图像生成不同的 API 源,请配置以下项: - # api_key: "your-text-api-key-here" - # base_url: "https://api.openai.com/v1" - - # 如果不配置,将使用图像生成的 API 配置 + # 三角色模型配置。后端会优先读取这里,前端只负责选择/覆盖 profile。 + models: + prompt_model: + api_key: "your-text-api-key" + base_url: "https://api.example.com/v1" + model: "DeepSeek-V4-Pro" + adapter: "openai_chat" + image_model: + api_key: "your-image-api-key" + base_url: "https://api.example.com/v1" + model: "gpt-image-2" + adapter: "raw_chat_multimodal" + # 可省略;省略时默认继承 image_model + edit_model: + api_key: "your-edit-api-key" + base_url: "https://api.example.com/v1" + model: "gpt-image-2" + adapter: "raw_chat_multimodal" # PPT 默认配置 ppt: @@ -52,4 +49,4 @@ output: # 文档配置 doc: dir: "doc" - sample_file: "sample_paper.txt" \ No newline at end of file + sample_file: "L9.md" diff --git a/doc/L9.md b/doc/L9.md new file mode 100644 index 0000000..fc16702 --- /dev/null +++ b/doc/L9.md @@ -0,0 +1,383 @@ +# 理想L9用户手册草稿 + +## 摘要 + +全新一代理想 L9 是理想汽车当前面向具身智能时代打造的旗舰增程 SUV。官方已明确:车辆采用理想第三代自研增程系统与 **72.7kWh 5C 超充电池**,**CLTC 纯电续航 420km**、**CLTC 综合续航 1650km**、**WLTC 百公里油耗 6.3L**;座舱核心卖点包括 **29 英寸 6K 一体式前排超宽屏**、**21 英寸 4K 后舱神奇移动屏**、**5440W 9.3.6 布局音响系统**、四零重力座椅、10L 智能冰箱和七温区体感控温。底盘方面,官方重点强调 **800V 主动悬架** 与“**完全体**”线控底盘;Livis 版本相较 Ultra 又进一步升级了主动悬架、EMB 线控制动和双马赫智能辅助驾驶。citeturn13view0turn24search0turn22search0 + +从车主实际使用角度,L9 的核心体验可以概括为三件事:**一是把“启停、换挡、调节座椅、灯光、空调、语音、投屏、车控”尽量集中在中控屏与控制中心完成;二是把补能从“找桩”扩展到“预约充电、低价时段、补能规划、电池保温、即插即充”等完整流程管理;三是把售后从“故障后到店”扩展到“车机保养提示、远程诊断、7×24 小时救援、平板拖运”**。这些能力在官方帮助中心、在线手册和 OTA 说明中均有明确体现。citeturn42search0turn42search1turn51view1turn49search6turn17view1turn15search7 + +如果只抓最重要的用车要点,建议优先记住以下规则:**儿童座椅不要装在前排副驾;胸部与方向盘距离尽量保持不少于 250mm;车辆连接充电枪时不能启动车辆;长期停放前动力电池建议保持 50%–80%,每 3 个月至少维护一次并充至 80%;拖车模式仅适合低速短距离挪移,优先平板拖运;遇到动力电池高温、高压相关故障、冒烟漏液等情况,应先拉开距离并联系官方救援** + +## 目录 + +- [一、车辆概述](#overview) +- [二、安全信息](#safety) +- [三、仪表与控制](#controls) +- [四、中控与信息娱乐系统](#infotainment) +- [五、驱动与驾驶模式](#drive) +- [六、充电与电池维护](#charging) +- [七、保养与定期检查](#maintenance) +- [八、故障排查与常见问题](#troubleshooting) +- [九、紧急救援与拖车指南](#rescue) +- [十、附录](#appendix) + +## 车辆概述与安全信息 + + + +### 车辆概述 + +官方发布信息显示,全新一代理想 L9 当前在售为两款:**L9 Ultra** 和 **L9 Livis**。其中 Ultra 官方统一零售价 **45.98 万元**,Livis 官方统一零售价 **50.98 万元**。两车均属于六座旗舰 SUV,均搭载第三代增程系统与 72.7kWh 5C 电池;而 Livis 明确增加了 800V 主动悬架、EMB 线控机械制动、双马赫智能辅助驾驶,以及一套专属设计套件。citeturn13view0 + +下表只列出**当前官方资料明确公开**的版本差异和共性功能;凡当前发布稿未明示的项目,本文不作推断。citeturn13view0 + +| 项目 | L9 Ultra | L9 Livis | +|---|---|---| +| 官方售价 | 45.98 万元 | 50.98 万元 | +| 交付时间 | 2026-05-17 起 | 2026-05-17 起 | +| 增程系统 | 第三代自研增程器 | 第三代自研增程器 | +| 电池 | 72.7kWh 5C 超充电池 | 72.7kWh 5C 超充电池 | +| 纯电续航 | 420km CLTC | 420km CLTC | +| 综合续航 | 1650km CLTC | 1650km CLTC | +| 底盘重点 | 线控转向、后轮转向、EHB、第三代双腔双阀魔毯空气悬架 | 在 Ultra 基础上升级 800V 主动悬架、EMB、双马赫智能辅助驾驶 | +| 专属外观/配置 | 官方未单列专属套件 | 晶灰 22 英寸轮毂、三色星环灯、双色方向盘、迎宾光毯等 | +| 可选配置 | 官方发布稿列出部分选装项 | 官方发布稿列出部分选装项 | + +全新一代理想 L9 在当前官方公开资料中明确的关键参数如下:**车长 5255mm、车宽 2000mm、车高 1810mm、轴距 3125mm、纯电续航 420km、综合续航 1650km、WLTC 油耗 6.3L/100km、峰值充电功率 420kW、官方称“10 分钟就能充到 80%”**。另外,发布稿还明确写到:四零重力座椅、29 英寸 6K 前排屏、21 英寸 4K 后舱移动屏、5440W 音响、10L 冰箱、后轮转向带来的 **5.3m** 极小转弯半径。citeturn13view0turn22search0turn24search0 + +对车主最常关心、但当前官方公开资料**没有明确值**的项目,本稿统一按以下方式处理:**百公里加速时间、整车最大功率、峰值扭矩、轮胎规格、油箱容积、整备质量、直流/交流从 0–100% 的统一标定时间**,当前公开官方车型页、发布稿和帮助中心检索结果中均**未见一套完整、逐项明确的公开表格**,因此本文在附录参数表中标注为“未公开”或“未在官方资料中找到”。citeturn13view0turn24search0turn23search1 + +> 图片资源索引:全新一代理想 L9 的官方外观图、座舱图、底盘/悬架示意图可在理想汽车发布稿配图中查看。citeturn13view0 + + + +### 安全信息 + +#### 乘员安全 + +理想官方在线手册明确建议,驾驶前至少完成五项检查:**确认车灯工作正常、车周无障碍物、所有车窗清晰且后视镜视野良好、续航与中控警示信息正常、制动/加速踏板区域无杂物干扰**。同时,正确坐姿直接影响安全带和气囊的保护效果;官方建议**胸部与方向盘距离不少于 250mm**,身体尽量贴合靠背,头枕位置到位,并让肩带位于颈部与肩部之间、腰带尽量低地横跨髋部。citeturn46search7 + +理想官方手册还给出了安全带提醒逻辑:所有座位均带乘员检测,车辆启动后若有人未系安全带,中控会点亮警告灯;当驾驶员或前两排乘员未系安全带,且车辆行驶一段时间或车速超过 **15km/h** 后,会触发更高等级的蜂鸣与闪烁告警。若系好后仍持续报警,应视为装置异常并联系官方。citeturn45search7turn52view0 + +#### 儿童安全与儿童座椅 + +官方对儿童乘坐有几条底线要求:**儿童优先坐后排;应根据体形使用儿童安全座椅;儿童不得单独留在车内;应避免儿童误操作车窗、遮阳帘、前舱盖、后备厢门和座椅等可能夹伤身体的部件**。同时,官方明确强调:**严禁在前排副驾安装儿童安全座椅**,尤其不得在受前排安全气囊保护的位置安装后向儿童座椅。citeturn46search2 + +就安装适配性而言,理想官方 L 系列共享手册公开表格显示:**前排乘员座椅不适用于常规儿童安全座椅;第二排和第三排可适配通用类儿童座椅**。对于 ISOFIX,手册公开页显示:**后向 ISOFIX 儿童座椅仅可安装于第二排**;体重不超过 18kg 的儿童,优先按照座椅说明书与车型适配表安装。与此同时,中国现行强制性国家标准 **GB 27887-2024《机动车儿童乘员用约束系统》**已于 **2025-07-01** 实施,因此建议优先选购符合现行标准、并与车辆适配的约束系统。citeturn41search6turn45search9turn20search1 + +理想的儿童锁并不是单一“后门童锁”概念,而是一个可在中控中按位置管理的功能组。官方说明显示,车主可在 **设置 > 车辆 > 儿童锁** 中分别锁定后排门窗以及第二排、第三排座椅功能;被锁定后,对应后车门无法从车内开启,后排车窗开关失效,相关座椅的调节、按摩、加热和通风也不能通过物理按键或语音触发。citeturn46search3 + +#### 安全气囊与被动安全 + +公开可检索的理想 L 系列共享手册示例页显示,同平台六座车型采用 **8 气囊布局**:**前排气囊 2 个、侧气囊 4 个、侧气帘 2 个**。其中侧气帘保护外侧乘员头部,前排主/副气囊保护头胸部,侧气囊保护驾驶员、前排乘员以及第二排外侧乘员躯干;远端侧气囊还会兼顾主副驾间的肩部与头部防护。需要注意:**当前公开的最新 L9 目录虽然明确存在“安全气囊”章节,但未在车型页单独列出气囊数量**,所以这里将其作为**理想 L 系列官方共享手册的可检索参考**,实际配置仍应以车机内 L9 手册为准。citeturn25search0turn46search0 + +如果安全气囊系统故障,对应警告灯会在车辆启动后不按正常逻辑熄灭。官方警示信息中明确写到:若**安全气囊系统故障灯**常亮,说明可能是气囊本体、控制器或安全带预紧器出现异常,应尽快联系理想客服;发生碰撞时,EDR 事件数据记录系统还会按 **GB 39732** 要求记录关键运行和约束系统信息,包括车速、安全带状态、加速/制动踏板、ABS、AEB、ESP 等,以便事故还原。citeturn52view0turn46search4 + +#### 高压系统与紧急操作 + +理想官方对高压安全的提示非常直接:**高压部件有明确警示标识,高压线束均为橙色;不要触摸、拉拽、损坏高压线束和插头,不要接触动力电池泄漏液体**。如果车辆发生严重碰撞,系统会自动切断高压供电,以降低事故风险。若发生故障或极端环境导致高压系统无法正常工作,车辆还会进入“电源管理”状态,只保留部分关键功能,比如危险警告灯、阅读灯和紧急呼叫,并限制空调、座椅加热/通风、屏幕、无线充电等舒适性功能。citeturn51view0turn46search5 + +在救援场景中,官方给出了手动高压切断方法:**前舱打开后,拆下雨刮盖板,可剪断或拉断紧急高压切断线束;随后还应断开蓄电池负极线束并做好绝缘防护**。官方同时强调,**不要在非紧急情况下手动切断高压系统**。如果车辆发生起火:火势小且不涉及高压系统时,可尝试灭火器初期处置;若火势较大或位于动力电池附近,应立即远离车辆并报警。citeturn46search8turn51view0 + +## 仪表控制与车辆互联 + + + +### 仪表与控制 + +L9 的公开手册目录显示,驾驶相关的人机界面由**安全驾驶交互屏、抬头显示、中央中控屏、控制中心以及方向盘/拨杆/快捷控制**共同组成;而公开可检索的 L 系列手册页进一步说明,中控顶部状态栏、环境感知区域、媒体卡片和底部功能栏构成了车主最常用的主界面。P 挡下,环境感知区域还能直接弹出油箱盖、后备厢门、充电口盖等快捷操作。citeturn25search0turn42search0turn49search7 + +方向盘和高频按键是日常最常用的实体控制。公开可检索的 L 系列官方手册页显示:方向盘支持上下前后调节;左侧按键/拨杆主要承担**语音唤醒、接打电话、媒体播放与切歌**,右侧按键/拨杆主要承担**智能驾驶自定义键、巡航速度设定与变道确认/取消**。如果车机出现死机,官方还提供了软重启方案:**同时向下长按最左侧拨杆和最右侧拨杆约 10 秒,可重启中控屏和副驾娱乐屏**。citeturn43search4turn44search5 + +电源逻辑方面,官方说明为:**解锁后进入 ACC;踩下制动踏板并携带智能钥匙或蓝牙钥匙时进入 READY;锁车离车后整车下电**。还需特别记住两条:**连接充电枪时无法启动车辆;若车辆启动状态下驾驶员侧车门打开且驾驶员离座,车辆会从启动模式切回 ACC**。citeturn41search2turn49search5 + +换挡机构为电子换挡杆,公开可检索的官方手册页写明:车辆提供 **P / R / N / D** 四个挡位;通常需要在**踩下制动**且符合车速条件时才能切挡。P 挡可以通过换挡杆末端按钮切入;低速下打开驾驶员车门、关闭电源、连接充/放电枪、手动激活电子驻车等条件,也可能触发车辆自动切入 P 挡。若制动功能异常,官方允许在紧急情况下**长按 P 挡按钮**触发动态驻车制动用于停车,但仅限紧急使用。citeturn40search0 + +门、窗与座椅是 L9 日常家庭用车的高频区域。全新一代理想 L9 的官方发布稿明确写到:**车内外均提供机械门把手和电子门把手**,并且四个电动车门引入了电容防夹条,让电吸门也具备防夹能力。车窗方面,官方共享手册说明:驾驶员门板可开启儿童门窗锁,后排也可通过智能钥匙长按实现整车开闭窗。citeturn13view0turn46search6 + +前排座椅具备记忆功能。根据官方共享手册:车主在手动调完前排座椅后,屏幕会弹出保存坐姿的提示;也可以在 **设置 > 车辆 > 座椅 > 坐姿选择与编辑** 中保存或调出位置。更重要的是,**驾驶员记忆位会同时保存方向盘与外后视镜位置**;在倒车挡下手动调节后视镜镜面时,镜面位置还会自动写入对应记忆位。迎宾座椅功能开启后,**挂 P 挡开门下车**时,主驾座椅和方向盘会自动让位;关门上车后会自动恢复。citeturn45search0turn45search3 + +第二排和第三排座椅操作同样相当重要。公开手册页显示,二排支持一键**后移**和**居中**;第三排靠背可电动放倒/抬起,在动作过程中若与第二排有干涉,系统会先调整第二排座椅位置再完成第三排动作。citeturn45search1turn45search2 + +灯光与雨刮的高频控制建议优先熟悉。官方共享手册显示:外部灯光可在中控底部灯光控制界面直接管理,支持**关闭、位置灯、近光灯、自动模式、后雾灯、星环灯、智能远光灯**。前雨刮共有**高速、低速、自动高灵敏、自动低灵敏、关闭**五挡;后雨刮支持间歇刮刷和喷洗联动。citeturn41search1turn41search0turn50search0 + +外后视镜通过主驾门板开关调节,支持左右镜面选择、电动折叠和展开。官方明确提示:**请在停车状态下调节后视镜,不要在驾驶中操作**。此外,一些镜面相关功能也能由语音直接触发,例如后视镜加热、折叠/展开、锁车自动折叠和倒车自动下倾。citeturn50search1turn50search2 + +下表适合打印后贴在车库或家中,作为高频操作速查。以下路径基于官方在线手册公开内容整理;如车机 OTA 后路径不同,以实车为准。citeturn42search0turn42search1turn41search2turn41search1 + +| 高频功能 | 建议记忆方式 | +|---|---| +| 启动车辆 | 解锁后踩制动,看到 READY | +| 熄火/下电 | 锁车离车自动下电;静止时也可在维护菜单关闭整车电源 | +| 换挡 | 踩制动后拨杆切 D/R/N;P 挡通过末端按钮 | +| 灯光 | 中控底部灯光图标进入 | +| 前雨刮 | 控制杆旋钮五挡 | +| 后雨刮 | 单独按键控制间歇和喷洗 | +| 座椅记忆 | 调整后保存坐姿;记忆位可联动方向盘和后视镜 | +| 迎宾座椅 | 设置 > 车辆 > 座椅 | +| 儿童锁 | 设置 > 车辆 > 儿童锁 | +| 充电口盖 | 中控底栏快捷键 / 车外按压 / 车钥匙 | +| 软重启 | 两侧拨杆同时长按约 10 秒 | + + + +### 中控与信息娱乐系统 + +从公开的官方共享手册看,L 系列中控主界面由四块信息层组成:**顶部状态栏、中央环境感知区、媒体卡片与底部功能栏**。状态栏里会显示时间、网络、蓝牙、外部温度和部分警示灯;环境感知区承担车辆状态、道路感知、障碍物、P 挡车控快捷图标等信息;底部功能栏则是进入灯光、全景影像、控制中心等功能的最快入口。citeturn42search0 + +控制中心是车主操作频率第二高的页面。官方说明可通过两种方式进入:**点击底栏控制中心图标**,或**从中控顶部向下滑动**。控制中心中可见驾驶员账号、蓝牙临时开关、蜂窝网络开关、公路/能源模式入口、音量亮度、自定义快捷键、通知中心、坐姿切换等。citeturn42search1 + +手机互联方面,理想官方给出了三套路径。第一种是**系统蓝牙**:主驾和副驾可单独连接不同设备,主驾常用于手机、平板、电脑、手柄;副驾常用于蓝牙耳机、蓝牙音箱和手柄。第二种是**蓝牙电话**:支持来电语音播报、骚扰电话识别,以及通话最小化不打断其他操作。第三种是**有线/无线投屏**:有线投屏通过官方直连投屏线完成,支持中控屏与副驾娱乐屏;无线投屏则通过车机热点和手机“屏幕镜像/投屏”完成,中控屏为保证行车安全,**仅在 P 挡支持无线投屏**。citeturn44search1turn44search0turn44search2turn44search3 + +语音助手“理想同学”是 L9 车机的核心入口之一。公开官方手册写明:可通过**说出“理想同学”**或使用**方向盘按键**唤醒;支持**唤醒人锁定、连续对话、自由对话、可见即可说、多人同时下指令、随时打断**,并支持粤语、上海话、四川话、河南话、河北话、山东话、天津话、陕西话、普通话等多种方言交互。其能力已经扩展到**车控、导航、美团地点检索、浏览器搜索、媒体控制、保养提醒、胎压/能耗问答和任务大师一键执行**。citeturn43search0turn43search1turn43search3turn42search5 + +导航方面,公开技能页虽然没有完全展开“地图应用”的每一项按钮,但已经明确显示:理想同学可在景点/餐饮/沿途 POI 查询后,直接把地点发起导航;车主也可通过语音说“开始导航”“导航去第 X 个结果”等口令完成操作。对长途场景,OTA 8.0 起又新增了“智能油电混动(Beta)”与智能充电推荐,导航会在满足条件时按约两小时一段的节奏推荐服务区补能点。citeturn43search3turn28search0 + +OTA 是理想车机体验的重要部分。官方在线手册要求:升级前保持车辆驻车、动力电池电量充足、离车并锁车;升级时不要移动车辆;充电中也可升级,但升级过程中会中断充电,升级完成后是否自动恢复充电取决于充电桩。官方社区 OTA 说明则补充:升级包通常在车辆上电且联网时后台下载,下载完成后在 P 挡弹窗提醒,或由 App 推送,用户可选择夜间升级。citeturn42search2turn27search0turn28search1 + +```mermaid +flowchart TD + A[车辆上电并联网] --> B[后台自动下载升级包] + B --> C{下载完成?} + C -- 否 --> B + C -- 是 --> D[P挡后车机弹窗或App提醒] + D --> E{是否立即/预约升级} + E -- 夜间升级 --> F[设定升级时间] + E -- 稍后 --> G[次日或后续再次提醒] + F --> H[驻车 锁车 离车] + H --> I[执行OTA升级] + I --> J[升级成功后通知结果] + I --> K[若失败 联系官方客服] +``` + +上图根据理想官方 OTA 手册与注意事项整理;若车辆正在充电,升级过程中充电会中断,结束后是否恢复取决于桩端和车端判断。citeturn42search2turn27search0turn28search1 + +## 驱动补能与电池 + + + +### 驱动与驾驶模式 + +从当前官方车型页与发布稿可以明确看出,L9 的核心逻辑仍是“**增程电驱为主,充电体验尽量接近纯电**”。理想汽车已明确:全新一代理想 L9 采用**理想第三代自研增程系统**与**72.7kWh 5C 超充电池**,支持 **420km CLTC 纯电续航**、**1650km CLTC 综合续航**,并给出了 **WLTC 6.3L/100km** 的油耗数据。官方同时强调,第三代增程器具备更好的静音与“几乎无感”的启动体验,并引入了**增程器智能保养**。citeturn13view0turn24search0 + +驾驶启动逻辑很简单:**解锁—踩制动—看到 READY—挂挡出发**。关闭则以**离车锁车自动下电**为主。需要特别注意的是,**只要车辆连接充电枪,就无法启动车辆**。如果车辆当前为启动状态,但打开驾驶员车门且驾驶员离座,系统会回退到 ACC,避免误操作。citeturn41search2turn49search5 + +公开可检索的理想官方共享手册中,**能量回收**是驾驶模式里最清晰的一项:在 **设置 > 车辆 > 驾驶 > 能量回收** 中,可选择 **舒适、标准、强** 三档。官方同时提醒两种限制情况:**环境过热/过冷时,能量回收可能受限;动力电池电量较高时,能量回收也可能被限制,待电量下降后才恢复。**citeturn40search1 + +关于不少车主会问到的“**四驱/后驱切换**”,本次检索到的**当前官方 L9 公布资料、公开在线手册目录和帮助中心文本中,未见独立的‘后驱/四驱手动切换’条目**。控制中心里可以看到“公路模式和能源模式”的入口,但公开可检索页面并未完整展开当前最新版 L9 对应的每一种驱动策略名称和逻辑。因此,本稿对此项的结论是:**未在当前官方资料中找到“手动四驱/后驱切换”说明**。若后续通过 OTA 加入新的动力策略,请以车机 **设置 / 实验室 / 驾驶** 页面为准。citeturn42search1turn25search0turn13view0 + +如果您的车辆已升级到较新的系统版本,还应留意 **OTA 8.0** 开始上线的“**智能油电混动(Beta)**”。官方说明显示,在长途导航总时长大于 2 小时、高速里程大于 100km 的条件下,该功能可进行**智能油电分配**与**智能充电推荐**,帮助车辆在更合适的时机保留电量、优化补能点选择。该功能默认关闭,需要到 **设置 > 通用 > 实验室** 开启。citeturn28search0 + +下表给出一个适合日常使用的“驾驶策略速览”。其中“后驱/四驱切换”因为当前官方资料未公开,故单独注明。citeturn40search1turn42search1turn28search0 + +| 场景 | 建议关注项 | 官方公开状态 | +|---|---|---| +| 城市通勤 | 纯电优先使用,关注能量回收等级与预约充电 | 官方支持能量回收三档、预约充电 | +| 高速长途 | 使用导航补能规划,关注智能油电混动与服务区充电推荐 | OTA 8.0 起公开支持 | +| 山路/狭窄路段 | 关注 5.3m 最小转弯半径、全景影像、泊车辅助 | 官方已明确 | +| 驱动形式/手动四驱切换 | 当前公开资料未见独立 2WD/4WD 手动切换页面 | 未在官方资料中找到 | + + + +### 充电与电池维护 + +理想给 L9 车主提供的补能路径,分为**家用充电桩、理想自营超充/优选超充/合作站公共充电,以及对外放电**。帮助中心公开说明显示,理想官方家充产品常见为**7kW 交流充电桩**与**20kW 直流充电桩**:前者使用 **220V 单相交流**、IP55;后者使用 **380V 三相交流**、IP65。理想还明确表示:其家充桩为**国标接口**,理论上支持符合国标的车辆充电。citeturn17view0turn22search3turn37search2turn37search4 + +公共网络方面,理想帮助中心 2026 年公开数据写明:目前已有**4000 多座理想超充站、3000 多座优选超充站、50000 多座合作充电站**;理想 App 支持找桩、先充后付、积分抵扣、停车费减免和即插即充等服务。对 L9 本身,官方车型页进一步给出了当前最关键的一项快充信息:**峰值充电功率 420kW,官方宣传“10 分钟就能充到 80%”**。citeturn37search6turn22search0 + +需要区分的是:**官方当前公开资料明确给出了 5C 超充能力与 10 分钟到 80% 的快充口径,但未公开统一的“家用 7kW 交流桩从 0–100% 需要多少小时”的 L9 专属标定时间**。因此,如果您只接受官方明示数据,可将“家充总时长”记为**未在官方资料中找到**。citeturn22search0turn23search1turn37search2 + +在车内设置层面,公开手册页显示,**应用列表 > 充电管理** 中可设置:**充电上限(80%–100%)、停止充电、预约充电、开始充电和电池保温**。预约充电支持三类逻辑:**按时开始、低价充电、按时结束**。其中“低价充电”只在指定时段内工作,同时禁用电池保温;电池保温则有最长 **12 小时** 的计时逻辑。citeturn51view1 + +充电指示灯的颜色也值得记住。官方说明如下:**白灯常亮**表示车辆已就绪、但未连接枪;**蓝灯常亮**表示已插枪但未开始充电或已充满;**蓝灯闪烁**表示正在充电或电池预热;**绿灯常亮/闪烁**对应对外放电待机/进行中;**红灯常亮**表示充放电系统存在故障。若慢充电子锁故障,可从后备厢找到**应急解锁手柄**进行解锁。citeturn51view1 + +```mermaid +flowchart TD + A[车辆挂P挡并停稳] --> B[打开充电口盖] + B --> C[连接充电枪] + C --> D{灯色状态} + D -- 蓝灯常亮 --> E[已插枪 待开始充电] + D -- 蓝灯闪烁 --> F[充电中或预热中] + D -- 红灯常亮 --> G[系统异常 停止充电并排障] + E --> H[进入充电管理] + H --> I[设置充电上限 预约时段 电池保温] + I --> J[开始充电] + F --> K[达到设定上限或用户停止] + K --> L[解锁拔枪 装回防尘盖 关闭充电口盖] +``` + +上图依据官方“充电管理”“充电指示灯”“慢充电子锁应急解锁”条目整理。慢充电子锁策略还明确遵循 **GB/T 18487.1** 相关要求;充电接口本身应符合中国现行传导充电国家标准,如 **GB/T 18487.1-2023** 与 **GB/T 20234.3-2023**。citeturn51view1turn20search2turn20search0 + +电池维护方面,理想官方给出的规则非常实用:**不要让动力电池完全放空;长期停放前电量保持 50%–80%;每停放 3 个月至少维护一次并充至 80%;避免车辆持续暴露在高于 55℃ 或低于 -30℃ 的环境超过 24 小时;若车辆曾拖底、泡水,必须到服务中心检查动力电池与高压安全**。此外,官方还特别提醒,不要向后备厢或地板等车内区域泼洒液体,以免引发高压系统故障甚至火灾。citeturn47search3turn46search5 + +## 保养故障与救援 + + + +### 保养与定期检查 + +当前官方面对 L9 的帮助中心页面强调:**车辆的实际保养周期会由车机根据使用状况实时测算并动态调整,收到车机保养提示后再预约到店**。同时,理想还公开了“全新理想 L9 增程器智能保养:实验室工况下最长可达 **3 年或 3 万公里**”这一原则。但当前帮助中心公开页面中的“L9 常规保养项目、周期及价格”主要以图示呈现,搜索结果无法完整抽出全部文字。citeturn19view0turn18search0turn13view0 + +为了便于打印和查阅,下表采用**两层表达**:第一层是“当前 L9 官方帮助中心已明确的原则”;第二层是“理想 L 系列官方共享手册可检索的通用保养表”,用于帮助车主形成概念。若您的车机提示或交付随车保养手册与下表不同,**以车机提示为准**。citeturn19view0turn47search0 + +| 保养项目 | 当前 L9 官方公开原则 | L 系列共享手册可检索参考 | +|---|---|---| +| 增程器常规保养 | 实验室工况最长 3 年或 3 万公里;实际以车机动态测算为准 | 小保养 1 年或增程器工作 10000km | +| 增程器大保养 | 官方图表未文本公开 | 2 年或增程器工作 20000km | +| 火花塞 | 官方图表未文本公开 | 增程器工作 40000km | +| 空调滤芯 | 官方图表未文本公开 | 1 年或行驶 20000km | +| 前减速器油 / 制动液 | 官方图表未文本公开 | 4 年或行驶 80000km | +| 冷却液 | 官方图表未文本公开 | 6 年或行驶 120000km | + +常规自检建议集中在四类:**轮胎、制动、滤清器、长期停放状态**。官方手册建议经常检查轮胎胎面、内外侧、胎压以及轮辋是否变形;遇到鼓包、轮毂变形等情况,应尽快更换。空调滤清器需定期检查和更换,若出风明显减弱或空气质量变差,也应提前处理。citeturn47search1turn47search2 + +关于易损件与质保,公开可检索的理想 L 系列保修手册给出的常见口径为:**整车 5 年或 10 万公里、三电系统 8 年或 16 万公里、空气弹簧 8 年或 16 万公里**;易损耗件中,**空调滤芯、轮胎、雨刮片、遥控器电池**通常为 **6 个月或 10000 公里**。但由于当前 L9 帮助中心公开质保细项主要以图示呈现,最终请以购车交付材料、车机《保修手册》和官方确认结果为准。citeturn47search7turn37search7 + +如车辆需要长期停放,除了前述的动力电池管理外,官方还建议:停在**平坦、干燥、通风、远离热源**的位置;无需断开低压蓄电池负极,因为车辆具有智能充电管理功能;重新启用前,应确认没有动力电池相关警示灯或提示信息。citeturn47search3 + + + +### 故障排查与常见问题 + +以下内容按“**车主先排查什么、何时停止自行处理、何时联系官方**”来写,便于打印后直接使用。官方原则是:**涉及制动、高压、电池高温、驱动系统故障、烟雾/漏液时,不继续驾驶。**citeturn52view0turn51view0 + +#### 启动故障 + +如果车辆无法启动,第一步先确认**是否仍连接充电枪**;官方明确规定,连接充电枪时无法启动车辆。第二步确认钥匙/蓝牙钥匙是否在车内且车辆已解锁;第三步看安全驾驶交互屏是否能进入 READY。若问题来自低压蓄电池,官方写得很清楚:**本车使用锂离子蓄电池,不允许外接电源给该蓄电池充电,也禁止与其他车辆进行跨接启动**。此时建议直接联系理想客服或官方道路救援。citeturn41search2turn49search1 + +#### 充电异常 + +若插枪后不充电,优先检查三件事:**是否开启了预约充电、是否设置了“按时开始/按时结束/低价时段”、充电指示灯颜色是否异常**。蓝灯常亮常见于“已插枪但未开始”;红灯常亮则说明充放电系统故障。慢充枪无法拔出时,先确认车辆解锁状态;仍不解锁,可使用后备厢内**应急解锁手柄**释放慢充电子锁。citeturn51view1 + +#### 轮胎异常与爆胎 + +官方手册的最低要求是:**先开双闪、穿反光衣、摆放警示牌,再处理胎压或等待救援**。公开手册页给出轮胎应急充气目标值为 **2.5bar**;若要更换轮胎,应开启举升模式。但要注意,理想明确写到:**本车未配备备胎及工具**,如需换胎请使用相同规格和花纹的轮胎。理想社区官方文章还进一步区分了道路类型:城市道路爆胎,警示牌距车后约 **50m**;高速爆胎则应尽快驶入应急车道,并在车后 **150m 以外**设置警示标志,人员撤离至护栏外,再联系救援。citeturn48search1turn29search0 + +#### 远程诊断 + +理想官方提供远程诊断功能。车辆收到远程诊断需求后,需要在中控屏确认;若 5 分钟内不确认,诊断会自动终止。远程诊断要求车辆处于**启动或 ACC 状态、正常模式、车速低于 2km/h**。开启后,请保持车辆上电,不要离车锁车。对于一些“车机显示异常但车辆仍可低速安全挪动”的问题,这通常比盲目自行断电/拆电更合适。citeturn49search6 + +#### 常见警示灯含义 + +完整的**DTC/错误代码映射表**,当前公开官方资料中**未见面向公众的完整版本**;因此附录只提供“**公开可检索的高频警示灯索引**”,而不伪造完整报码列表。以下表格根据官方“警告灯和指示灯”页面整理。citeturn52view0turn51view3 + +| 警示项 | 官方含义 | 车主建议 | +|---|---|---| +| 制动系统故障灯 | 制动液液位低或制动系统故障 | 立即安全停车,停止继续驾驶 | +| 安全气囊系统故障灯 | 气囊/控制器/预紧器故障 | 尽快联系官方 | +| ABS 故障灯 | ABS 系统异常 | 谨慎驾驶,尽快检修 | +| 电动转向系统故障灯 | 转向助力异常,转向会更沉 | 谨慎低速挪车,尽快检修 | +| 高压断开指示灯 | 车辆无法上高压电 | 停止继续用车 | +| 动力电池温度过高灯 | 电池温度过高 | 安全停车并联系官方 | +| 胎压监测故障灯 | 胎压系统故障或模块未正常供电 | 先检查轮胎,再检修 | +| 驱动系统故障灯 | 驱动系统故障 | 安全停车,避免继续驾驶 | +| 驱动系统功率受限灯 | 整车动力受限 | 降低车速,择机检修 | +| 蓄电池低电量/系统故障灯 | 低压系统异常 | 联系官方 | +| 外部灯光故障灯 | 外部灯光有故障 | 夜间谨慎,尽快维修 | +| 摩擦片磨损灯 | 刹车片磨损至极限 | 尽快更换 | +| 悬架电子调节/减振故障灯 | 悬架系统异常 | 立即降低车速并尽快检修 | + + + +### 紧急救援与拖车指南 + +理想官方的道路救援范围说明非常清楚:**在整车质保期内,若因产品质量问题导致车辆无法正常行驶,可享受中国境内 24 小时免费道路救援**。官方可提供**远程诊断、拖车救援、轮胎救援、困境救援**等方式;若车主未经官方同意自行找第三方救援,由此产生的费用通常不由理想承担。citeturn17view1turn15search7 + +车辆内置**紧急呼叫**功能。手动方式可在中控 **应用列表 > 蓝牙电话 > 官方客服 > SOS 紧急呼叫** 中触发,或点击中控顶部状态栏的 **SOS** 图标发起;自动方式则会在**碰撞事故且激活安全带预紧器**后自动触发,且自动呼叫过程不能手动取消。完成紧急呼叫后,系统会在一段时间内自动接听呼叫中心的回拨。citeturn44search4 + +危险警告灯在任何电源模式下都能工作。官方还加入了“紧急制动联动双闪”的逻辑;不过车主仍应将其视为辅助,而不是代替人工摆放警示牌。就轮胎故障和高速抛锚而言,官方社区明确建议:**优先把人转移到安全区域,再谈拖车或充气。**citeturn41search8turn29search0 + +拖车时,理想的原则是:**优先平板拖运**。如果必须短距离牵引,先在 **设置 > 车辆 > 维护 > 拖车模式** 中开启拖车模式。官方明示:处于拖车模式时,**轮胎仅允许缓慢转动,速度不得超过 5km/h,距离不得超过 5km**;超过此限制,可能造成不可修复损伤,而且通常不属于质保范围。若中控无法使用或无法进入拖车模式,则必须使用小拖车架/平板拖运。citeturn51view2 + +实际将故障车拉上平板车时,官方的紧急拖车步骤是:**从后备厢取拖车钩—打开前拖车钩盖板—顺时针旋入安装孔—用硬金属棒紧固—连接钢索或安全链—开启拖车模式—把车拉上平板车后再对轮胎进行束缚固定。**官方特别强调:**从车辆前方进行拖车操作**;如果电池包已经出现**变形、漏液、冒烟**,必须先排除安全风险,不能贸然拖离。citeturn51view2turn51view0 + +```mermaid +flowchart TD + A[确认人员安全并开双闪] --> B{车辆能否正常驱动?} + B -- 能 --> C[低速驶离危险区域并联系官方] + B -- 不能 --> D[联系理想道路救援] + D --> E{车辆是否可进入拖车模式?} + E -- 可以 --> F[设置 > 车辆 > 维护 > 拖车模式] + E -- 不可以 --> G[直接安排平板拖运] + F --> H[安装前拖车钩并紧固] + H --> I[前方牵引至平板车] + I --> J[束缚轮胎并固定车辆] + J --> K[拖送至最近理想服务中心] +``` + +如果车辆装有拖车钩选装件,还要区分“**应急拖运车辆**”与“**日常牵引挂车**”两个概念。前者属于救援;后者则受道路法规约束。官方发布稿显示,L9 可选装**电动拖车钩**;而国家行政法规库公布的《道路交通安全法实施条例》第五十六条明确:**小型载客汽车只允许牵引旅居挂车或者总质量 700kg 以下的挂车,挂车不得载人**。若您计划合法拖挂房车或挂车,除车辆硬件外,还请同步核实当地交管部门对驾驶证类别和车辆组合的具体要求。citeturn13view0turn21search0turn21search4 + +## 附录与来源 + + + +### 技术参数表 + +下表仅收录**截至检索日可在当前官方公开资料中明确找到的 L9 参数**;未能在官方车型页、发布稿、帮助中心或公开在线手册中明确检索到的项目,一律标注为“未公开”或“未在官方资料中找到”。citeturn13view0turn24search0turn23search1 + +| 参数项 | 当前结论 | +|---|---| +| 在售版本 | L9 Ultra、L9 Livis | +| 官方售价 | Ultra 45.98 万元;Livis 50.98 万元 | +| 上市/交付 | 2026-05-15 发布;2026-05-17 起交付 | +| 车身尺寸 | 5255 × 2000 × 1810 mm | +| 轴距 | 3125 mm | +| 座椅布局 | 6 座 | +| 电池 | 72.7kWh 5C 超充电池 | +| CLTC 纯电续航 | 420 km | +| CLTC 综合续航 | 1650 km | +| WLTC 油耗 | 6.3 L/100km | +| 峰值充电功率 | 420 kW | +| 官方快充口径 | 10 分钟可充至 80% | +| 最小转弯半径 | 5.3 m | +| 前排屏幕 | 29 英寸 6K 一体式超宽全景屏 | +| 后舱屏幕 | 21 英寸 4K 神奇移动屏 | +| 音响系统 | 5440W,9.3.6 布局 | +| 冰箱 | 10L,-6℃ 至 50℃ | +| 百公里加速 | 未在官方资料中找到 | +| 峰值功率 | 未在官方资料中找到 | +| 峰值扭矩 | 未在官方资料中找到 | +| 轮胎规格 | 未在官方资料中找到 | +| 油箱容积 | 未在官方资料中找到 | +| 统一家充时长 | 未在官方资料中找到 | + +### 错误代码表与常用联系方式 + +当前公开官方资料中,**未检索到面向公众发布的完整 L9 DTC/报码对照表**。因此,建议把上文“常见警示灯索引”视为车主可执行版本;真正的报码、远程诊断和故障树,应以车机、服务中心诊断仪与官方远程诊断结果为准。理想官方已明确提供远程诊断能力,且需要在中控确认后才能开始。citeturn49search6turn52view0 + +适合放在手册最后一页的常用联系方式如下:**理想汽车 24 小时服务热线 400-686-0900**;质保期内因产品质量导致无法行驶,可通过同一体系获得道路救援;车内还可通过 **SOS 紧急呼叫** 发起人工救援。高速道路爆胎或事故场景,理想官方社区文章建议同步联系**高速报警/救援电话 12122**并与理想客服联动处理。citeturn17view1turn15search7turn44search4turn29search0 + +### 主要来源与图片索引 + +以下为本文编写时最主要的检索来源。为便于打印版使用,列出来源名称及可点击引用。 + +**官方主来源** + +- 理想汽车《全新一代理想 L9 正式发布》发布稿。citeturn13view0 +- 理想汽车 L9 官方车型页与参数摘要。citeturn12search3turn22search0turn24search0turn23search1 +- 理想汽车官方在线手册目录:全新 L9 / 智能焕新版 L9。citeturn25search0turn49search11 +- 理想汽车帮助中心:L9 用车须知、道路救援、充电服务、家用充电桩、车辆售后指南。citeturn19view0turn17view1turn17view0turn37search6turn16search8 + +**官方共享手册补充来源** + +- 换挡、电源模式、能量回收、灯光、雨刮、中控、控制中心、儿童安全、座椅、充电、拖车、警示灯等 L 系列官方共享手册条目。citeturn40search0turn41search2turn40search1turn41search1turn41search0turn50search0turn42search0turn42search1turn46search2turn46search3turn45search0turn45search1turn45search2turn51view1turn51view2turn52view0 + +**法规与标准来源** + +- 国家标准全文公开平台:**GB/T 20234.3-2023《电动汽车传导充电用连接装置 第3部分:直流充电接口》**。citeturn20search0 +- 国家标准全文公开平台:**GB/T 18487.1-2023《电动汽车传导充电系统 第1部分:通用要求》**。citeturn20search2 +- 国家标准全文公开平台:**GB 27887-2024《机动车儿童乘员用约束系统》**。citeturn20search1 +- 国家行政法规库:**《中华人民共和国道路交通安全法实施条例》第五十六条**。citeturn21search0 + +**官方社区与补充实务来源** + +- OTA 升级说明与注意事项。citeturn27search0turn28search1turn28search5 +- 蓝牙钥匙、投屏、对外放电等官方社区/手册说明。citeturn26search3turn44search2turn44search3turn49search3 +- 爆胎与胎压处理官方社区说明。citeturn29search0turn48search1 + +**图片索引建议** + +- 官方外观图、双色车身图、座舱图、底盘图:建议直接查看理想汽车发布稿原文中的配图。citeturn13view0 +- 官方在线手册中的功能示意图:建议在对应的在线手册条目中查看配图。citeturn40search0turn41search1turn42search0turn51view1turn51view2 + +> 最后提示:理想汽车的车机、语音、辅助驾驶、补能管理和故障提示都具有持续 OTA 演进特征。对于“按钮位置、菜单名称、实验室功能、辅助驾驶策略、投屏支持设备列表”等细节,**若您的当前车机版本与本文不一致,请优先以车机《用户手册》、车内页面提示和官方客服答复为准**。citeturn42search2turn25search0turn49search11 \ No newline at end of file diff --git a/doc/sample_paper.txt b/doc/sample_paper.txt deleted file mode 100644 index 1b9d37d..0000000 --- a/doc/sample_paper.txt +++ /dev/null @@ -1,182 +0,0 @@ -# **为什么面试官在面试中都爱问 HTTPS ?** -原创作者:moment 2025年01月15日 12:08* 广东 -尽管 `HTTP` 在我们的项目中应用已经很广泛了,然而 `HTTP` 并非只有好的一面,事物皆具有两面性,它也是有不足之处的。 -HTTP 的不足之处主要有以下几个方面: -1. 数据传输不加密:HTTP 传输的数据是明文的,任何人都可以在网络中监听并读取传输的数据。这意味着,如果通过 HTTP 传输的是敏感信息(如用户名、密码、银行卡号等),就会容易被窃取。这就会导致数据泄露,影响用户隐私和安全。 -2. 数据容易被篡改:HTTP 不提供数据完整性保护,数据在传输过程中可以被中途篡改。恶意攻击者可以通过中间人攻击(Man-in-the-Middle, MITM)修改数据,导致用户接收到被篡改的内容,如篡改的文件、消息等。 -3. 缺乏身份验证:HTTP 协议本身无法验证客户端访问的是合法的服务器,可能会遭遇伪造网站或钓鱼网站。攻击者可以通过创建假网站诱导用户输入个人信息或执行恶意操作,造成信息泄露或财产损失。 -4. 容易遭受中间人攻击(MITM):由于 HTTP 协议的数据是明文传输的,攻击者能够通过中间人攻击拦截、读取、修改传输数据。攻击者可以截获会话内容,窃取敏感信息,甚至伪造响应返回给客户端,造成严重的安全隐患。如下图所示: -5. 缺乏数据完整性保护:HTTP 协议本身没有内建的校验机制来验证数据是否在传输过程中被篡改。恶意攻击者可以修改数据,客户端无法判断是否收到被篡改的内容。 -6. 浏览器安全警告:许多现代浏览器已经将 HTTP 网站标记为"不安全",并警告用户。HTTP 网站会影响用户信任,特别是在涉及电子商务、登录、支付等敏感操作时,用户会更加倾向于避免访问 HTTP 网站。 -7. 不支持 HTTP/2 特性:HTTP 协议(特别是 HTTP/1.x 版本)效率较低,无法充分利用现代网络的性能优势。比如,它存在队头阻塞(Head-of-Line Blocking)问题,多个请求必须按顺序处理。在大流量的网站或复杂的请求/响应场景下,HTTP 的性能较差,响应速度较慢。 -8. 搜索引擎优化(SEO)劣势:搜索引擎(如 Google)更倾向于优先排名 HTTPS 网站,HTTP 网站的排名可能会受到影响。如果一个网站仅使用 HTTP 协议,其搜索引擎排名可能会比使用 HTTPS 的网站低,从而减少网站的访问量。 -# **什么是 HTTPS** -为了解决上述存在的问题,就用到了 `HTTPS`,实际上它也并非是应用层的一种新协议,只是 `HTTP` 通信接口部分用 `SSL` 和 `TLS` 协议代替而已。 -在正常情况下,`HTTP` 直接和 `TCP` 通信,当使用 `SSL` 时,则演变成先和 `SSL` 通信,再由 `SSL` 和 `TCP` 通信了,换句话说,所谓的 `HTTPS` 实际上就是身披 `SSL` 协议这层外壳的 `HTTP`。 -在采用 `SSL` 后,`HTTP` 就拥有了 `HTTPS` 的加密、证书和完整性保护这些功能。 -## **相互交换秘钥的公开密钥加密技术** -在对 SSL 进行讲解之前,我们先来了解一下加密方法。SSL 采用一种叫做公开密钥加密的加密处理方式。 -在近代的加密方法中,加密算法是公开的,而密钥是保密的,通过这种方式得以保持加密方法的安全性。加密和解密都会用到密钥,没有密钥就无法对密码解密,反过来说,任何人只要持有密钥就能解密了。 -### **对称密钥加密(共享密钥加密)** -加密和解密同用一个密钥的方式称为共享密钥加密,也被叫做对称密钥加密: -以共享密钥方式加密时必须将密钥也发给对方,这是一个挑战,因为在传输密钥本身也需要保证其安全性。如果密钥在传输过程中被截获或篡改,通信的机密性将会被威胁。 -在使用共享密钥的通信中,通信双方必须共享同一个密钥,并且双方都必须信任这个密钥的安全性。如果这个密钥在任何一方处被泄露或公开,通信的机密性将无法得到保证。因此,确保双方对共享密钥的安全性保持信任是至关重要的。 -我们先来看一个对称加密的例子,假设用户 A 想给用户 B 发送一条加密信息: -1. 用户 A 和用户 B 事先共享一个密钥 `K`。 -2. 用户 A 使用密钥 `K` 对消息 `M` 进行加密,生成密文 `C`:`C = E(M, K)`,其中 `E` 是加密算法。 -3. 用户 A 将密文 `C` 发送给用户 B。 -4. 用户 B 收到密文后,使用相同的密钥 `K` 解密,恢复原始消息 `M`:`M = D(C, K)`,其中 `D` 是解密算法。 -对称密钥加密的缺点非常明显: -1. 双方需要事先共享密钥,密钥传输过程容易被截获。如果密钥泄露,通信安全将受到严重威胁。 -2. 不适合大规模使用:在多方通信中,每对通信方都需要一个独立的密钥。密钥数量增长迅速,难以管理。例如,若有 1000 个用户,每两人之间需要一个密钥,总共需要约 50 万个密钥。 -3. 无法实现身份验证:对称加密本身无法验证通信方的身份,容易受到中间人攻击。 -### **非对称密钥加密(公开密钥加密)** -公开密钥加密方式很好地解决了共享密钥加密的困难。它使用一对非对称的密钥,一把叫作私有密钥,另外一把叫作公开密钥。私有密钥不能让其他任何人知道,而公开密钥则可以随意发布,任何人都可以获得。 -使用方式:发送密文的一方使用 `对方的公钥` 对信息进行加密,对方接收到被加密的信息后再使用自己的私钥进行解密。 -特点:信息传输一对多,服务器只需要维持好一个私钥就能和多个客户端进行加密通信。可以实现安全的身份验证、数字签名和密钥交换等功能。 -优点: -1. 安全性高:私钥不会被公开传输,只有私钥的持有者才能解密加密的信息; -2. 方便的密钥交换:发送方和接收方只需交换公钥,而无需交换密钥; -3. 可以实现数字签名:私钥持有者可以使用私钥对消息进行签名,接收方可以使用公钥验证签名的有效性; -缺点: -1. 计算复杂度高:与对称密钥加密相比,非对称密钥加密的计算速度慢,处理大量数据时可能会更耗时; -2. 密钥管理复杂:由于涉及到公钥和私钥的生成、发布和保护,密钥管理可能会更复杂; -3. 通信效率较低:由于加密和解密操作需要使用较长的密钥,导致加密数据的大小增加,从而降低了通信效率; -虽然说安全性高,但也不是没有被盗的可能,因为公钥是公开的,谁都可以获取,如果发送的加密信息是通过私钥加密的话,有公钥的黑客就可以用这个公钥来解密拿到里面的信息。 -下面有一个例子,假设用户 A 想发送一条安全消息给用户 B: -1. 用户 A 获取用户 B 的 `公钥`。 -2. 用户 A 使用 B 的公钥对消息进行加密,生成密文:其中,是用户 B 的公钥。 -3. 用户 A 将密文发送给用户 B。 -4. 用户 B 收到密文后,使用自己的 `私钥` 解密,恢复原始消息:其中,是用户 B 的私钥。 -非对称加密是一种安全性极高的加密技术,适用于身份验证、密钥交换和数字签名等场景。尽管速度较慢、不适合大数据加密,但它通过与对称加密结合,可以在现代网络通信中高效地提供安全保障。 -### **为什么非对称加密效率低一点** -非对称加密的效率较低主要是由于其算法的复杂性和计算成本较高的特点。以下是一些导致非对称加密效率低的主要原因: -1. 密钥长度较长:非对称加密需要使用一对密钥,包括公钥和私钥。通常情况下,这些密钥的长度要比对称加密中使用的密钥长得多。较长的密钥长度会导致加密和解密的操作都需要更多的计算时间。 -2. 计算复杂性:非对称加密算法(如 RSA 和 Elliptic Curve Cryptography)涉及到大整数运算、模幂运算等复杂的数学运算。这些运算需要更多的计算资源和时间,因此非对称加密的处理速度较慢。 -3. 加密速度较慢:由于非对称加密的加密和解密操作都使用不同的密钥,因此加密和解密速度都较慢。这使得非对称加密不适合处理大量数据,特别是实时通信和大规模数据传输方面。 -4. 密钥管理复杂性:非对称加密需要管理和保护两个密钥:公钥和私钥。这增加了密钥管理的复杂性,包括生成、存储和分发密钥等方面的挑战。 -5. 安全性优先:非对称加密的设计目标之一是提供更高的安全性,因此牺牲了一些性能。密钥的长度和复杂的数学运算增加了攻击者破解加密的难度,但同时也降低了效率。 -非对称加密效率较低主要源于其复杂的数学运算、较长的密钥长度和双密钥管理需求。这些特性决定了非对称加密在性能上无法与对称加密相比,但它通过提供更高的安全性和灵活性,成为密钥交换、身份验证和数字签名等场景的关键技术。通过混合加密和硬件优化,非对称加密的性能瓶颈可以得到有效缓解,从而实现安全与效率的平衡。 -### **混合加密机制** -`HTTPS` 采用共享密钥加密和公开密钥加密两者并用的混合加密机制。它采用了对称密钥加密算法的高效性和非对称密钥加密算法的安全性,可以保证安全性的同时提高加密和解密的效率。 -混合加密机制的操作步骤主要有以下几个方面: -1. 密钥交换:接收方生成一对非对称密钥(`公钥` 和 `私钥`),并将公钥发送给发送方; -2. 对称密钥生成:发送方生成一个随机的对称密钥,用于对消息进行加密; -3. 对称密钥加密:发送方使用接收方的公钥将对称密钥加密,并将加密后的对称密钥发送给接收方; -4. 消息处理:发送方使用对称密钥对要发送的消息进行加密,并将加密后的消息发送给接收方; -5. 密文传输:接收方收到加密后的对称密钥和消息; -6. 对称密钥解密:接收方使用自己的私钥解密接收到的对称密钥; -7. 消息解密:接收方使用解密后的对称密钥对接收到的消息进行解密,获得原文明文消息; -在 HTTPS 中,非对称密钥用于安全地交换对称密钥,确保通信双方能在不暴露私密信息的情况下共享加密密钥。之后,对称密钥用于加密和解密实际的数据传输,因为对称加密处理数据速度更快。两者结合确保了数据传输的安全性和效率。 -使用文字的方式来表达难免会有些难以理解,接下来我们使用一个流程图来看看混合加密机制的步骤是怎样实现的: -虽然混合加密机制结合了对称加密和非对称加密两者的优势,能够实现双方之间安全的传输。但也不是没有缺点,它的缺点主要有以下几个方面: -1. 数据不完整性:混合加密主要是为了解决 `HTTP` 中内容可能被窃听的问题。但是它并不能保证数据的完整性,也就是说在传输的时候数据是有可能被第三方篡改的,比如完全替换掉,所以说它并不能校验数据的完整性; -2. 复杂性:混合加密涉及多种加密算法和密钥管理过程,因此实现和管理起来相对复杂; -3. 密钥交换:混合加密需要在通信双方之间进行密钥交换,以便建立安全的通信信道,如果密钥交换过程不正确或者被攻击者窃取,那么整个加密系统的安全性将会受到威胁; -4. 性能开销:混合加密需要同时使用非对称加密和对称加密算法,非对称加密算法的加密和解密速度较慢,而对称加密算法的加密和解密速度较快。因此,在大规模数据传输时,可能会引入性能开销; -5. 中间人攻击:混合加密并不能防止中间人攻击,如果攻击者能够劫持或篡改通信信道,并替换公钥或插入恶意代码,那么它们仍然可以窃听、修改或伪装通信内容; -假设用户 A 需要向用户 B 发送加密消息,以下是混合加密的详细过程: -1. 用户 A 生成会话密钥:用户 A 生成一个随机的会话密钥。例如,是一个 256 位的对称加密密钥。 -2. 用户 A 加密数据:使用对称加密(如 AES),用户 A 使用会话密钥对消息进行加密,生成密文。 -3. 用户 A 加密会话密钥:使用非对称加密(如 RSA),用户 A 用用户 B 的公钥加密会话密钥,生成密文。 -4. 用户 A 发送数据:用户 A 将加密的会话密钥和加密的数据一起发送给用户 B。 -5. 用户 B 解密会话密钥:用户 B 使用自己的私钥解密加密的会话密钥,恢复会话密钥。 -6. 用户 B 解密数据:用户 B 使用会话密钥解密加密的数据,恢复出原始消息。 -假设用户 B 收到用户 A 通过混合加密机制发送的密文,用户 B 如何通过解密获取明文?以下是完整的解密过程: -1. 解密会话密钥 -用户 B 收到加密的会话密钥和加密的数据密文。 -用户 B 使用自己的 **私钥** 对加密的会话密钥进行解密,恢复出会话密钥。 -解密后,会话密钥是对称加密所需的密钥。 -2. 解密数据密文 -用户 B 使用解密得到的会话密钥对数据密文进行对称解密。 -解密后,是用户 A 发送的原始明文数据。 -混合加密机制结合了对称加密和非对称加密的优点,既保证了数据传输的安全性,又提高了加密处理的效率。这种机制在现代网络通信和数据加密中广泛使用,特别是在 HTTPS 协议、云存储、电子邮件加密和区块链等场景中,成为实现高效安全通信的关键技术。 -### **保证公开密钥正确性的数字证书** -目前来看,混合加密机制已经很安全了,但也不是完全没有问题。那就是无法证明公开密钥本身就是货真价实的公开密钥。它有可能在公开密钥传输途中,真正的公开密钥已经被攻击者替换掉了。 -为了解决这个问题,通过数字证书认证机构和其他相关机关颁发的公开密钥证书。其中数字证书的基本组成部分主要有以下几个主体: -1. 公钥:证书中包含了公钥,即需要验证的公开密钥; -2. 签名:证书颁发机构使用自己的私钥对证书的内容进行数字签名,以验证证书的完整性和真实性; -3. 有效期:证书包含了开始和结束的有效期,指定了证书的有效期限; -4. 颁发机构信息:证书中包含了颁发机构的身份信息,用于验证颁发机构的可信性; -证书的主体部分包含了公钥持有者的身份信息,如名称、电子邮件地址等。 -服务器会将这份由数字证书认证机构颁发的公钥证书发送给客户端,以进行公开密钥加密方式通信。接到证书的客户端可使用数字证书认证机构的公开密钥,对那张证书上的数字签名进行验证,一旦验证通过,客户端便可以明确两件事: -1. 认证服务器的公开密钥是真实有效的数字证书认证机构; -2. 服务器的公开密钥是值得信赖的; -数字签名是什么呢,它是一种用于验证数据完整性和身份认证的技术,它的产生过程主要有以下几个步骤: -1. 生成密钥对:数字签名使用非对称密钥加密算法,首先需要生成密钥对。密钥对包括一个私钥和一个公钥。私钥用于生成签名,而公钥用于验证签名; -2. 签名生成:使用私钥对数据进行签名,签名生成的过程通常是先对数据进行哈希运算,然后使用私钥对哈希值进行加密,生成签名; -3. 签名附加:将生成的签名与原始数据一起发送或存储; -4. 验证签名:接收方或验证者收到签名和原始数据后,可以执行以下步骤验证签名的有效性: - - 提取公钥:从签名的来源获取签名者的公钥; - - 解密签名:使用签名者的公钥对签名进行解密,得到解密后的哈希值; - - 哈希计算:对原始数据进行哈希运算,得到哈希值; - - 比较哈希值:将解密后的哈希值与计算得到的哈希值进行比较。如果两者匹配,说明签名是有效的。如果不匹配,说明签名无效; -通过这个过程,验证者可以确保数据在传输过程中没有被篡改,并且可以确定签名的来源。 -**数字证书的颁发流程** -有了数字签名校验数据的完整性,但是数字签名校验的前提是能拿到发送方的公钥,并且保证这个公钥是可信赖的,所以就需要数字证书。 -数字证书的颁发流程通常涉及以下步骤: -1. 密钥生成: - 实体(`个人`、`组织` 或 `服务器`)生成一个密钥对,包括一个公钥和一个私钥; - 私钥用于加密和签名,公钥用于解密和验证; -2. 证书请求: - 实体向证书颁发机构(`Certificate Authority`,`CA`)提交证书请求; - 证书请求中包含实体的公钥以及一些身份信息,例如名称、电子邮件地址等; -3. 身份验证: - `CA` 对实体的身份进行验证,验证的方式包括人工审核、文件验证、域名验证等; - `CA` 确保证书请求的提交者拥有对应的私钥,并具备合法身份; -4. 证书生成: - 经过身份验证后,`CA` 使用自己的私钥对证书进行签名,生成数字证书; - 数字证书中包含实体的公钥,身份信息以及 `CA` 的签名; -5. 证书颁发: - `CA` 将生成的数字证书颁发给实体,通常以电子文件的形式提供; - 实体接收到数字证书后,可以将其用于加密通信、数字签名等安全操作; -6. 证书验证: - 其他参与者在与实体进行通信时,可以获取实体的数字证书; - 参与者使用证书颁发机构的公钥验证证书的签名,确保证书的完整性和真实性; -**为什么说数字证书就能对通信方的身份进行验证呢?** -数字证书能够对通信方身份进行验证,是因为数字证书采用了公钥加密和数字签名的技术,结合了非对称密钥加密算法的特性。 -在数字证书中,证书颁发机构使用自己的私钥对证书进行签名,这个数字签名可以被其他参与者使用 `CA` 的公钥进行验证,通过验证数字签名,可以确保证书的完整性和真实性。 -以下几个步骤是数字证书验证通信方身份的过程: -1. 获取证书:通信方在通信开始之前,从对方获取数字证书; -2. 提取公钥:通信方从数字证书中提取对方的公钥; -3. 验证签名:通信方使用证书颁发机构的公钥对证书中签名进行解密,得到签名的哈希值; -4. 哈希计算:通信方对原始证书内容进行哈希计算,生成一个哈希值; -5. 比较哈希值:通信方将解密得到的哈希值与自己计算的哈希值进行比较,如果两者相同,则证书的签名是有效的,证明证书没有被篡改; -通过以上验证步骤,通信方可以确保证书的完整性,并且确定证书的来源是可信的。这样通信方可以信任证书中关联的公钥,并使用公钥进行加密、身份认证或数字签名的验证。 -总的来说,数字证书通过使用证书颁发机构的私钥对证书进行签名,提供了一种可信任的方式来验证证书的完整性和真实性。通过验证证书,通信方可以建立对对方身份的信任,并使用其公钥进行安全的通信操作。 -# **SSL/TLS 是如何工作的** -HTTPS 是 HTTP 协议的一种安全形式。它围绕 HTTP、传输层安全性(TLS)包装了一个加密层。 -HTTP 只是一种协议,但当与 TLS 配对时,它会被加密。 -TLS 和 SSL 是面向 Socket 的协议,因此加密发送方和接收方之间的套接字或传输通道,但不加密数据。这是使这两个协议独立于应用层的主要原因。 -接下来我们来看看 TLS 是如何工作的。先上图: -我们将对图中的每一个步骤做详细的解释: -1. 握手启动 (Initiation of TLS Handshake):浏览器(客户端)发起 TLS 握手请求,与服务器建立安全通信。 -2. 客户端问候 (Client Hello):客户端发送 ClientHello 消息,包含以下内容: - 1. 支持的 TLS 协议版本(如 TLS 1.2、TLS 1.3); - 2. 支持的加密算法(如 RSA、ECDHE、AES); - 3. 随机数(用于密钥协商); - 4. 会话 ID(如果是恢复连接时用); -3. 服务器问候 (Server Hello):服务器响应 ServerHello 消息,内容包括: - 1. 确认使用的 TLS 协议版本; - 2. 选择的加密算法; - 3. 服务器生成的随机数; - 4. 会话 ID; -4. 服务器证书(Server Certificate):服务器发送其 SSL/TLS 证书(由 CA 签发),包含: - 1. 服务器的公开密钥; - 2. 服务器的身份信息(如域名); - 3. 证书的有效期; -5. 服务器密钥交换 (Server Key Exchange,可选):在某些情况下(如使用 Diffie-Hellman 密钥交换算法),服务器会发送密钥交换参数。这一步是可选的,具体取决于协商的加密算法。 -6. 服务器握手结束通知 (Server Handshake Finished):服务器发送 ServerHelloDone,表示服务器端的握手阶段完成。 -7. 客户端密钥交换 (Client Key Exchange):客户端生成一个预主密钥(Pre-Master Secret),并使用服务器的公钥加密后发送给服务器。服务器用私钥解密,得到预主密钥。 -8. 生成主密钥(Pre-Master to Master Secret):客户端和服务器各自使用预主密钥、客户端随机数、服务器随机数,以及协商的加密算法,生成主密钥。 -9. 通知切换到加密模式(Change Cipher Spec):客户端和服务器分别发送 ChangeCipherSpec 消息,表明后续通信将使用加密模式。 -10. 握手完成确认 (Handshake Finished):客户端和服务器分别发送握手完成确认消息,确认握手过程完成。 -11. 加密通信 (Encrypted Communication):握手完成后,客户端和服务器使用主密钥进行加密通信。 -在上面的步骤中,主要有三个核心流程: -1. 身份验证:通过服务器的 SSL/TLS 证书验证其身份; -2. 密钥协商:利用非对称加密生成共享的会话密钥; -3. 加密通信:使用对称加密(如 AES)提高传输效率。 -HTTPS 是通过在 HTTP 上加入 TLS(传输层安全协议)实现安全通信的,它提供加密、身份验证和数据完整性保护。TLS 握手是 HTTPS 的核心流程,客户端与服务器通过握手协商加密算法、验证服务器身份,并生成共享的会话密钥。完成握手后,双方使用对称加密对数据进行高效传输,确保通信内容的机密性和完整性。 -# **总结** -尽管 HTTPS 提供了显著的安全优势,但由于性能开销、证书管理成本、特定场景需求和历史遗留问题,一些场景下仍然使用 HTTP。不过,随着免费证书的普及、TLS 1.3 的性能提升以及对安全性的重视,使用 HTTPS 已成为现代互联网的趋势,并被搜索引擎(如 Google)优先推荐。 -`HTTPS` 的本质就是在 `HTTP` 的基础上添加了安全层,主要是通过加密和验证机制来保护通信数据的安全性和隐私性。它提供了保密性、完整性和身份验证的重要机制,使得数据在传输过程中得到了有效的保护,防止数据被窃听、篡改和伪装。 diff --git a/docs/assets/aippt-demo-poster.png b/docs/assets/aippt-demo-poster.png new file mode 100644 index 0000000..9268a79 Binary files /dev/null and b/docs/assets/aippt-demo-poster.png differ diff --git a/docs/assets/aippt-demo.webm b/docs/assets/aippt-demo.webm new file mode 100644 index 0000000..6d7c4b3 Binary files /dev/null and b/docs/assets/aippt-demo.webm differ diff --git a/main.py b/main.py index d8f23b3..aea1e1c 100644 --- a/main.py +++ b/main.py @@ -18,13 +18,13 @@ def main(): epilog=""" 示例: # 从文件生成 PPT - python main.py -i doc/sample_paper.txt -n 5 + python main.py -i doc/L9.md -n 5 # 指定风格和受众 - python main.py -i doc/paper.txt -n 10 --style "科技感" --audience "投资人" + python main.py -i doc/L9.md -n 10 --style "科技感" --audience "投资人" # 仅生成 Prompt - python main.py -i doc/paper.txt -n 5 --prompt-only -o prompts.json + python main.py -i doc/L9.md -n 5 --prompt-only -o prompts.json # 从 Prompt 文件生成图片 python main.py --from-prompt prompts.json diff --git a/requirements.txt b/requirements.txt index d6d0200..8cef45f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,5 @@ fastapi>=0.104.0 uvicorn>=0.24.0 python-multipart>=0.0.6 python-pptx>=0.6.21 +pypdf>=4.0.0 +docling>=2.0.0 diff --git a/src/config.py b/src/config.py index 0f178db..53dc1ab 100644 --- a/src/config.py +++ b/src/config.py @@ -106,6 +106,7 @@ class PPTConfig: language: str = None style: str = None target_audience: str = None + user_requirements: str = None num_pages: int = None aspect_ratio: Literal["16:9", "4:3", "1:1"] = None quality: Literal["1K", "2K", "4K"] = None @@ -122,6 +123,8 @@ def __post_init__(self): self.style = config.get("style", "现代简约商务风格") if self.target_audience is None: self.target_audience = config.get("target_audience", "专业人士") + if self.user_requirements is None: + self.user_requirements = config.get("user_requirements", "") if self.num_pages is None: self.num_pages = config.get("num_pages", 10) if self.aspect_ratio is None: @@ -161,7 +164,7 @@ def get_sample_file() -> Path: """获取示例文件路径""" config = get_config().get("doc", {}) doc_dir = get_doc_dir() - return doc_dir / config.get("sample_file", "sample_paper.txt") + return doc_dir / config.get("sample_file", "L9.md") def get_timeout_config() -> Dict[str, int]: diff --git a/src/config_writer.py b/src/config_writer.py new file mode 100644 index 0000000..b196c18 --- /dev/null +++ b/src/config_writer.py @@ -0,0 +1,45 @@ +""" +Helpers for updating local config.yaml from the workbench. +""" + +from pathlib import Path +from typing import Any, Dict + +import yaml + +from .config import CONFIG_FILE, load_yaml_config, reload_config + + +def save_model_profiles_to_config(profile_data: Dict[str, Any], config_path: Path = CONFIG_FILE) -> Dict[str, Any]: + config = load_yaml_config() + config.setdefault("api", {}) + existing_models = config.get("api", {}).get("models", {}) + config["api"]["models"] = _clean_profile_data(profile_data, existing_models) + + config_path.write_text( + yaml.safe_dump(config, allow_unicode=True, sort_keys=False), + encoding="utf-8", + ) + reload_config() + return config + + +def _clean_profile_data(profile_data: Dict[str, Any], existing_models: Dict[str, Any]) -> Dict[str, Any]: + cleaned: Dict[str, Any] = {} + for role in ("prompt_model", "image_model", "edit_model"): + profile = profile_data.get(role) + if not profile: + continue + existing_profile = existing_models.get(role, {}) if isinstance(existing_models, dict) else {} + api_key = profile.get("api_key") or existing_profile.get("api_key", "") + cleaned[role] = { + "model": profile.get("model", ""), + "base_url": profile.get("base_url", ""), + "api_key": api_key, + "adapter": profile.get("adapter", "openai_chat" if role == "prompt_model" else "raw_chat_multimodal"), + } + if profile.get("id"): + cleaned[role]["id"] = profile["id"] + if profile.get("label"): + cleaned[role]["label"] = profile["label"] + return cleaned diff --git a/src/document_parser.py b/src/document_parser.py new file mode 100644 index 0000000..0370a61 --- /dev/null +++ b/src/document_parser.py @@ -0,0 +1,93 @@ +""" +Document parsing facade. + +Docling is used for rich office/PDF formats when available. Markdown and plain +text stay lightweight and do not require Docling. +""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Optional + + +@dataclass +class ParsedDocument: + filename: str + normalized_markdown: str + metadata: Dict[str, Any] = field(default_factory=dict) + + +class DocumentParser: + supported_extensions = {".md", ".markdown", ".txt", ".pdf", ".docx", ".pptx"} + + def parse(self, path: Path) -> ParsedDocument: + path = Path(path) + suffix = path.suffix.lower() + if suffix not in self.supported_extensions: + raise ValueError(f"不支持的文件格式: {suffix}") + + if suffix in {".md", ".markdown"}: + return ParsedDocument( + filename=path.name, + normalized_markdown=path.read_text(encoding="utf-8"), + metadata={"parser": "markdown"}, + ) + + if suffix == ".txt": + return ParsedDocument( + filename=path.name, + normalized_markdown=path.read_text(encoding="utf-8"), + metadata={"parser": "text"}, + ) + + if suffix == ".pdf": + parsed_pdf = self._parse_pdf_text(path) + if parsed_pdf and parsed_pdf.normalized_markdown.strip(): + return parsed_pdf + + return self._parse_with_docling(path) + + def _parse_pdf_text(self, path: Path) -> Optional[ParsedDocument]: + try: + from pypdf import PdfReader + except ImportError: + return None + + try: + reader = PdfReader(str(path)) + pages = [] + for index, page in enumerate(reader.pages, start=1): + text = page.extract_text() or "" + if text.strip(): + pages.append(f"\n{text.strip()}") + except Exception: + return None + + return ParsedDocument( + filename=path.name, + normalized_markdown="\n\n".join(pages), + metadata={"parser": "pypdf", "extension": ".pdf", "pages": len(reader.pages)}, + ) + + def _parse_with_docling(self, path: Path) -> ParsedDocument: + try: + from docling.document_converter import DocumentConverter + except ImportError as exc: + raise RuntimeError("解析该文件需要安装 docling") from exc + + converter = DocumentConverter() + result = converter.convert(str(path)) + document = result.document + + if hasattr(document, "export_to_markdown"): + markdown = document.export_to_markdown() + elif hasattr(document, "export_to_text"): + markdown = document.export_to_text() + else: + markdown = str(document) + + return ParsedDocument( + filename=path.name, + normalized_markdown=markdown, + metadata={"parser": "docling", "extension": path.suffix.lower()}, + ) diff --git a/src/image_result.py b/src/image_result.py new file mode 100644 index 0000000..97490ff --- /dev/null +++ b/src/image_result.py @@ -0,0 +1,140 @@ +""" +Image result normalization utilities. + +Model providers return images in several shapes: markdown links, plain URLs, +data URLs, OpenAI-style b64_json payloads, or raw base64 strings. The API layer +uses this module to normalize all of them into base64 data for frontend state, +editing history, and export. +""" + +import base64 +import re +from dataclasses import dataclass +from typing import Any, Callable, Optional + +import requests + + +@dataclass +class NormalizedImage: + base64_data: str + mime_type: str = "image/png" + source: str = "base64" + + +class ImageResultNormalizer: + """Normalize image payloads from OpenAI-compatible and markdown responses.""" + + def __init__(self, fetcher: Optional[Callable[[str], bytes]] = None): + self.fetcher = fetcher or self._fetch_url + + def normalize(self, payload: Any) -> NormalizedImage: + candidate = self._extract_candidate(payload) + + if not candidate: + raise ValueError("未能从模型响应中提取图片") + + if isinstance(candidate, bytes): + return NormalizedImage(base64.b64encode(candidate).decode(), "image/png", "bytes") + + candidate = str(candidate).strip() + + data_url = self._parse_data_url(candidate) + if data_url: + mime_type, data = data_url + self._validate_base64(data) + return NormalizedImage(data, mime_type, "base64") + + if candidate.startswith("http://") or candidate.startswith("https://"): + image_bytes = self.fetcher(candidate) + return NormalizedImage( + base64.b64encode(image_bytes).decode(), + self._guess_mime_type(candidate, image_bytes), + "url", + ) + + if self._looks_like_base64(candidate): + self._validate_base64(candidate) + return NormalizedImage(candidate, "image/png", "base64") + + raise ValueError("模型响应中的图片格式不受支持") + + def _extract_candidate(self, payload: Any) -> Any: + if payload is None: + return None + + if isinstance(payload, (str, bytes)): + if isinstance(payload, str): + markdown_url = self._extract_markdown_url(payload) + return markdown_url or payload + return payload + + if hasattr(payload, "model_dump"): + payload = payload.model_dump() + elif hasattr(payload, "to_dict"): + payload = payload.to_dict() + + if isinstance(payload, dict): + if payload.get("b64_json"): + return payload["b64_json"] + if payload.get("url"): + return payload["url"] + if payload.get("image_base64"): + return payload["image_base64"] + + data = payload.get("data") + if isinstance(data, list) and data: + return self._extract_candidate(data[0]) + + choices = payload.get("choices") + if isinstance(choices, list) and choices: + message = choices[0].get("message", {}) if isinstance(choices[0], dict) else {} + content = message.get("content") + if content: + return self._extract_candidate(content) + + return None + + @staticmethod + def _extract_markdown_url(text: str) -> Optional[str]: + match = re.search(r"!\[[^\]]*]\((https?://[^)]+)\)", text) + if match: + return match.group(1) + return None + + @staticmethod + def _parse_data_url(text: str) -> Optional[tuple[str, str]]: + match = re.match(r"^data:(image/[^;]+);base64,(.+)$", text, re.DOTALL) + if not match: + return None + return match.group(1), match.group(2).strip() + + @staticmethod + def _looks_like_base64(text: str) -> bool: + compact = re.sub(r"\s+", "", text) + if len(compact) < 8 or len(compact) % 4 != 0: + return False + return bool(re.fullmatch(r"[A-Za-z0-9+/]+={0,2}", compact)) + + @staticmethod + def _validate_base64(data: str) -> None: + base64.b64decode(data, validate=True) + + @staticmethod + def _guess_mime_type(url: str, image_bytes: bytes) -> str: + lowered = url.lower().split("?", 1)[0] + if lowered.endswith(".jpg") or lowered.endswith(".jpeg"): + return "image/jpeg" + if lowered.endswith(".webp"): + return "image/webp" + if image_bytes.startswith(b"\xff\xd8"): + return "image/jpeg" + if image_bytes.startswith(b"RIFF") and b"WEBP" in image_bytes[:16]: + return "image/webp" + return "image/png" + + @staticmethod + def _fetch_url(url: str) -> bytes: + response = requests.get(url, timeout=120) + response.raise_for_status() + return response.content diff --git a/src/model_profiles.py b/src/model_profiles.py new file mode 100644 index 0000000..bbc0051 --- /dev/null +++ b/src/model_profiles.py @@ -0,0 +1,219 @@ +""" +Model profile resolution for the PPT workbench. + +The application treats prompt generation, image generation, and image editing +as separate model roles. Edit defaults to image because many providers use the +same model for both tasks. +""" + +import os +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict, Optional + + +DEFAULT_ADAPTERS = { + "prompt": "openai_chat", + "image": "raw_chat_multimodal", + "edit": "raw_chat_multimodal", +} + + +@dataclass +class ModelProfile: + role: str + model: str + base_url: str + api_key: str + adapter: str = "" + id: str = "" + label: str = "" + + def __post_init__(self): + if self.base_url: + self.base_url = self.base_url.rstrip("/") + if not self.base_url.endswith("/v1"): + self.base_url += "/v1" + if not self.adapter: + self.adapter = DEFAULT_ADAPTERS.get(self.role, "openai_chat") + if not self.id: + self.id = self.role + if not self.label: + self.label = self.model + + def to_public_dict(self) -> Dict[str, Any]: + data = asdict(self) + data["api_key"] = "SET" if self.api_key else "EMPTY" + return data + + +@dataclass +class ModelProfileSet: + prompt: ModelProfile + image: ModelProfile + edit: ModelProfile + + def to_public_dict(self) -> Dict[str, Any]: + return { + "prompt_model": self.prompt.to_public_dict(), + "image_model": self.image.to_public_dict(), + "edit_model": self.edit.to_public_dict(), + } + + +def resolve_model_profiles(data: Dict[str, Any]) -> ModelProfileSet: + prompt = _profile_from_dict("prompt", data.get("prompt_model") or data.get("text") or {}) + image = _profile_from_dict("image", data.get("image_model") or data.get("image") or {}) + + edit_source = data.get("edit_model") or data.get("edit") + edit = _profile_from_dict("edit", edit_source) if edit_source else _inherit_edit_profile(image) + + return ModelProfileSet(prompt=prompt, image=image, edit=edit) + + +def load_profiles_from_env(env_path: Optional[Path] = None) -> Optional[ModelProfileSet]: + configured_path = env_path or os.environ.get("AIPPT_ENV_FILE") + if not configured_path: + return None + path = Path(configured_path) + if not path.exists(): + return None + + raw = _parse_env_like_file(path) + if not raw: + return None + + text_model = raw.get("text_model_name") or raw.get("_model_name") + text_url = raw.get("text_model_url") or raw.get("_model_url") + text_key = raw.get("text_model_api_key") or raw.get("_model_api_key") + + image_model = raw.get("image_gen_model_name") + image_url = raw.get("image_gen_model_url") + image_key = raw.get("image_gen_model_api_key") + + edit_model = raw.get("image_edit_model_name") or image_model + edit_url = raw.get("image_edit_model_url") or image_url + edit_key = raw.get("image_edit_model_api_key") or image_key + + if not (text_model and text_url and text_key and image_model and image_url and image_key): + return None + + return resolve_model_profiles( + { + "prompt_model": { + "id": "env-text", + "label": text_model, + "model": text_model, + "base_url": text_url, + "api_key": text_key, + "adapter": "openai_chat", + }, + "image_model": { + "id": "env-image", + "label": image_model, + "model": image_model, + "base_url": image_url, + "api_key": image_key, + "adapter": "raw_chat_multimodal", + }, + "edit_model": { + "id": "env-edit", + "label": edit_model, + "model": edit_model, + "base_url": edit_url, + "api_key": edit_key, + "adapter": "raw_chat_multimodal", + }, + } + ) + + +def load_default_profiles(config_data: Optional[Dict[str, Any]] = None) -> Optional[ModelProfileSet]: + """Load profiles from the repository-local config.yaml data.""" + if config_data is None: + try: + from .config import get_config + + config_data = get_config() + except Exception: + config_data = {} + + api_config = (config_data or {}).get("api", {}) + profile_data = api_config.get("models") or {} + if profile_data: + return resolve_model_profiles(profile_data) + + if api_config.get("text") or api_config.get("image"): + legacy = { + "prompt_model": { + **(api_config.get("text") or {}), + "adapter": (api_config.get("text") or {}).get("adapter", "openai_chat"), + }, + "image_model": { + **(api_config.get("image") or {}), + "adapter": (api_config.get("image") or {}).get("adapter", "raw_chat_multimodal"), + }, + } + if api_config.get("edit"): + legacy["edit_model"] = { + **api_config["edit"], + "adapter": api_config["edit"].get("adapter", "raw_chat_multimodal"), + } + try: + return resolve_model_profiles(legacy) + except ValueError: + pass + + return None + + +def _inherit_edit_profile(image: ModelProfile) -> ModelProfile: + return ModelProfile( + role="edit", + id="edit", + label=image.label, + model=image.model, + base_url=image.base_url, + api_key=image.api_key, + adapter=image.adapter, + ) + + +def _profile_from_dict(role: str, data: Dict[str, Any]) -> ModelProfile: + model = data.get("model") or data.get("model_name") + base_url = data.get("base_url") or data.get("url") + api_key = data.get("api_key") or data.get("key") + + if not model or not base_url or api_key is None: + raise ValueError(f"{role} model profile is incomplete") + + return ModelProfile( + role=role, + id=data.get("id", role), + label=data.get("label", model), + model=model, + base_url=base_url, + api_key=api_key, + adapter=data.get("adapter", DEFAULT_ADAPTERS.get(role, "openai_chat")), + ) + + +def _parse_env_like_file(path: Path) -> Dict[str, str]: + replacements = str.maketrans({"“": '"', "”": '"', "‘": "'", "’": "'"}) + values: Dict[str, str] = {} + for raw_line in path.read_text(errors="replace").splitlines(): + line = raw_line.strip().translate(replacements) + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[len("export "):].strip() + if "=" in line: + key, value = line.split("=", 1) + elif ":" in line: + key, value = line.split(":", 1) + else: + continue + key = key.strip() + value = value.strip().strip("\"'") + values[key] = value + return values diff --git a/src/model_router.py b/src/model_router.py new file mode 100644 index 0000000..411a1e6 --- /dev/null +++ b/src/model_router.py @@ -0,0 +1,150 @@ +""" +Model routing facade for prompt generation, image generation, and image edits. +""" + +import base64 +import json +from pathlib import Path +from typing import Any, Dict, Optional + +import requests +from openai import OpenAI + +from .image_result import ImageResultNormalizer +from .model_profiles import ModelProfile, ModelProfileSet +from .prompts import PromptTemplates + + +class ModelRouter: + def __init__(self, profiles: ModelProfileSet): + self.profiles = profiles + self.normalizer = ImageResultNormalizer() + + def generate_text(self, prompt: str, system_instruction: Optional[str] = None) -> str: + profile = self.profiles.prompt + if profile.adapter not in {"openai_chat", "litellm"}: + raise ValueError(f"不支持的文本模型适配器: {profile.adapter}") + + client = OpenAI(api_key=profile.api_key, base_url=profile.base_url, timeout=120) + messages = [] + if system_instruction: + messages.append({"role": "system", "content": system_instruction}) + messages.append({"role": "user", "content": prompt}) + response = client.chat.completions.create(model=profile.model, messages=messages) + return response.choices[0].message.content or "" + + def generate_image( + self, + prompt: str, + aspect_ratio: str = "16:9", + quality: str = "2K", + output_path: str = "output.png", + ) -> str: + profile = self.profiles.image + full_prompt = f"{PromptTemplates.get_image_generation_prefix()}{prompt}" + payload = self._chat_image_payload(profile, full_prompt, aspect_ratio, quality) + result = self._post_chat(profile, payload) + return self._write_normalized_image(result, output_path) + + def edit_image( + self, + prompt: str, + source_image_path: str, + aspect_ratio: str = "16:9", + quality: str = "2K", + output_path: str = "output.png", + ) -> str: + profile = self.profiles.edit + source = Path(source_image_path).read_bytes() + source_b64 = base64.b64encode(source).decode() + full_prompt = f"{PromptTemplates.get_image_generation_prefix()}{prompt}" + payload = self._chat_image_payload( + profile, + full_prompt, + aspect_ratio, + quality, + source_image_base64=source_b64, + ) + result = self._post_chat(profile, payload) + return self._write_normalized_image(result, output_path) + + def generate_image_base64(self, prompt: str, aspect_ratio: str = "16:9", quality: str = "2K") -> str: + profile = self.profiles.image + result = self._post_chat(profile, self._chat_image_payload(profile, prompt, aspect_ratio, quality)) + return self.normalizer.normalize(result).base64_data + + def edit_image_base64( + self, + image_base64: str, + instruction: str, + aspect_ratio: str = "16:9", + quality: str = "2K", + ) -> str: + profile = self.profiles.edit + payload = self._chat_image_payload( + profile, + instruction, + aspect_ratio, + quality, + source_image_base64=image_base64, + ) + return self.normalizer.normalize(self._post_chat(profile, payload)).base64_data + + def _chat_image_payload( + self, + profile: ModelProfile, + prompt: str, + aspect_ratio: str, + quality: str, + source_image_base64: Optional[str] = None, + ) -> Dict[str, Any]: + if source_image_base64: + content: Any = [ + { + "type": "text", + "text": ( + f"{prompt}\n\n" + f"输出画幅: {aspect_ratio},质量: {quality}。" + "请返回修改后的图片,优先返回图片链接或 base64。" + ), + }, + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{source_image_base64}"}, + }, + ] + else: + content = ( + f"{prompt}\n\n" + f"输出画幅: {aspect_ratio},质量: {quality}。" + "请直接生成一张可用于 PPT 的图片,优先返回图片链接或 base64。" + ) + + return { + "model": profile.model, + "messages": [{"role": "user", "content": content}], + "max_tokens": 2000, + } + + def _post_chat(self, profile: ModelProfile, payload: Dict[str, Any]) -> Dict[str, Any]: + if profile.adapter not in {"raw_chat_multimodal", "openai_chat"}: + raise ValueError(f"不支持的图像模型适配器: {profile.adapter}") + + response = requests.post( + f"{profile.base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {profile.api_key}", + "Content-Type": "application/json", + }, + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + timeout=180, + ) + response.raise_for_status() + return response.json() + + def _write_normalized_image(self, payload: Dict[str, Any], output_path: str) -> str: + image = self.normalizer.normalize(payload) + path = Path(output_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(base64.b64decode(image.base64_data)) + return str(path) diff --git a/src/models.py b/src/models.py index 6b57ea6..24f6c6d 100644 --- a/src/models.py +++ b/src/models.py @@ -9,6 +9,8 @@ from datetime import datetime from pathlib import Path +from pydantic import BaseModel, Field + @dataclass class SlidePrompt: @@ -17,6 +19,7 @@ class SlidePrompt: title: str content_summary: str prompt: str + display_content: str = "" def to_dict(self) -> Dict[str, Any]: return asdict(self) @@ -26,6 +29,38 @@ def from_dict(cls, data: Dict[str, Any]) -> 'SlidePrompt': return cls(**data) +class SlideOutline(BaseModel): + """用户可审阅的单页设计大纲""" + page: int = Field(..., ge=1) + title: str = Field(..., min_length=1) + narrative_goal: str = Field(..., min_length=1) + key_points: List[str] = Field(default_factory=list) + visual_direction: str = Field(..., min_length=1) + + +class DeckOutline(BaseModel): + """整套 PPT 的设计大纲""" + title: str = Field(..., min_length=1) + user_requirements: str = Field("", description="已吸收的用户定制需求") + design_style: str = Field(..., min_length=1) + audience: str = Field(..., min_length=1) + slides: List[SlideOutline] = Field(default_factory=list) + + +class SlidePromptPlan(BaseModel): + """用户可确认的单页设计和内部图像 prompt""" + page: int = Field(..., ge=1) + title: str = Field(..., min_length=1) + content_summary: str = Field(..., min_length=1) + display_content: str = Field(..., min_length=1) + prompt: str = Field(..., min_length=1) + + +class SlidePromptPlanSet(BaseModel): + """逐页设计和 prompt 结构化结果""" + slide_prompts: List[SlidePromptPlan] = Field(default_factory=list) + + @dataclass class PromptData: """所有 Prompt 数据""" diff --git a/src/prompt_generator.py b/src/prompt_generator.py index e9bbcaa..0cabef2 100644 --- a/src/prompt_generator.py +++ b/src/prompt_generator.py @@ -4,11 +4,13 @@ """ import json +import re from datetime import datetime +from typing import Any from .client import AIClient from .config import PPTConfig -from .models import PromptData, SlidePrompt +from .models import DeckOutline, PromptData, SlidePrompt, SlidePromptPlanSet from .prompts import PromptTemplates @@ -21,27 +23,92 @@ def __init__(self, client: AIClient): def generate(self, source_material: str, config: PPTConfig) -> PromptData: """生成所有 Prompt""" user_requirements = self._build_user_requirements(config) - - # 第一步:生成初始 Prompt - print("[Step 2.1] 生成初始 Prompt...") - initial_prompts = self._generate_initial_prompts(source_material, user_requirements, config) - - # 第二步:检查和优化 Prompt - print("[Step 2.2] 检查和优化 Prompt...") + + print("[Step 2.1] 生成 PPT 设计大纲...") + outline = self.generate_outline(source_material, config) + + print("[Step 2.2] 基于设计大纲生成逐页 Prompt...") + initial_prompts = self.generate_prompts_from_outline(source_material, outline, config) + + print("[Step 2.3] 检查和优化 Prompt...") final_prompts = self._review_and_optimize_prompts( initial_prompts, source_material, user_requirements, config ) return final_prompts + + def generate_outline(self, source_material: str, config: PPTConfig) -> DeckOutline: + """生成用户可编辑的整套 PPT 设计大纲""" + user_requirements = self._build_user_requirements(config) + system_instruction = PromptTemplates.get_outline_system(user_requirements, config.num_pages) + base_user_prompt = PromptTemplates.get_outline_user(source_material, config.num_pages) + + last_error = "" + for attempt in range(3): + try: + print(f" 尝试生成设计大纲 ({attempt + 1}/3)...") + user_prompt = self._with_correction(base_user_prompt, last_error) + response = self.client.generate_text(user_prompt, system_instruction) + outline = self._parse_model_response(response, DeckOutline) + self._validate_outline(outline, config) + print(f"✅ 设计大纲生成完成,共 {len(outline.slides)} 页") + return outline + except Exception as e: + last_error = str(e) + print(f" ⚠️ 大纲第 {attempt + 1} 次尝试失败: {last_error}") + if attempt == 2: + raise Exception(f"设计大纲生成失败,重试 3 次后仍失败: {last_error}") + + raise RuntimeError("设计大纲生成异常") + + def generate_prompts_from_outline( + self, + source_material: str, + outline: DeckOutline | dict, + config: PPTConfig, + ) -> PromptData: + """根据用户确认后的大纲生成逐页设计说明和图像 Prompt""" + user_requirements = self._build_user_requirements(config) + outline_model = self._coerce_model(outline, DeckOutline) + self._validate_outline(outline_model, config) + outline_json = self._model_to_json(outline_model) + + system_instruction = PromptTemplates.get_prompts_from_outline_system(user_requirements, config.num_pages) + base_user_prompt = PromptTemplates.get_prompts_from_outline_user( + source_material, + outline_json, + config.num_pages, + ) + + last_error = "" + for attempt in range(3): + try: + print(f" 尝试生成逐页设计 ({attempt + 1}/3)...") + user_prompt = self._with_correction(base_user_prompt, last_error) + response = self.client.generate_text(user_prompt, system_instruction) + prompt_plan = self._parse_model_response(response, SlidePromptPlanSet) + self._validate_prompt_plan(prompt_plan, config) + prompt_data = self._prompt_plan_to_prompt_data(prompt_plan, source_material, user_requirements, config) + print(f"✅ 逐页 Prompt 生成完成,共 {len(prompt_data.slide_prompts)} 页") + return prompt_data + except Exception as e: + last_error = str(e) + print(f" ⚠️ 逐页设计第 {attempt + 1} 次尝试失败: {last_error}") + if attempt == 2: + raise Exception(f"逐页设计生成失败,重试 3 次后仍失败: {last_error}") + + raise RuntimeError("逐页设计生成异常") def _build_user_requirements(self, config: PPTConfig) -> str: """构建用户需求描述""" + custom = (config.user_requirements or "").strip() + custom_line = f"\n- 用户定制要求:{custom}" if custom else "" return f"""- 语言:{config.language} - 风格:{config.style} - 目标受众:{config.target_audience} - 页数:{config.num_pages} 页 - 图片比例:{config.aspect_ratio} -- 图片质量:{config.quality}""" +- 图片质量:{config.quality}{custom_line}""" def _generate_initial_prompts( self, source_material: str, user_requirements: str, config: PPTConfig @@ -122,34 +189,7 @@ def _review_and_optimize_prompts( def _parse_prompt_response(self, response: str, config: PPTConfig) -> PromptData: """解析 LLM 响应中的 JSON(增强错误处理)""" - # 尝试多种方式提取 JSON - json_str = None - - # 方式1:查找 { } 包围的内容 - json_start = response.find('{') - json_end = response.rfind('}') + 1 - - if json_start != -1 and json_end > json_start: - json_str = response[json_start:json_end] - else: - # 方式2:查找 ```json 代码块 - import re - json_match = re.search(r'```json\s*(\{.*?\})\s*```', response, re.DOTALL) - if json_match: - json_str = json_match.group(1) - else: - # 方式3:查找任何 ``` 代码块 - code_match = re.search(r'```\s*(\{.*?\})\s*```', response, re.DOTALL) - if code_match: - json_str = code_match.group(1) - - if not json_str: - raise ValueError(f"未找到有效的 JSON 格式。响应内容: {response[:500]}...") - - try: - data = json.loads(json_str) - except json.JSONDecodeError as e: - raise ValueError(f"JSON 解析失败: {e}。JSON内容: {json_str[:200]}...") + data = self._parse_json_payload(response) # 验证必要字段 if "slide_prompts" not in data: @@ -181,3 +221,117 @@ def _parse_prompt_response(self, response: str, config: PPTConfig) -> PromptData created_at=datetime.now().isoformat(), config=config.to_dict() ) + + def _parse_json_payload(self, response: str) -> Any: + """从模型响应中提取 JSON 对象""" + json_str = None + + json_match = re.search(r'```json\s*(\{.*?\})\s*```', response, re.DOTALL) + if json_match: + json_str = json_match.group(1) + else: + code_match = re.search(r'```\s*(\{.*?\})\s*```', response, re.DOTALL) + if code_match: + json_str = code_match.group(1) + else: + json_start = response.find('{') + json_end = response.rfind('}') + 1 + if json_start != -1 and json_end > json_start: + json_str = response[json_start:json_end] + + if not json_str: + raise ValueError(f"未找到有效的 JSON 格式。响应内容: {response[:500]}...") + + try: + return json.loads(json_str) + except json.JSONDecodeError as e: + raise ValueError(f"JSON 解析失败: {e}。JSON内容: {json_str[:200]}...") + + def _parse_model_response(self, response: str, model_cls): + """提取 JSON 并交给 Pydantic schema 校验""" + data = self._parse_json_payload(response) + return self._coerce_model(data, model_cls) + + def _coerce_model(self, data: Any, model_cls): + if isinstance(data, model_cls): + return data + if hasattr(model_cls, "model_validate"): + return model_cls.model_validate(data) + return model_cls.parse_obj(data) + + def _model_to_dict(self, model: Any) -> dict: + if hasattr(model, "model_dump"): + return model.model_dump() + return model.dict() + + def _model_to_json(self, model: Any) -> str: + return json.dumps(self._model_to_dict(model), ensure_ascii=False, indent=2) + + def _with_correction(self, user_prompt: str, last_error: str) -> str: + if not last_error: + return user_prompt + return ( + f"{user_prompt}\n\n" + "【上一次输出校验失败】\n" + f"{last_error}\n" + "请修正后重新输出完整 JSON,特别注意页数、字段完整性和格式。" + ) + + def _validate_outline(self, outline: DeckOutline, config: PPTConfig) -> None: + if len(outline.slides) != config.num_pages: + raise ValueError(f"大纲页数不匹配: 期望{config.num_pages}页,实际{len(outline.slides)}页") + + pages = [slide.page for slide in outline.slides] + expected = list(range(1, config.num_pages + 1)) + if pages != expected: + raise ValueError(f"大纲页码必须连续为 {expected},实际为 {pages}") + + for slide in outline.slides: + if not slide.key_points: + raise ValueError(f"第 {slide.page} 页缺少 key_points") + + if (config.user_requirements or "").strip() and not outline.user_requirements.strip(): + raise ValueError("大纲未说明已吸收的用户定制要求") + + def _validate_prompt_plan(self, prompt_plan: SlidePromptPlanSet, config: PPTConfig) -> None: + if len(prompt_plan.slide_prompts) != config.num_pages: + raise ValueError( + f"逐页设计页数不匹配: 期望{config.num_pages}页,实际{len(prompt_plan.slide_prompts)}页" + ) + + pages = [slide.page for slide in prompt_plan.slide_prompts] + expected = list(range(1, config.num_pages + 1)) + if pages != expected: + raise ValueError(f"逐页设计页码必须连续为 {expected},实际为 {pages}") + + required_prefix = "你生成的 PPT 其中一页的内容,要图文并茂。" + for slide in prompt_plan.slide_prompts: + if not slide.prompt.startswith(required_prefix): + raise ValueError(f"第 {slide.page} 页 prompt 必须以固定前缀开头") + if len(slide.display_content.strip()) < 20: + raise ValueError(f"第 {slide.page} 页 display_content 过短,无法供用户审阅") + + def _prompt_plan_to_prompt_data( + self, + prompt_plan: SlidePromptPlanSet, + source_material: str, + user_requirements: str, + config: PPTConfig, + ) -> PromptData: + slide_prompts = [ + SlidePrompt( + page=item.page, + title=item.title, + content_summary=item.content_summary, + prompt=item.prompt, + display_content=item.display_content, + ) + for item in prompt_plan.slide_prompts + ] + return PromptData( + slide_prompts=slide_prompts, + created_at=datetime.now().isoformat(), + config=config.to_dict(), + source_material=source_material[:2000], + user_requirements=user_requirements, + ) diff --git a/src/prompts/templates.py b/src/prompts/templates.py index 8f3bf03..a3217d8 100644 --- a/src/prompts/templates.py +++ b/src/prompts/templates.py @@ -6,6 +6,97 @@ class PromptTemplates: """Prompt 模版集合""" + + @staticmethod + def get_outline_system(user_requirements: str, num_pages: int) -> str: + """生成用户可编辑 PPT 设计大纲的系统指令""" + return f"""你是资深的信息架构师和 PPT 总监。 +你的任务不是生成图片 prompt,而是先根据资料和用户需求规划一份用户可审阅、可编辑的 PPT 设计大纲。 + +【用户需求】 +{user_requirements} + +【硬性要求】 +1. 必须严格输出 {num_pages} 页,不能多也不能少 +2. 必须吸收并体现用户需求、输出语言、风格、受众、比例等约束 +3. 每页只写用户能看懂的设计内容:页面标题、叙事目标、关键要点、视觉方向 +4. 不要写内部系统提示词,不要写图像模型指令,不要写“prompt”字段 +5. 页面结构要有完整逻辑:封面/概览/主体展开/总结,但要根据资料内容灵活命名 + +【输出格式】 +只输出 JSON,不要解释,不要 Markdown 代码块: +{{ + "title": "整套 PPT 标题", + "user_requirements": "一句话说明已吸收的用户定制要求", + "design_style": "整体视觉与表达风格", + "audience": "目标受众", + "slides": [ + {{ + "page": 1, + "title": "页面标题", + "narrative_goal": "本页在整体叙事中的作用", + "key_points": ["要点一", "要点二", "要点三"], + "visual_direction": "页面布局、配色、图表或插图方向" + }} + ] +}} +""" + + @staticmethod + def get_outline_user(source_material: str, num_pages: int) -> str: + """生成设计大纲的用户提示""" + return f"""请基于以下资料规划 {num_pages} 页 PPT 设计大纲。 + +【输入资料】 +{source_material[:10000]} + +请严格输出 JSON。 +""" + + @staticmethod + def get_prompts_from_outline_system(user_requirements: str, num_pages: int) -> str: + """根据已确认大纲生成逐页图像 prompt 的系统指令""" + return f"""你是 PPT 设计师和 AI 图像生成 prompt 专家。 +用户已经确认了 PPT 设计大纲。请基于原始资料和确认后的大纲,生成每页的设计说明和内部图像生成 prompt。 + +【用户需求】 +{user_requirements} + +【硬性要求】 +1. 必须严格输出 {num_pages} 页,页码从 1 到 {num_pages} +2. 每页必须忠实遵循确认后的大纲,同时结合原始资料补充准确内容 +3. display_content 是给用户看的页面设计说明,不要包含系统提示词、不要暴露模型调用包装 +4. prompt 是给图像模型使用的完整页面描述,必须以“你生成的 PPT 其中一页的内容,要图文并茂。”开头 +5. prompt 中必须明确页面文字、布局、图表/配图、颜色和字体可读性 + +【输出格式】 +只输出 JSON,不要解释,不要 Markdown 代码块: +{{ + "slide_prompts": [ + {{ + "page": 1, + "title": "页面标题", + "content_summary": "本页核心内容和叙事作用摘要", + "display_content": "用户可审阅的页面设计说明,包含标题、正文要点、主要视觉元素和布局", + "prompt": "你生成的 PPT 其中一页的内容,要图文并茂。..." + }} + ] +}} +""" + + @staticmethod + def get_prompts_from_outline_user(source_material: str, outline_json: str, num_pages: int) -> str: + """根据确认大纲生成逐页 prompt 的用户提示""" + return f"""请根据已确认的大纲生成 {num_pages} 页 PPT 的逐页设计说明和图像 prompt。 + +【原始资料】 +{source_material[:10000]} + +【用户确认后的设计大纲】 +{outline_json} + +请严格输出 JSON。 +""" @staticmethod def get_initial_prompt_system(user_requirements: str, num_pages: int) -> str: diff --git a/tests/test_config_writer.py b/tests/test_config_writer.py new file mode 100644 index 0000000..126701f --- /dev/null +++ b/tests/test_config_writer.py @@ -0,0 +1,77 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import yaml + +from src.config_writer import save_model_profiles_to_config + + +class ConfigWriterTest(unittest.TestCase): + def test_saves_model_profiles_to_config_yaml(self): + with tempfile.TemporaryDirectory() as temp_dir: + config_path = Path(temp_dir) / "config.yaml" + + with patch("src.config_writer.load_yaml_config", return_value={"ppt": {"num_pages": 3}}), \ + patch("src.config_writer.reload_config"): + save_model_profiles_to_config( + { + "prompt_model": { + "model": "text", + "base_url": "https://text.example/v1", + "api_key": "text-key", + "adapter": "openai_chat", + }, + "image_model": { + "model": "image", + "base_url": "https://image.example/v1", + "api_key": "image-key", + "adapter": "raw_chat_multimodal", + }, + }, + config_path=config_path, + ) + + saved = yaml.safe_load(config_path.read_text()) + self.assertEqual(saved["ppt"]["num_pages"], 3) + self.assertEqual(saved["api"]["models"]["prompt_model"]["api_key"], "text-key") + self.assertEqual(saved["api"]["models"]["image_model"]["model"], "image") + + def test_empty_api_key_preserves_existing_secret(self): + with tempfile.TemporaryDirectory() as temp_dir: + config_path = Path(temp_dir) / "config.yaml" + + with patch("src.config_writer.load_yaml_config", return_value={ + "api": { + "models": { + "prompt_model": {"api_key": "old-text-key"}, + "image_model": {"api_key": "old-image-key"}, + } + } + }), patch("src.config_writer.reload_config"): + save_model_profiles_to_config( + { + "prompt_model": { + "model": "new-text", + "base_url": "https://text.example/v1", + "api_key": "", + "adapter": "openai_chat", + }, + "image_model": { + "model": "new-image", + "base_url": "https://image.example/v1", + "api_key": "", + "adapter": "raw_chat_multimodal", + }, + }, + config_path=config_path, + ) + + saved = yaml.safe_load(config_path.read_text()) + self.assertEqual(saved["api"]["models"]["prompt_model"]["api_key"], "old-text-key") + self.assertEqual(saved["api"]["models"]["image_model"]["api_key"], "old-image-key") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_document_parser.py b/tests/test_document_parser.py new file mode 100644 index 0000000..c6bcd82 --- /dev/null +++ b/tests/test_document_parser.py @@ -0,0 +1,71 @@ +import tempfile +import unittest +from pathlib import Path + +from src.document_parser import DocumentParser + + +class DocumentParserTest(unittest.TestCase): + def test_markdown_is_returned_as_normalized_markdown(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "paper.md" + path.write_text("# Title\n\nBody", encoding="utf-8") + + result = DocumentParser().parse(path) + + self.assertEqual(result.filename, "paper.md") + self.assertEqual(result.normalized_markdown, "# Title\n\nBody") + self.assertEqual(result.metadata["parser"], "markdown") + + def test_txt_is_wrapped_as_markdown_text(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "notes.txt" + path.write_text("Line 1\nLine 2", encoding="utf-8") + + result = DocumentParser().parse(path) + + self.assertEqual(result.normalized_markdown, "Line 1\nLine 2") + self.assertEqual(result.metadata["parser"], "text") + + def test_text_pdf_is_parsed_with_lightweight_pdf_extractor(self): + pdf_bytes = b"""%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj +4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj +5 0 obj << /Length 44 >> stream +BT /F1 24 Tf 72 720 Td (Hello PDF text) Tj ET +endstream endobj +xref +0 6 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000241 00000 n +0000000311 00000 n +trailer << /Root 1 0 R /Size 6 >> +startxref +405 +%%EOF +""" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "manual.pdf" + path.write_bytes(pdf_bytes) + + result = DocumentParser().parse(path) + + self.assertIn("Hello PDF text", result.normalized_markdown) + self.assertEqual(result.metadata["parser"], "pypdf") + + def test_unsupported_extension_is_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "archive.zip" + path.write_bytes(b"zip") + + with self.assertRaises(ValueError): + DocumentParser().parse(path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_export_pptx_ratio.py b/tests/test_export_pptx_ratio.py new file mode 100644 index 0000000..9423c57 --- /dev/null +++ b/tests/test_export_pptx_ratio.py @@ -0,0 +1,27 @@ +import tempfile +import unittest +from pathlib import Path + +from PIL import Image +from pptx import Presentation + +from api.routes.export import _export_pptx + + +class ExportPptxRatioTest(unittest.TestCase): + def test_exports_4_3_pptx_when_requested(self): + with tempfile.TemporaryDirectory() as tmp: + image_path = Path(tmp) / "slide.png" + output_path = Path(tmp) / "presentation.pptx" + Image.new("RGB", (800, 600), "white").save(image_path) + + _export_pptx([str(image_path)], str(output_path), aspect_ratio="4:3") + + prs = Presentation(str(output_path)) + + ratio = prs.slide_width / prs.slide_height + self.assertAlmostEqual(ratio, 4 / 3, places=2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generator.py b/tests/test_generator.py index dc11a10..e480f1b 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -5,7 +5,9 @@ """ import sys +import os from pathlib import Path +import pytest # 添加 src 到路径 sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -14,6 +16,11 @@ from src.config import load_sample_material from src.models import PromptData +pytestmark = pytest.mark.skipif( + os.getenv("RUN_MODEL_TESTS") != "1", + reason="真实模型集成测试默认跳过;设置 RUN_MODEL_TESTS=1 后运行" +) + def test_full_generation(num_pages=None): """完整测试:生成 PPT(使用默认配置,仅覆盖页数)""" diff --git a/tests/test_image_result_normalizer.py b/tests/test_image_result_normalizer.py new file mode 100644 index 0000000..5b9a39b --- /dev/null +++ b/tests/test_image_result_normalizer.py @@ -0,0 +1,63 @@ +import base64 +import unittest + +from src.image_result import ImageResultNormalizer + + +class ImageResultNormalizerTest(unittest.TestCase): + def test_extracts_markdown_image_url_and_converts_to_base64(self): + png_bytes = b"\x89PNG\r\n\x1a\nfake-image" + + def fetcher(url: str) -> bytes: + self.assertEqual(url, "https://example.test/slide.png") + return png_bytes + + result = ImageResultNormalizer(fetcher=fetcher).normalize( + { + "choices": [ + { + "message": { + "content": "![image](https://example.test/slide.png)\n" + } + } + ] + } + ) + + self.assertEqual(result.base64_data, base64.b64encode(png_bytes).decode()) + self.assertEqual(result.mime_type, "image/png") + self.assertEqual(result.source, "url") + + def test_accepts_b64_json_from_image_endpoint(self): + encoded = base64.b64encode(b"image-bytes").decode() + + result = ImageResultNormalizer().normalize( + {"data": [{"b64_json": encoded}]} + ) + + self.assertEqual(result.base64_data, encoded) + self.assertEqual(result.source, "base64") + + def test_accepts_data_url(self): + encoded = base64.b64encode(b"image-bytes").decode() + + result = ImageResultNormalizer().normalize( + f"data:image/jpeg;base64,{encoded}" + ) + + self.assertEqual(result.base64_data, encoded) + self.assertEqual(result.mime_type, "image/jpeg") + self.assertEqual(result.source, "base64") + + def test_accepts_plain_base64(self): + encoded = base64.b64encode(b"image-bytes").decode() + + result = ImageResultNormalizer().normalize(encoded) + + self.assertEqual(result.base64_data, encoded) + self.assertEqual(result.mime_type, "image/png") + self.assertEqual(result.source, "base64") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_model_profiles.py b/tests/test_model_profiles.py new file mode 100644 index 0000000..10fd640 --- /dev/null +++ b/tests/test_model_profiles.py @@ -0,0 +1,69 @@ +import unittest + +from src.model_profiles import ModelProfile, ModelProfileSet, load_default_profiles, load_profiles_from_env, resolve_model_profiles + + +class ModelProfileResolutionTest(unittest.TestCase): + def test_edit_profile_inherits_image_profile_by_default(self): + profiles = resolve_model_profiles( + { + "prompt_model": { + "id": "prompt", + "model": "DeepSeek-V4-Pro", + "base_url": "https://text.example/v1", + "api_key": "text-key", + "adapter": "openai_chat", + }, + "image_model": { + "id": "image", + "model": "gpt-image-2", + "base_url": "https://image.example/v1", + "api_key": "image-key", + "adapter": "raw_chat_multimodal", + }, + } + ) + + self.assertEqual(profiles.edit.model, "gpt-image-2") + self.assertEqual(profiles.edit.base_url, "https://image.example/v1") + self.assertEqual(profiles.edit.api_key, "image-key") + self.assertEqual(profiles.edit.adapter, "raw_chat_multimodal") + + def test_explicit_edit_profile_overrides_image_profile(self): + profiles = resolve_model_profiles( + { + "prompt_model": {"model": "text", "base_url": "https://t/v1", "api_key": "t"}, + "image_model": {"model": "image", "base_url": "https://i/v1", "api_key": "i"}, + "edit_model": { + "model": "edit", + "base_url": "https://e/v1", + "api_key": "e", + "adapter": "raw_chat_multimodal", + }, + } + ) + + self.assertEqual(profiles.edit.model, "edit") + self.assertEqual(profiles.edit.base_url, "https://e/v1") + self.assertEqual(profiles.edit.api_key, "e") + + def test_public_view_masks_api_keys(self): + profiles = ModelProfileSet( + prompt=ModelProfile(role="prompt", model="text", base_url="https://t/v1", api_key="secret"), + image=ModelProfile(role="image", model="image", base_url="https://i/v1", api_key="secret"), + edit=ModelProfile(role="edit", model="image", base_url="https://i/v1", api_key="secret"), + ) + + public = profiles.to_public_dict() + + self.assertEqual(public["prompt_model"]["api_key"], "SET") + self.assertEqual(public["image_model"]["api_key"], "SET") + self.assertEqual(public["edit_model"]["api_key"], "SET") + + def test_default_profiles_do_not_load_external_env_file(self): + self.assertIsNone(load_profiles_from_env()) + self.assertIsNone(load_default_profiles({})) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_profile_resolver.py b/tests/test_profile_resolver.py new file mode 100644 index 0000000..75d6205 --- /dev/null +++ b/tests/test_profile_resolver.py @@ -0,0 +1,83 @@ +import unittest +from unittest.mock import patch + +from api.models import EditConfig, GenerationConfig, ImageApiConfig, TextApiConfig +from api.profile_resolver import profiles_from_edit_config, profiles_from_generation_config +from src.model_profiles import ModelProfile, ModelProfileSet + + +class ProfileResolverTest(unittest.TestCase): + def default_profiles(self): + return ModelProfileSet( + prompt=ModelProfile( + role="prompt", + model="default-text", + base_url="https://text.example/v1", + api_key="text-key", + ), + image=ModelProfile( + role="image", + model="default-image", + base_url="https://image.example/v1", + api_key="image-key", + adapter="raw_chat_multimodal", + ), + edit=ModelProfile( + role="edit", + model="default-edit", + base_url="https://edit.example/v1", + api_key="edit-key", + adapter="raw_chat_multimodal", + ), + ) + + def test_empty_frontend_generation_config_uses_backend_profiles(self): + config = GenerationConfig( + text=TextApiConfig(api_key="", base_url="https://oneapi.example/v1", model="DeepSeek-V4-Pro"), + image=ImageApiConfig(api_key="", base_url="https://image.example/v1", model="gpt-image-2"), + page_count=1, + ) + + with patch("api.profile_resolver.load_default_profiles", return_value=self.default_profiles()): + profiles = profiles_from_generation_config(config) + + self.assertEqual(profiles.prompt.api_key, "text-key") + self.assertEqual(profiles.image.api_key, "image-key") + + def test_masked_frontend_generation_config_uses_backend_profiles(self): + config = GenerationConfig( + text=TextApiConfig(api_key="SET", base_url="https://oneapi.example/v1", model="DeepSeek-V4-Pro"), + image=ImageApiConfig(api_key="SET", base_url="https://image.example/v1", model="gpt-image-2"), + page_count=1, + ) + + with patch("api.profile_resolver.load_default_profiles", return_value=self.default_profiles()): + profiles = profiles_from_generation_config(config) + + self.assertEqual(profiles.prompt.model, "default-text") + self.assertEqual(profiles.image.model, "default-image") + + def test_complete_frontend_generation_config_overrides_backend_profiles(self): + config = GenerationConfig( + text=TextApiConfig(api_key="override-text-key", base_url="https://text.override/v1", model="override-text"), + image=ImageApiConfig(api_key="override-image-key", base_url="https://image.override/v1", model="override-image"), + page_count=1, + ) + + with patch("api.profile_resolver.load_default_profiles", return_value=self.default_profiles()): + profiles = profiles_from_generation_config(config) + + self.assertEqual(profiles.prompt.api_key, "override-text-key") + self.assertEqual(profiles.image.api_key, "override-image-key") + + def test_empty_edit_config_uses_backend_profiles(self): + config = EditConfig(api_key="", base_url="", model="gpt-image-2") + + with patch("api.profile_resolver.load_default_profiles", return_value=self.default_profiles()): + profiles = profiles_from_edit_config(config) + + self.assertEqual(profiles.edit.api_key, "edit-key") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_prompt_planning.py b/tests/test_prompt_planning.py new file mode 100644 index 0000000..595d7da --- /dev/null +++ b/tests/test_prompt_planning.py @@ -0,0 +1,93 @@ +import json + +import pytest + +from src.config import PPTConfig +from src.models import DeckOutline +from src.prompt_generator import PromptGenerator + + +class FakeTextClient: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + def generate_text(self, prompt, system_instruction=None): + self.calls.append((prompt, system_instruction)) + if not self.responses: + raise AssertionError("No fake response left") + return self.responses.pop(0) + + +def _outline(page_count=3): + return { + "title": "L9 实验设计", + "user_requirements": "已强调架构、风险和研发受众", + "design_style": "克制科技风", + "audience": "研发团队", + "slides": [ + { + "page": page, + "title": f"第 {page} 页", + "narrative_goal": f"说明第 {page} 个叙事节点", + "key_points": [f"要点 {page}.1", f"要点 {page}.2"], + "visual_direction": "左文右图,低饱和科技配色", + } + for page in range(1, page_count + 1) + ], + } + + +def _prompt_plan(page_count=3): + return { + "slide_prompts": [ + { + "page": page, + "title": f"第 {page} 页", + "content_summary": f"第 {page} 页摘要", + "display_content": f"第 {page} 页展示标题、两条核心要点和一张架构示意图,便于用户审阅。", + "prompt": "你生成的 PPT 其中一页的内容,要图文并茂。" + f"第 {page} 页,标题为第 {page} 页,包含清晰文字和架构插图。", + } + for page in range(1, page_count + 1) + ] + } + + +def test_generate_outline_retries_and_validates_page_count(): + bad = _outline(page_count=2) + good = _outline(page_count=3) + client = FakeTextClient([json.dumps(bad, ensure_ascii=False), json.dumps(good, ensure_ascii=False)]) + generator = PromptGenerator(client) + config = PPTConfig(num_pages=3, user_requirements="强调架构风险", target_audience="研发团队") + + outline = generator.generate_outline("source", config) + + assert isinstance(outline, DeckOutline) + assert len(outline.slides) == 3 + assert len(client.calls) == 2 + assert "上一次输出校验失败" in client.calls[1][0] + assert "强调架构风险" in client.calls[0][1] + + +def test_generate_prompts_from_outline_keeps_display_content_and_exact_pages(): + client = FakeTextClient([json.dumps(_prompt_plan(3), ensure_ascii=False)]) + generator = PromptGenerator(client) + config = PPTConfig(num_pages=3, user_requirements="强调架构风险") + + prompt_data = generator.generate_prompts_from_outline("source", _outline(3), config) + + assert len(prompt_data.slide_prompts) == 3 + assert prompt_data.slide_prompts[0].display_content.startswith("第 1 页展示") + assert prompt_data.slide_prompts[0].prompt.startswith("你生成的 PPT 其中一页的内容,要图文并茂。") + + +def test_generate_prompts_from_outline_rejects_internal_prompt_without_prefix(): + bad_plan = _prompt_plan(2) + bad_plan["slide_prompts"][0]["prompt"] = "画一页 PPT" + client = FakeTextClient([json.dumps(bad_plan, ensure_ascii=False)] * 3) + generator = PromptGenerator(client) + config = PPTConfig(num_pages=2) + + with pytest.raises(Exception, match="逐页设计生成失败"): + generator.generate_prompts_from_outline("source", _outline(2), config) diff --git a/web/index.html b/web/index.html index 2c13594..19f55f9 100644 --- a/web/index.html +++ b/web/index.html @@ -2,9 +2,9 @@ - + - AI PPT Generator + AI PPT 工作台
diff --git a/web/package-lock.json b/web/package-lock.json index b7e108f..ba2bf7e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,6 +15,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.1.6", "@testing-library/react": "^14.1.2", + "@types/node": "^20.10.0", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@typescript-eslint/eslint-plugin": "^6.14.0", @@ -1619,6 +1620,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -6303,6 +6314,13 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", diff --git a/web/public/aippt-logo.svg b/web/public/aippt-logo.svg new file mode 100644 index 0000000..9fe0045 --- /dev/null +++ b/web/public/aippt-logo.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/public/vite.svg b/web/public/vite.svg deleted file mode 100644 index 6a41099..0000000 --- a/web/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web/src/App.tsx b/web/src/App.tsx index e09fd2a..c1ec169 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,12 +5,16 @@ import CenterPanel from './components/CenterPanel' import RightPanel from './components/RightPanel' import ApiConfigForm from './components/ApiConfigForm' import GenerationConfigForm from './components/GenerationConfigForm' +import DesignWorkflowPanel from './components/DesignWorkflowPanel' import GenerateButton from './components/GenerateButton' import ProgressIndicator from './components/ProgressIndicator' import ConfirmDialog from './components/ConfirmDialog' import NewProjectButton from './components/NewProjectButton' import RestoreSessionDialog from './components/RestoreSessionDialog' -import { AppStateProvider, useAppState } from './contexts/AppStateContext' +import { AppStateProvider } from './contexts/AppStateContext' +import { useAppState } from './contexts/useAppState' +import { UiPreferencesProvider } from './contexts/UiPreferencesContext' +import { useUiPreferences } from './contexts/useUiPreferences' import { useGeneration } from './hooks/useGeneration' import { useEdit } from './hooks/useEdit' import { useEditConflict } from './hooks/useEditConflict' @@ -18,14 +22,16 @@ import { useExport } from './hooks/useExport' import { useAutoSave } from './hooks/useAutoSave' import { useStateRestore } from './hooks/useStateRestore' import { StorageService } from './services/storageService' -import { saveFullApiConfig } from './components/ApiConfigForm' -import { GenerationConfig, ExportFormat, FullApiConfig } from './types' +import { uploadDocument } from './services/uploadService' +import { saveFullApiConfig } from './utils/apiConfig' +import { ConfirmedSlidePrompt, GenerationConfig, ExportFormat, FullApiConfig } from './types' /** * 主应用内容组件 * 使用 Context 中的状态 */ function AppContent() { + const { t } = useUiPreferences() const { state, setFile, @@ -56,7 +62,7 @@ function AppContent() { const { state: exportState, startExport - } = useExport(slides) + } = useExport(slides, state.generationConfig.aspectRatio) const [exportError, setExportError] = useState(null) // 状态恢复 @@ -68,6 +74,7 @@ function AppContent() { } = useStateRestore() const [showRestoreDialog, setShowRestoreDialog] = useState(false) + const [confirmedSlidePrompts, setConfirmedSlidePrompts] = useState(null) // 检查是否有可恢复的数据 useEffect(() => { @@ -109,16 +116,12 @@ function AppContent() { // 处理新建项目 const handleNewProject = useCallback(() => { resetState() + setConfirmedSlidePrompts(null) }, [resetState]) - const handleFileSelect = useCallback((file: File) => { - // Read file content - const reader = new FileReader() - reader.onload = (e) => { - const content = e.target?.result as string - setFile(file, content, file.name) - } - reader.readAsText(file) + const handleFileSelect = useCallback(async (file: File) => { + const uploadResult = await uploadDocument(file) + setFile(file, uploadResult.content, uploadResult.filename || file.name) }, [setFile]) const handleSlideSelect = useCallback((slideId: string) => { @@ -150,8 +153,16 @@ function AppContent() { }, [setGenerationConfig]) const handleGenerate = useCallback(() => { - generate() - }, [generate]) + generate(confirmedSlidePrompts || undefined) + }, [confirmedSlidePrompts, generate]) + + const handlePromptsReady = useCallback((prompts: ConfirmedSlidePrompt[]) => { + setConfirmedSlidePrompts(prompts) + }, []) + + const handleClearPrompts = useCallback(() => { + setConfirmedSlidePrompts(null) + }, []) // 处理取消编辑(带冲突检测) const handleEditCancel = useCallback(() => { @@ -189,9 +200,9 @@ function AppContent() { try { await startExport(format) } catch (err) { - setExportError(err instanceof Error ? err.message : '导出失败') + setExportError(err instanceof Error ? err.message : t('export.failed')) } - }, [startExport]) + }, [startExport, t]) return ( <> @@ -234,32 +245,34 @@ function AppContent() { initialConfig={state.fullApiConfig} onConfigChange={handleApiConfigChange} /> - - {/* 分隔线 */} -
- + {/* 生成配置表单 */} - - {/* 分隔线 */} -
- - {/* 生成按钮 */} - - - {/* 进度指示器 - 仅在生成中或有进度时显示 */} - {(isGenerating || progress.status === 'completed' || error) && ( - <> -
+ confirmedPrompts={confirmedSlidePrompts} + onPromptsReady={handlePromptsReady} + onClearPrompts={handleClearPrompts} + > + + + {(isGenerating || progress.status === 'completed' || error) && ( +
- - )} +
+ )} + } rightPanel={ @@ -287,10 +301,10 @@ function AppContent() { {/* 编辑冲突确认对话框 */} - - + + + + + ) } diff --git a/web/src/components/ApiConfigForm.tsx b/web/src/components/ApiConfigForm.tsx index 5cd8a13..ef5a2a7 100644 --- a/web/src/components/ApiConfigForm.tsx +++ b/web/src/components/ApiConfigForm.tsx @@ -1,141 +1,188 @@ -import { useState, useEffect } from 'react' +import { ReactNode, useEffect, useState } from 'react' import { FullApiConfig, ImageApiConfig, TextApiConfig } from '../types' - -const STORAGE_KEY = 'aippt_full_api_config' +import { loadFullApiConfig, saveFullApiConfig, validateFullApiConfig } from '../utils/apiConfig' +import { loadBackendModelProfiles, saveBackendModelProfiles } from '../services/modelProfileService' +import { useUiPreferences } from '../contexts/useUiPreferences' interface ApiConfigFormProps { onConfigChange?: (config: FullApiConfig) => void initialConfig?: FullApiConfig } -const DEFAULT_IMAGE_CONFIG: ImageApiConfig = { - apiKey: '', - baseUrl: '', - model: 'gemini-3-pro-image-preview' -} - -const DEFAULT_TEXT_CONFIG: TextApiConfig = { - apiKey: '', - baseUrl: '', - model: 'gemini-3-pro-preview', - format: 'gemini', - thinkingLevel: 'high' -} +type ModelSection = 'image' | 'edit' | 'text' -export const DEFAULT_FULL_API_CONFIG: FullApiConfig = { - image: DEFAULT_IMAGE_CONFIG, - text: DEFAULT_TEXT_CONFIG +interface ModelCardProps { + id: ModelSection + title: string + subtitle: string + tone: 'amber' | 'violet' | 'emerald' + model: string + isOpen: boolean + hasError?: boolean + errorLabel: string + emptyModelLabel: string + onToggle: (id: ModelSection) => void + children: ReactNode } -export function loadFullApiConfig(): FullApiConfig { - try { - const stored = localStorage.getItem(STORAGE_KEY) - if (stored) { - const parsed = JSON.parse(stored) - return { - image: { - apiKey: parsed.image?.apiKey || '', - baseUrl: parsed.image?.baseUrl || '', - model: parsed.image?.model || DEFAULT_IMAGE_CONFIG.model - }, - text: { - apiKey: parsed.text?.apiKey || '', - baseUrl: parsed.text?.baseUrl || '', - model: parsed.text?.model || DEFAULT_TEXT_CONFIG.model, - format: parsed.text?.format || DEFAULT_TEXT_CONFIG.format, - thinkingLevel: parsed.text?.thinkingLevel ?? DEFAULT_TEXT_CONFIG.thinkingLevel - } - } - } - } catch (e) { - console.error('Failed to load API config from localStorage:', e) +const toneStyles = { + amber: { + card: 'border-amber-200 bg-amber-50/70', + icon: 'bg-amber-100 text-amber-700', + badge: 'bg-amber-100 text-amber-800 border-amber-200' + }, + violet: { + card: 'border-violet-200 bg-violet-50/60', + icon: 'bg-violet-100 text-violet-700', + badge: 'bg-violet-100 text-violet-800 border-violet-200' + }, + emerald: { + card: 'border-emerald-200 bg-emerald-50/60', + icon: 'bg-emerald-100 text-emerald-700', + badge: 'bg-emerald-100 text-emerald-800 border-emerald-200' } - return DEFAULT_FULL_API_CONFIG } -export function saveFullApiConfig(config: FullApiConfig): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(config)) - } catch (e) { - console.error('Failed to save API config to localStorage:', e) - } -} +function ModelCard({ + id, + title, + subtitle, + tone, + model, + isOpen, + hasError = false, + errorLabel, + emptyModelLabel, + onToggle, + children +}: ModelCardProps) { + const styles = toneStyles[tone] -export function validateFullApiConfig(config: FullApiConfig): { - isValid: boolean - errors: { - image?: { apiKey?: string; baseUrl?: string; model?: string } - text?: { apiKey?: string; baseUrl?: string; model?: string } - } -} { - const errors: { - image?: { apiKey?: string; baseUrl?: string; model?: string } - text?: { apiKey?: string; baseUrl?: string; model?: string } - } = {} - - const imageErrors: { apiKey?: string; baseUrl?: string; model?: string } = {} - if (!config.image.apiKey?.trim()) imageErrors.apiKey = '图像模型 API Key 不能为空' - if (!config.image.baseUrl?.trim()) { - imageErrors.baseUrl = '图像模型 Base URL 不能为空' - } else { - try { new URL(config.image.baseUrl) } catch { imageErrors.baseUrl = '请输入有效的 URL 格式' } - } - if (!config.image.model?.trim()) imageErrors.model = '图像模型名称不能为空' - if (Object.keys(imageErrors).length > 0) errors.image = imageErrors - - const textErrors: { apiKey?: string; baseUrl?: string; model?: string } = {} - if (!config.text.apiKey?.trim()) textErrors.apiKey = '文本模型 API Key 不能为空' - if (!config.text.baseUrl?.trim()) { - textErrors.baseUrl = '文本模型 Base URL 不能为空' - } else { - try { new URL(config.text.baseUrl) } catch { textErrors.baseUrl = '请输入有效的 URL 格式' } - } - if (!config.text.model?.trim()) textErrors.model = '文本模型名称不能为空' - if (Object.keys(textErrors).length > 0) errors.text = textErrors - - return { isValid: Object.keys(errors).length === 0, errors } -} - -export function loadApiConfig() { - const full = loadFullApiConfig() - return { apiKey: full.image.apiKey, baseUrl: full.image.baseUrl } -} - -export function saveApiConfig(config: { apiKey: string; baseUrl: string }) { - const full = loadFullApiConfig() - full.image.apiKey = config.apiKey - full.image.baseUrl = config.baseUrl - saveFullApiConfig(full) -} + return ( +
+ -export function validateApiConfig(config: { apiKey: string; baseUrl: string }) { - const errors: { apiKey?: string; baseUrl?: string } = {} - if (!config.apiKey?.trim()) errors.apiKey = 'API Key 不能为空' - if (!config.baseUrl?.trim()) { - errors.baseUrl = 'Base URL 不能为空' - } else { - try { new URL(config.baseUrl) } catch { errors.baseUrl = '请输入有效的 URL 格式' } - } - return { isValid: Object.keys(errors).length === 0, errors } + {isOpen && ( +
+ {children} +
+ )} +
+ ) } /** - * API 配置表单组件 - 橙黄主题 + * API 配置表单组件 */ function ApiConfigForm({ onConfigChange, initialConfig }: ApiConfigFormProps) { + const { t } = useUiPreferences() const [config, setConfig] = useState(() => initialConfig || loadFullApiConfig()) const [errors, setErrors] = useState<{ image?: { apiKey?: string; baseUrl?: string; model?: string } text?: { apiKey?: string; baseUrl?: string; model?: string } + edit?: { apiKey?: string; baseUrl?: string; model?: string } }>({}) + const [openSections, setOpenSections] = useState>(() => { + const loaded = initialConfig || loadFullApiConfig() + return { + image: !loaded.image.baseUrl, + edit: !(loaded.edit || loaded.image).baseUrl, + text: !loaded.text.baseUrl + } + }) const [showImageApiKey, setShowImageApiKey] = useState(false) const [showTextApiKey, setShowTextApiKey] = useState(false) + const [showEditApiKey, setShowEditApiKey] = useState(false) const [saved, setSaved] = useState(false) + const [saveError, setSaveError] = useState(null) + const [isOpen, setIsOpen] = useState(false) const [useSharedConfig, setUseSharedConfig] = useState(() => { const loaded = loadFullApiConfig() - return loaded.image.apiKey === loaded.text.apiKey && loaded.image.baseUrl === loaded.text.baseUrl + return Boolean(loaded.image.baseUrl && loaded.image.baseUrl === loaded.text.baseUrl) }) + useEffect(() => { + let cancelled = false + loadBackendModelProfiles() + .then(response => { + if (cancelled || !response.success || !response.profiles) return + const { prompt_model, image_model, edit_model } = response.profiles + setConfig(prev => ({ + image: { + apiKey: prev.image.apiKey, + baseUrl: image_model.base_url, + model: image_model.model + }, + text: { + apiKey: prev.text.apiKey, + baseUrl: prompt_model.base_url, + model: prompt_model.model, + format: 'openai', + thinkingLevel: null + }, + edit: { + apiKey: prev.edit?.apiKey || '', + baseUrl: edit_model.base_url, + model: edit_model.model + } + })) + setOpenSections({ + image: !image_model.base_url, + edit: !edit_model.base_url, + text: !prompt_model.base_url + }) + }) + .catch(() => { + setOpenSections({ image: true, edit: true, text: true }) + }) + return () => { + cancelled = true + } + }, []) + useEffect(() => { onConfigChange?.(config) }, [config, onConfigChange]) @@ -144,17 +191,25 @@ function ApiConfigForm({ onConfigChange, initialConfig }: ApiConfigFormProps) { if (useSharedConfig) { setConfig(prev => ({ ...prev, - text: { ...prev.text, apiKey: prev.image.apiKey, baseUrl: prev.image.baseUrl } + text: { ...prev.text, apiKey: prev.image.apiKey, baseUrl: prev.image.baseUrl }, + edit: { ...(prev.edit || prev.image), apiKey: prev.image.apiKey, baseUrl: prev.image.baseUrl } })) } }, [useSharedConfig]) + const toggleSection = (section: ModelSection) => { + setOpenSections(prev => ({ ...prev, [section]: !prev[section] })) + } + const handleImageConfigChange = (field: keyof ImageApiConfig, value: string) => { setConfig(prev => { const newConfig = { ...prev, image: { ...prev.image, [field]: value } } if (useSharedConfig && (field === 'apiKey' || field === 'baseUrl')) { newConfig.text = { ...newConfig.text, [field]: value } } + if (!prev.edit || prev.edit[field] === prev.image[field]) { + newConfig.edit = { ...(prev.edit || prev.image), [field]: value } + } return newConfig }) setSaved(false) @@ -171,243 +226,343 @@ function ApiConfigForm({ onConfigChange, initialConfig }: ApiConfigFormProps) { } } - const handleSave = () => { + const handleEditConfigChange = (field: keyof ImageApiConfig, value: string) => { + setConfig(prev => ({ + ...prev, + edit: { ...(prev.edit || prev.image), [field]: value } + })) + setSaved(false) + if (errors.edit?.[field]) { + setErrors(prev => ({ ...prev, edit: { ...prev.edit, [field]: undefined } })) + } + } + + const handleSave = async () => { const validation = validateFullApiConfig(config) if (!validation.isValid) { setErrors(validation.errors) + setIsOpen(true) + setOpenSections(prev => ({ + image: prev.image || Boolean(validation.errors.image), + edit: prev.edit || Boolean(validation.errors.edit), + text: prev.text || Boolean(validation.errors.text) + })) return } - saveFullApiConfig(config) - setErrors({}) - setSaved(true) - setTimeout(() => setSaved(false), 3000) + try { + await saveBackendModelProfiles(config) + saveFullApiConfig(config) + setErrors({}) + setSaveError(null) + setSaved(true) + setTimeout(() => setSaved(false), 3000) + } catch (error) { + setSaveError(error instanceof Error ? error.message : t('api.saveError')) + } } const inputClass = (hasError: boolean) => ` - w-full px-4 py-2.5 text-sm border rounded-xl transition-all duration-200 - focus:outline-none focus:ring-2 focus:ring-primary-400 focus:border-transparent - ${hasError ? 'border-red-400 bg-red-50' : 'border-warm-200 bg-white'} + w-full px-4 py-2.5 text-sm border rounded-lg transition-all duration-200 + focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:border-transparent + ${hasError ? 'border-red-400 bg-red-50' : 'border-slate-200 bg-white'} ` + const keyPlaceholder = (hasBaseUrl: boolean, label: string) => + hasBaseUrl ? t('api.keyConfiguredPlaceholder') : t('api.keyPlaceholder', { label }) + + const renderKeyField = ( + value: string, + onChange: (value: string) => void, + showKey: boolean, + setShowKey: (value: boolean) => void, + placeholder: string, + hasError: boolean + ) => ( +
+ +
+ onChange(event.target.value)} + placeholder={placeholder} + className={`${inputClass(hasError)} pr-10`} + /> + +
+
+ ) + + const editConfig = config.edit || config.image + const configuredModels = [config.text.model, config.image.model, editConfig.model].filter(Boolean) + const modelSummary = configuredModels.length > 0 ? configuredModels.join(' / ') : t('api.unsetModel') return ( -
- {/* Header */} -
-
-
- +
+
+
+
- API 配置 -
- -
- - {/* 图像模型配置 */} -
-

-
- - - +
+

{t('api.title')}

+

{modelSummary}

- 图像生成模型 -

- -
- -
- handleImageConfigChange('apiKey', e.target.value)} - placeholder="输入图像模型 API Key" - className={inputClass(!!errors.image?.apiKey)} - /> - -
- {errors.image?.apiKey &&

{errors.image.apiKey}

}
- -
- - handleImageConfigChange('baseUrl', e.target.value)} - placeholder="https://api.example.com" - className={inputClass(!!errors.image?.baseUrl)} - /> - {errors.image?.baseUrl &&

{errors.image.baseUrl}

} -
- -
- - handleImageConfigChange('model', e.target.value)} - placeholder="gemini-3-pro-image-preview" - className={inputClass(!!errors.image?.model)} - /> - {errors.image?.model &&

{errors.image.model}

} -
-
+ + + + - {/* 文本模型配置 */} -
-

-
- - - -
- 文本生成模型 -

- - {!useSharedConfig && ( - <> -
- -
+ {isOpen && ( +
+
+
+
+
+
+ + + +
+
+

{t('api.title')}

+

{t('api.subtitle')}

+
+
+
+
- {errors.text?.apiKey &&

{errors.text.apiKey}

} + {t('api.shared')} + +
+
+ + + {renderKeyField( + config.image.apiKey, + value => handleImageConfigChange('apiKey', value), + showImageApiKey, + setShowImageApiKey, + keyPlaceholder(!!config.image.baseUrl, t('api.imageLabel')), + !!errors.image?.apiKey + )} + {errors.image?.apiKey &&

{errors.image.apiKey}

} +
+ + handleImageConfigChange('baseUrl', event.target.value)} + placeholder={t('api.imageBasePlaceholder')} + className={inputClass(!!errors.image?.baseUrl)} + /> + {errors.image?.baseUrl &&

{errors.image.baseUrl}

} +
+
+ + handleImageConfigChange('model', event.target.value)} + placeholder="gpt-image-2" + className={inputClass(!!errors.image?.model)} + /> + {errors.image?.model &&

{errors.image.model}

} +
+
+ + + {renderKeyField( + editConfig.apiKey, + value => handleEditConfigChange('apiKey', value), + showEditApiKey, + setShowEditApiKey, + keyPlaceholder(!!editConfig.baseUrl, t('api.editLabel')), + !!errors.edit?.apiKey + )} + {errors.edit?.apiKey &&

{errors.edit.apiKey}

} +
+ + handleEditConfigChange('baseUrl', event.target.value)} + placeholder={t('api.editBasePlaceholder')} + className={inputClass(!!errors.edit?.baseUrl)} + /> + {errors.edit?.baseUrl &&

{errors.edit.baseUrl}

} +
+
+ + handleEditConfigChange('model', event.target.value)} + placeholder="gpt-image-2" + className={inputClass(!!errors.edit?.model)} + /> + {errors.edit?.model &&

{errors.edit.model}

}
- +
+ + + {renderKeyField( + config.text.apiKey, + value => handleTextConfigChange('apiKey', value), + showTextApiKey, + setShowTextApiKey, + keyPlaceholder(!!config.text.baseUrl, t('api.textLabel')), + !!errors.text?.apiKey + )} + {errors.text?.apiKey &&

{errors.text.apiKey}

}
- + handleTextConfigChange('baseUrl', e.target.value)} - placeholder="https://api.example.com" + onChange={(event) => handleTextConfigChange('baseUrl', event.target.value)} + placeholder={t('api.imageBasePlaceholder')} className={inputClass(!!errors.text?.baseUrl)} /> {errors.text?.baseUrl &&

{errors.text.baseUrl}

}
- - )} - -
- - handleTextConfigChange('model', e.target.value)} - placeholder="gemini-3-pro-preview" - className={inputClass(!!errors.text?.model)} - /> - {errors.text?.model &&

{errors.text.model}

} -
- -
- -
- {(['gemini', 'openai'] as const).map((format) => ( - - ))} -
-
- - {config.text.format === 'gemini' && ( -
- -
- {[ - { value: null, label: '关闭' }, - { value: 'low', label: '低' }, - { value: 'high', label: '高' } - ].map((option) => ( - - ))} +
+ + handleTextConfigChange('model', event.target.value)} + placeholder={t('api.textModelPlaceholder')} + className={inputClass(!!errors.text?.model)} + /> + {errors.text?.model &&

{errors.text.model}

}
-

仅支持 Gemini 3+ 系列模型

-
- )} -
+
+ +
+ {(['gemini', 'openai'] as const).map(format => ( + + ))} +
+
+ {config.text.format === 'gemini' && ( +
+ +
+ {[ + { value: null, label: t('api.thinking.off') }, + { value: 'low', label: t('api.thinking.low') }, + { value: 'high', label: t('api.thinking.high') } + ].map(option => ( + + ))} +
+
+ )} +
- {/* 保存按钮 */} -
- - {saved && ( - - - - - 已保存 - - )} -
-
+
+ + {saved && ( + + + + + {t('common.saved')} + + )} +
+ {saveError &&

{saveError}

} +
+ )} + ) } diff --git a/web/src/components/CenterPanel.tsx b/web/src/components/CenterPanel.tsx index 8f030a1..8a2c5b5 100644 --- a/web/src/components/CenterPanel.tsx +++ b/web/src/components/CenterPanel.tsx @@ -1,5 +1,6 @@ import { EditSession, EditHistoryItem } from '../types' import EditPanel from './EditPanel' +import { useUiPreferences } from '../contexts/useUiPreferences' interface CenterPanelProps { isEditMode: boolean @@ -25,6 +26,8 @@ function CenterPanel({ onRevertToVersion, children }: CenterPanelProps) { + const { t } = useUiPreferences() + return (
{isEditMode && editSession ? ( @@ -44,15 +47,15 @@ function CenterPanel({
{/* Section Header */}
-
+
-

生成设置

-

配置 API 和生成参数

+

{t('center.title')}

+

{t('center.subtitle')}

@@ -65,7 +68,7 @@ function CenterPanel({
-

设置表单将在后续任务中实现

+

{t('center.empty')}

)}
diff --git a/web/src/components/ConfirmDialog.tsx b/web/src/components/ConfirmDialog.tsx index 4556061..63f7052 100644 --- a/web/src/components/ConfirmDialog.tsx +++ b/web/src/components/ConfirmDialog.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react' +import { useUiPreferences } from '../contexts/useUiPreferences' interface ConfirmDialogProps { isOpen: boolean @@ -20,14 +21,17 @@ function ConfirmDialog({ isOpen, title, message, - confirmText = '确认', - cancelText = '取消', + confirmText, + cancelText, confirmVariant = 'primary', onConfirm, onCancel }: ConfirmDialogProps) { + const { t } = useUiPreferences() const dialogRef = useRef(null) const confirmButtonRef = useRef(null) + const resolvedConfirmText = confirmText || t('common.confirm') + const resolvedCancelText = cancelText || t('common.cancel') // 打开时聚焦确认按钮 useEffect(() => { @@ -110,7 +114,7 @@ function ConfirmDialog({ className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors" data-testid="confirm-dialog-cancel" > - {cancelText} + {resolvedCancelText}
diff --git a/web/src/components/DesignWorkflowPanel.tsx b/web/src/components/DesignWorkflowPanel.tsx new file mode 100644 index 0000000..b7e15c8 --- /dev/null +++ b/web/src/components/DesignWorkflowPanel.tsx @@ -0,0 +1,444 @@ +import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react' +import { ConfirmedSlidePrompt, DeckOutline, FullApiConfig, GenerationConfig } from '../types' +import { requestDeckOutline, requestSlidePrompts } from '../services/generateService' +import { useUiPreferences } from '../contexts/useUiPreferences' + +type WorkflowStatus = + | 'idle' + | 'outline_loading' + | 'outline_ready' + | 'prompts_loading' + | 'prompts_ready' + | 'error' + +interface DesignWorkflowPanelProps { + fileContent: string + fullApiConfig: FullApiConfig + generationConfig: GenerationConfig + confirmedPrompts: ConfirmedSlidePrompt[] | null + onPromptsReady: (prompts: ConfirmedSlidePrompt[]) => void + onClearPrompts: () => void + children?: ReactNode +} + +function renderLines(content: string): string[] { + return content + .split(/\n+/) + .map((line) => line.trim()) + .filter(Boolean) +} + +function DesignWorkflowPanel({ + fileContent, + fullApiConfig, + generationConfig, + confirmedPrompts, + onPromptsReady, + onClearPrompts, + children +}: DesignWorkflowPanelProps) { + const { t } = useUiPreferences() + const [status, setStatus] = useState('idle') + const [outline, setOutline] = useState(null) + const [slidePrompts, setSlidePrompts] = useState([]) + const [expandedOutlinePages, setExpandedOutlinePages] = useState>(new Set()) + const [expandedDesignPages, setExpandedDesignPages] = useState>(new Set()) + const [isOpen, setIsOpen] = useState(true) + const [error, setError] = useState(null) + + const resetKey = useMemo( + () => JSON.stringify({ + content: fileContent, + pages: generationConfig.pageCount, + language: generationConfig.language, + style: generationConfig.style, + audience: generationConfig.targetAudience, + requirements: generationConfig.userRequirements + }), + [fileContent, generationConfig] + ) + + useEffect(() => { + setStatus('idle') + setOutline(null) + setSlidePrompts([]) + setExpandedOutlinePages(new Set()) + setExpandedDesignPages(new Set()) + setError(null) + onClearPrompts() + }, [resetKey, onClearPrompts]) + + const canPlan = fileContent.trim().length > 0 && status !== 'outline_loading' && status !== 'prompts_loading' + const hasConfirmedPrompts = Boolean(confirmedPrompts?.length) + + const handleGenerateOutline = useCallback(async () => { + if (!canPlan) return + setStatus('outline_loading') + setError(null) + onClearPrompts() + + try { + const nextOutline = await requestDeckOutline({ + content: fileContent, + fullApiConfig, + generationConfig + }) + setOutline(nextOutline) + setSlidePrompts([]) + setExpandedOutlinePages(new Set()) + setExpandedDesignPages(new Set()) + setStatus('outline_ready') + } catch (err) { + setError(err instanceof Error ? err.message : t('workflow.outlineFailed')) + setStatus('error') + } + }, [canPlan, fileContent, fullApiConfig, generationConfig, onClearPrompts, t]) + + const handleGeneratePrompts = useCallback(async () => { + if (!outline) return + setStatus('prompts_loading') + setError(null) + onClearPrompts() + + try { + const parsedOutline = outline + if (!Array.isArray(parsedOutline.slides) || parsedOutline.slides.length !== generationConfig.pageCount) { + throw new Error(t('workflow.pageCountMismatch', { count: generationConfig.pageCount })) + } + + const prompts = await requestSlidePrompts({ + content: fileContent, + fullApiConfig, + generationConfig, + outline: parsedOutline + }) + setOutline(parsedOutline) + setSlidePrompts(prompts) + onPromptsReady(prompts) + setStatus('prompts_ready') + } catch (err) { + setError(err instanceof Error ? err.message : t('workflow.promptsFailed')) + setStatus('outline_ready') + } + }, [fileContent, fullApiConfig, generationConfig, outline, onClearPrompts, onPromptsReady, t]) + + const markOutlineDirty = useCallback(() => { + if (slidePrompts.length > 0 || hasConfirmedPrompts) { + setSlidePrompts([]) + onClearPrompts() + setExpandedDesignPages(new Set()) + setStatus('outline_ready') + } + }, [hasConfirmedPrompts, onClearPrompts, slidePrompts.length]) + + const updateOutlineField = useCallback((field: keyof Omit, value: string) => { + markOutlineDirty() + setOutline((current) => current ? { ...current, [field]: value } : current) + }, [markOutlineDirty]) + + const updateSlideField = useCallback(( + page: number, + field: 'title' | 'narrative_goal' | 'visual_direction', + value: string + ) => { + markOutlineDirty() + setOutline((current) => current + ? { + ...current, + slides: current.slides.map((slide) => ( + slide.page === page ? { ...slide, [field]: value } : slide + )) + } + : current) + }, [markOutlineDirty]) + + const updateSlideKeyPoints = useCallback((page: number, value: string) => { + markOutlineDirty() + const keyPoints = value + .split('\n') + .map((item) => item.trim()) + .filter(Boolean) + setOutline((current) => current + ? { + ...current, + slides: current.slides.map((slide) => ( + slide.page === page ? { ...slide, key_points: keyPoints } : slide + )) + } + : current) + }, [markOutlineDirty]) + + const toggleOutlinePage = useCallback((page: number) => { + setExpandedOutlinePages((current) => { + const next = new Set(current) + if (next.has(page)) next.delete(page) + else next.add(page) + return next + }) + }, []) + + const toggleDesignPage = useCallback((page: number) => { + setExpandedDesignPages((current) => { + const next = new Set(current) + if (next.has(page)) next.delete(page) + else next.add(page) + return next + }) + }, []) + + return ( +
+ + + {isOpen && ( +
+
+ {[ + ['1', t('workflow.stepOutline')], + ['2', t('workflow.stepPrompts')], + ['3', t('workflow.stepImages')] + ].map(([index, label], stepIndex) => ( +
+ {index} + {label} +
+ ))} +
+ + + + {outline && ( +
+
+
+

{t('workflow.outlinePlan')}

+ + {t('workflow.outlinePages', { count: outline.slides.length })} + +
+ +
+ + +
+ + +
+ +