中期成果提交:PPT自动生成 Agent(感知-规划-行动-记忆闭环 + aura-ppt 渲染引擎 + Web 交互界面 + 测试用例 + AI 使用日志)
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
__pycache__/
|
||||
*.pyc
|
||||
src/web/data/
|
||||
engine/ppt-master/
|
||||
tests/_e2e_download.pptx
|
||||
@@ -0,0 +1,28 @@
|
||||
# AGENTS.md — AI 协作方式与项目说明
|
||||
|
||||
> 本文件说明本项目的 AI 开发协作方式,并与根目录 `_AI_USAGE_LOG.md` 配合溯源。
|
||||
|
||||
## 项目说明
|
||||
|
||||
- **项目**:PPT 自动生成 Agent(赛道一:Agent 开发实战赛 · 新规项目)
|
||||
- **核心定位**:Agent 为 C 位,Web 为必要交互界面。Agent 包装 aura-ppt 主渲染引擎,实现「感知→规划→行动→记忆」业务闭环。
|
||||
- **技术栈**:OpenCode + DeepSeek(完全免费,大赛推荐);主渲染引擎 aura-ppt(python-pptx 确定性渲染);Web 框架 Flask;数据存储 SQLite;大模型经 opencode serve 免费接入(mimo-v2.5-free,零 Key),预留 OpenAI 兼容接口,配 Key 即切 DeepSeek。
|
||||
- **开发范式**:需求澄清 → AI 生成方案 → 人工审核 → AI 编码实现 → 测试验证 → 反馈迭代(范式图见 `DESIGN.md`)。
|
||||
|
||||
## AI 使用日志规则(自动执行)
|
||||
|
||||
每次创建或修改代码文件后,在项目根目录的 `_AI_USAGE_LOG.md` 中追加一条记录,必须包含以下字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 日期时间 | AI 修改代码的时间 |
|
||||
| 范式步骤 | 与 `DESIGN.md` 范式图步骤名一致 |
|
||||
| 修改摘要 | 本次 AI 修改做了什么 |
|
||||
| 涉及文件 | 被 AI 创建或修改的代码文件路径 |
|
||||
| 使用模型 | 本次使用的 AI 模型(DeepSeek) |
|
||||
|
||||
## 协作约定
|
||||
|
||||
1. 所有文件创建、修改、删除等操作,先经人工确认后执行。
|
||||
2. 提供方案时给出选项与推荐,由人工决定方向。
|
||||
3. 代码红线:不硬编码绝对路径、不硬编码 API Key / 密码(经环境变量注入)。
|
||||
@@ -0,0 +1,184 @@
|
||||
# 设计文档
|
||||
|
||||
> 成果物02 · 赛道一:Agent开发实战赛 · 新规项目
|
||||
|
||||
---
|
||||
|
||||
## 一、场景描述与业务价值
|
||||
|
||||
### 场景说明
|
||||
|
||||
本项目聚焦公司内部两大高频 PPT 场景:
|
||||
|
||||
| 场景 | 描述 | 典型用途 |
|
||||
|------|------|---------|
|
||||
| **社内汇报** | 面向团队/本部的进展报告与提案 | 项目周报、改善方案汇报、团队建设进度 |
|
||||
| **社内培训** | 面向同事的技术/方法论知识传授 | 工具教学、技术培训、新流程讲解 |
|
||||
|
||||
详细场景规范见 `事前调查/02-场景规范/社内汇报与培训PPT规范.md`。
|
||||
|
||||
### 业务价值
|
||||
|
||||
| 维度 | 当前痛点 | Agent 方案效果 |
|
||||
|------|---------|---------------|
|
||||
| **效率** | 单份 PPT 从搭框架到排版耗时数小时 | 文字输入后分钟级生成 |
|
||||
| **门槛** | 需要 PowerPoint 操作技巧和设计感 | 无需任何 PPT 技能,输入文字即可 |
|
||||
| **一致性** | 各人风格不统一,交付质量参差 | 统一模板和配色系统,风格稳定专业 |
|
||||
| **迭代** | 修改需手动逐页调整 | AI 对话一句话改,多版本自动存档 |
|
||||
|
||||
---
|
||||
|
||||
## 二、开发范式流程图
|
||||
|
||||
> 本图描述团队使用 AI 进行开发的完整工作流。图中步骤名将在 AI 使用日志的"范式步骤"列中一一对应,供评委对照验证。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[需求澄清] --> B[AI 生成方案]
|
||||
B --> C[人工审核确认]
|
||||
C --> D[AI 编码实现]
|
||||
D --> E[测试验证]
|
||||
E --> F{通过?}
|
||||
F -->|是| G[功能完成]
|
||||
F -->|否| H[反馈 AI 修正]
|
||||
H --> C
|
||||
G --> I[文档更新]
|
||||
I --> J[下一功能]
|
||||
```
|
||||
|
||||
**范式步骤说明**:
|
||||
|
||||
| 步骤 | 说明 | 对应日志验证 |
|
||||
|------|------|------------|
|
||||
| 需求澄清 | 用户用自然语言描述功能需求,AI 追问确认细节 | 日志中标记"需求澄清"的记录 |
|
||||
| AI 生成方案 | AI 给出修改方案或新功能设计,用户审阅 | 日志中标记"AI生成方案"的记录 |
|
||||
| 人工审核确认 | 用户回复"ok"确认方案,或提出修改意见 | 用户回复记录 |
|
||||
| AI 编码实现 | AI 修改/创建代码文件,遵循"先确认后修改"原则 | 日志中"涉及文件"列 + "AI编码"标记 |
|
||||
| 测试验证 | 用户或测试顾问运行功能、检查输出、对比预期 | 实验报告中的测试用例记录 |
|
||||
| 反馈迭代 | 发现问题后描述给 AI,AI 修正后重新验证 | 同一功能的多次编码记录形成闭环 |
|
||||
|
||||
---
|
||||
|
||||
## 三、Agent 架构图
|
||||
|
||||
> 赛道一要求:Agent 必须能自主完成"感知→规划→行动"业务闭环。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph 前端["Web 交互界面(HTML + JS)"]
|
||||
A1[用户注册 / 登录]
|
||||
A2[输入文字内容]
|
||||
A3[选择场景:社内汇报 / 社内培训]
|
||||
A4[选择配色风格]
|
||||
A5[下载 PPTX]
|
||||
A6[AI 对话修改]
|
||||
A7[查看历史记录]
|
||||
end
|
||||
|
||||
subgraph 感知["感知层(Perception)"]
|
||||
B1[解析输入文字]
|
||||
B2[识别场景选择]
|
||||
B3[读取用户偏好]
|
||||
end
|
||||
|
||||
subgraph 规划["规划层(Planning)"]
|
||||
C1[场景 → 模板匹配]
|
||||
C2[内容 → 页面分配]
|
||||
C3[偏好 → 配色应用]
|
||||
end
|
||||
|
||||
subgraph 行动["行动层(Action)"]
|
||||
D1[LLM 内容规划 → 生成 plan.json]
|
||||
D2[调用 aura-ppt 渲染引擎]
|
||||
D3[plan.json → PPTX 确定性渲染]
|
||||
D4[内容保真验证 + 导出 PPTX]
|
||||
D5[AI 对话修改重新生成]
|
||||
end
|
||||
|
||||
subgraph 记忆["记忆层(Memory)"]
|
||||
E1[(SQLite 用户表)]
|
||||
E2[(SQLite 历史记录表)]
|
||||
E3[(SQLite 偏好表)]
|
||||
end
|
||||
|
||||
A1 --> B3
|
||||
A2 --> B1
|
||||
A3 --> B2
|
||||
A4 --> B3
|
||||
B1 --> C2
|
||||
B2 --> C1
|
||||
B3 --> C3
|
||||
C1 --> D1
|
||||
C2 --> D1
|
||||
C3 --> D1
|
||||
D1 --> D2
|
||||
D2 --> D3
|
||||
D3 --> D4
|
||||
D4 --> A5
|
||||
D4 --> E2
|
||||
A6 --> B1
|
||||
D5 --> A5
|
||||
A7 --> E2
|
||||
E2 --> A7
|
||||
E3 --> B3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、架构说明
|
||||
|
||||
### 组件划分
|
||||
|
||||
| 层 | 技术实现 | 核心职责 |
|
||||
|------|---------|---------|
|
||||
| **前端** | HTML + JavaScript | 用户交互:登录、输入、场景/配色选择、下载、对话、历史浏览 |
|
||||
| **感知层** | Python | 解析用户输入文字内容,识别场景选择,读取用户历史偏好 |
|
||||
| **规划层** | Python | 根据场景匹配对应模板结构,分配页数,应用配色方案 |
|
||||
| **行动层** | Python + aura-ppt | LLM 生成 plan.json 页面规划,调用 aura-ppt 确定性渲染 PPTX |
|
||||
| **记忆层** | SQLite | 存储用户信息、生成历史(多版本)、个人偏好(配色/场景) |
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
用户输入 → 感知层(解析+识别) → 规划层(LLM 内容规划)
|
||||
→ 行动层(plan.json → aura-ppt → PPTX)
|
||||
→ 输出下载 + 写入记忆层
|
||||
|
||||
对话修改时:
|
||||
用户指令 → 感知层 → 规划层(局部方案) → 行动层(重新生成指定页) → 新版本写入记忆层
|
||||
```
|
||||
|
||||
### 关键设计决策
|
||||
|
||||
| 决策 | 内容 | 理由 |
|
||||
|------|------|------|
|
||||
| **引擎选型** | 包装 aura-ppt,不改内核 | plan.json → PPTX 确定性渲染,本地 python-pptx 零云端依赖 |
|
||||
| **场景结构** | 汇报/培训两套预定义模板 | 从社内标杆 PPT 中提炼,确保结构符合公司标准 |
|
||||
| **配色系统** | 白底 + 商务蓝色系 | 用户偏好明确,与公司社内报告风格一致 |
|
||||
| **AI 对话修改** | 局部重新生成,非全文重跑 | 减少 Token 消耗,响应更快 |
|
||||
| **多版本存档** | 每次修改生成新版本,保留旧版 | 支持用户回溯到任意历史版本 |
|
||||
| **开发方式** | 先给方案确认,再改文件 | 用户习惯约定,避免返工 |
|
||||
|
||||
---
|
||||
|
||||
## 五、工具/API 清单
|
||||
|
||||
| 工具 / API | 用途 | 调用方式 |
|
||||
|-----------|------|---------|
|
||||
| **aura-ppt** | PPT 生成核心引擎(plan.json → PPTX 确定性渲染) | Python subprocess 调用 CLI |
|
||||
| **python-pptx** | PPTX 文件渲染与读写 | Python 库(pip install) |
|
||||
| **DeepSeek API** | AI 对话修改的自然语言理解 | HTTP API 调用 |
|
||||
| **Flask** | Web 服务框架(路由、模板、API) | Python 库(pip install) |
|
||||
| **SQLite3** | 用户信息、历史记录、偏好数据存储 | Python 内置模块 |
|
||||
| **OpenCode** | AI 辅助开发工具 | CLI 终端工具 |
|
||||
|
||||
---
|
||||
|
||||
## 六、参考依据
|
||||
|
||||
| 类别 | 文件 | 用途 |
|
||||
|------|------|------|
|
||||
| 场景规范 | `事前调查/02-场景规范/社内汇报与培训PPT规范.md` | 汇报和培训的结构模板与内容规范 |
|
||||
| 竞品分析 | `参考资料/竞品分析/竞品清单.md` | 赛道格局分析与定位参考 |
|
||||
| 试跑成果 | `事前调查/01.试跑现有工具/ppt-master/` | ppt-master v2.0 验证数据 |
|
||||
| 大赛要求 | `2026年讯和技术大赛-参赛者手册` | 成果物格式与评审标准 |
|
||||
@@ -0,0 +1,135 @@
|
||||
# 项目说明
|
||||
|
||||
> 成果物01 · 赛道一:Agent开发实战赛 · 新规项目
|
||||
|
||||
---
|
||||
|
||||
## 一、项目性质声明
|
||||
|
||||
**新规项目**。本项目为全新选题,从零构建。
|
||||
|
||||
---
|
||||
|
||||
## 二、项目概述
|
||||
|
||||
### 选题背景
|
||||
|
||||
公司内部日常工作中,汇报材料和培训资料的 PPT 制作占用了大量时间。以 AI 推进部为例,每月需要制作多份面向不同受众的 PPT,从搭框架、写内容到排版美化,单份耗时常以小时计。随着 AI Agent 技术的成熟,这类重复性文档工作正是 Agent 最适合解决的场景。
|
||||
|
||||
### 要解决的问题
|
||||
|
||||
让非技术员工通过简单的 Web 界面,输入文字内容后一键生成专业 PPT,并支持通过自然语言对话对生成结果进行修改。聚焦两大场景:**社内汇报、社内培训**。
|
||||
|
||||
---
|
||||
|
||||
## 三、整体功能说明
|
||||
|
||||
### 核心交互流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[用户注册 / 登录] --> B[进入个人工作台]
|
||||
B --> C[显示历史记录与偏好<br/>Agent 认出你是谁]
|
||||
C --> D[输入文字内容]
|
||||
D --> E[选择场景<br/>社内汇报 / 社内培训]
|
||||
E --> F[选择配色风格]
|
||||
F --> G["Agent 感知与规划<br/>(分析内容 → 规划页面结构)"]
|
||||
G --> H["Agent 行动<br/>(一键生成 PPTX)"]
|
||||
H --> I[下载 PPTX]
|
||||
I --> J{满意吗?}
|
||||
J -->|满意| K[完成]
|
||||
J -->|不满意| L["AI 对话修改<br/>(自然语言提修改意见)"]
|
||||
L --> M["Agent 理解指令<br/>(局部重新生成)"]
|
||||
M --> I
|
||||
K --> N[所有版本自动存档]
|
||||
M --> N
|
||||
N --> O[随时回溯任意历史版本]
|
||||
```
|
||||
|
||||
### 功能清单
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **用户系统** | 注册/登录,SQLite 存储用户信息,登录后展示个人历史记录与偏好 |
|
||||
| **场景选择** | 支持两大场景:社内汇报、社内培训,Agent 据此调整内容结构和设计风格 |
|
||||
| **配色风格** | 预设多套配色方案(商务蓝、极简白、深色科技等),用户自选 |
|
||||
| **一键生成** | 输入文字内容后,Agent 自动完成"分析→规划→生成→导出 PPTX"全流程 |
|
||||
| **AI 对话修改** | 生成后可通过自然语言提出修改意见,Agent 理解意图并局部修正 |
|
||||
| **历史记录** | 保存与 AI 交互过程中的多版本历史(含每次修改前后的 PPT 版本),支持回溯和复用 |
|
||||
|
||||
---
|
||||
|
||||
## 四、效果预期
|
||||
|
||||
| 维度 | 目标 |
|
||||
|------|------|
|
||||
| **效率提升** | 单份 PPT 从"数小时"压缩到"分钟级" |
|
||||
| **降低门槛** | 不需要 PowerPoint 操作技巧,输入文字即可 |
|
||||
| **质量稳定** | 统一的模板和配色系统,确保输出风格专业一致 |
|
||||
| **Agent 闭环** | 完整演示"感知→规划→行动→记忆"的 Agent 工作流 |
|
||||
|
||||
---
|
||||
|
||||
## 五、团队分工
|
||||
|
||||
| 成员 | 角色 | 职责 |
|
||||
|------|------|------|
|
||||
| **张华丹** | 项目负责人 | 需求定义、产品设计、AI 协作开发、测试验收、答辩 |
|
||||
| **孙亚菲** | 测试顾问 | 功能测试、使用反馈、文档审阅 |
|
||||
|
||||
---
|
||||
|
||||
## 六、技术路线概要
|
||||
|
||||
| 维度 | 选择 |
|
||||
|------|------|
|
||||
| **开发工具** | OpenCode + DeepSeek(完全免费,大赛推荐) |
|
||||
| **核心引擎** | aura-ppt(plan.json → PPTX 确定性渲染,项目内副本) |
|
||||
| **Agent 核心** | Python(感知→规划→行动→记忆四层 + LLM 内容规划) |
|
||||
| **前端界面** | HTML + JS(轻量 Web 界面) |
|
||||
| **数据存储** | SQLite(用户信息 + 历史记录 + 个人偏好存储) |
|
||||
| **项目周期** | 6 个月(6月-11月),对齐赛道一赛程 |
|
||||
|
||||
---
|
||||
|
||||
## 七、安装与运行
|
||||
|
||||
### 环境要求
|
||||
|
||||
| 项 | 要求 |
|
||||
|----|------|
|
||||
| Python | 3.10+ |
|
||||
| 操作系统 | Windows / macOS / Linux |
|
||||
| 大模型 | 免费方案复用 `opencode serve`(零 Key),或配置 OpenAI 兼容接口(DeepSeek 等) |
|
||||
|
||||
### 安装步骤
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python -m src.db.init # 初始化 SQLite 数据库
|
||||
```
|
||||
|
||||
### 运行方法
|
||||
|
||||
```bash
|
||||
python src/web/app.py # 启动 Web 服务,默认 http://127.0.0.1:5000
|
||||
```
|
||||
|
||||
浏览器访问 `http://127.0.0.1:5000` → 注册/登录 → 输入文字 → 选择场景与配色 → 一键生成 PPTX → 支持 AI 对话修改与多版本回溯。
|
||||
|
||||
### 依赖清单
|
||||
|
||||
见 `requirements.txt`:flask、python-pptx、werkzeug、PyMuPDF、mammoth、markdownify、beautifulsoup4、openpyxl。
|
||||
|
||||
### API 密钥配置
|
||||
|
||||
本项目默认走**零 Key 方案**:`LLM_BACKEND=opencode` 复用本机 `opencode serve`(默认 `http://127.0.0.1:4096`)。如需切换 DeepSeek 等 OpenAI 兼容接口,通过环境变量配置(不硬编码进源码):
|
||||
|
||||
```bash
|
||||
export LLM_BACKEND=openai
|
||||
export OPENAI_API_BASE=https://api.deepseek.com
|
||||
export OPENAI_API_KEY=sk-xxxxxxxx
|
||||
export OPENAI_MODEL=deepseek-chat
|
||||
```
|
||||
|
||||
所有可选配置项(引擎路径、模板模式、配图策略、并发上限等)均经环境变量注入,见 `config.py`。
|
||||
@@ -0,0 +1,55 @@
|
||||
# AI 使用日志
|
||||
|
||||
> 自动记录 — 每次创建/修改代码文件后 AI 自动追加
|
||||
>
|
||||
> **范式步骤**说明:开发结束后将"待补充"替换为设计文档范式图中的步骤名称(如:需求澄清→AI生成方案→人工审核→AI编码→测试验证→反馈迭代)
|
||||
|
||||
| 日期时间 | 范式步骤 | 修改摘要 | 涉及文件 | 使用模型 |
|
||||
|----------|---------|---------|---------|---------|
|
||||
| 2024-06-12 10:00 | 需求澄清 | 项目文件夹结构规划,确定项目资料/ + ppt-agent/ 二级分离 | 项目资料/, ppt-agent/ | DeepSeek |
|
||||
| 2024-06-12 11:00 | 需求澄清 | 竞品调研,整理20+工具(国际/国内/开源)形成竞品清单 | 参考资料/竞品分析/竞品清单.md | DeepSeek |
|
||||
| 2024-06-12 15:00 | 需求澄清 | 创建AGENTS.md项目规则文件,建立AI使用日志规范 | AGENTS.md | DeepSeek |
|
||||
| 2024-06-12 16:00 | 需求澄清 | 规范日志格式:统一为一日一文件、仅含摘要 | AGENTS.md | DeepSeek |
|
||||
| 2026-06-13 09:00 | 需求澄清 | 梳理事前调查清单(17项6阶段),确认试跑ppt-master策略 | 项目资料/事前调查/ | DeepSeek |
|
||||
| 2026-06-13 11:00 | 需求澄清 | 确认项目定位:Web端可视化界面,完全依赖AI写代码 | 项目资料/事前调查/ | DeepSeek |
|
||||
| 2026-06-15 09:00 | AI 编码实现 | ppt-master v1.0首次试跑,发现3个问题并修复(封面白、中文乱码、英文大写) | ppt-master skill files | DeepSeek |
|
||||
| 2026-06-15 12:00 | AI 编码实现 | 技能扩展:新增D) Tech Training模式,修改5个文件 | references/strategist.md, executor-base.md, executor-tech-training.md, brands/tech-training/ | DeepSeek |
|
||||
| 2026-06-15 15:00 | AI 编码实现 | ppt-master v2.0全流程生成,输出日文版PPT(10页) | ai-basics-jp-v2_v2.0.pptx | DeepSeek |
|
||||
| 2026-06-15 16:00 | AI 编码实现 | 源文件恢复(--move导致源文件被移入项目目录,复制回原位置) | sources/ | DeepSeek |
|
||||
| 2026-06-22 10:00 | 需求澄清 | 项目方向审视:从"Web系统"修正为"Agent为核心",更新项目说明 | 对外资料/项目说明.md | DeepSeek |
|
||||
| 2026-06-22 14:00 | 需求澄清 | 确认Agent架构:感知→规划→行动→记忆,明确范围(汇报+培训) | 对外资料/项目说明.md | DeepSeek |
|
||||
| 2026-06-23 09:00 | 需求澄清 | 环境确认(Python/Node.js/OpenCode/DeepSeek API全绿灯) | 项目资料/ | DeepSeek |
|
||||
| 2026-06-23 10:00 | AI 编码实现 | 分析3份社内PPT标杆素材,提炼汇报和培训的结构规范 | 事前调查/02-场景规范/社内汇报与培训PPT规范.md | DeepSeek |
|
||||
| 2026-06-23 14:00 | AI 编码实现 | 撰写设计文档(成果物02):范式图+架构图+组件+工具清单 | 对外资料/设计文档.md | DeepSeek |
|
||||
| 2026-06-25 09:00 | AI 编码实现 | PPT自动生成试跑:用AI用语说明资料_v7.md初始化PPT项目 | projects/ai_terminology_training_ppt169_20260624/ | DeepSeek |
|
||||
| 2026-06-25 10:00 | AI 编码实现 | 生成17页商务蓝白PPT,完成质量检查(0 error)并导出首版 | 17个svg_output/*.svg → exports/*.pptx | DeepSeek |
|
||||
| 2026-06-25 14:00 | AI 编码实现 | 第一轮优化:字体统一、目录4+4、◆前缀替代横线、编号对齐、页码右下角 | spec_lock.md, svg_output/*.svg | DeepSeek |
|
||||
| 2026-06-25 16:00 | AI 编码实现 | 第二轮优化:纯中文去日文、正文去◆纯编号、表格/流程图化、色块间距≥16px | spec_lock.md, svg_output/*.svg | DeepSeek |
|
||||
| 2026-06-25 17:27 | AI 编码实现 | 最终导出优化版PPTX(17页Native DrawingML,0失败) | exports/ai_terminology_training_20260625_172746.pptx | DeepSeek |
|
||||
| 2026-06-25 18:00 | 需求澄清 | 查阅参赛者手册,确认当前进度与下一步行动计划 | AGENTS.md, _AI_USAGE_LOG.md | DeepSeek |
|
||||
| 2026-06-25 18:30 | 需求澄清 | 在AGENTS.md中添加自动日志规则,创建_AI_USAGE_LOG.md并回填历史 | AGENTS.md, _AI_USAGE_LOG.md | DeepSeek |
|
||||
| 2026-07-20 15:40 | 需求澄清 | 【回补】撰写产品需求文档 PRD:23条用户故事、错误码体系、两版本策略/不做布局模板等关键决策定稿 | 项目资料/需求与思路/02-需求文档/产品需求文档_PRD.md | DeepSeek |
|
||||
| 2026-07-23 10:09 | 需求澄清 | 【回补】PRD 修订至 v1.2,补充里程碑与开发阶段对应关系 | 项目资料/需求与思路/02-需求文档/产品需求文档_PRD.md | DeepSeek |
|
||||
| 2026-08-17 14:13 | AI 编码实现 | 【回补】ppt-master 引擎完整拷贝进项目内副本作为真值(58.27MB/12144文件) | engine/ppt-master/ | DeepSeek |
|
||||
| 2026-08-17 14:28 | AI 编码实现 | 【回补】撰写《系统详细设计》v0.1:分层架构/REST API/数据库设计/Agent四层模块/参数映射/集成方式 | ppt-agent/docs/系统详细设计.md | DeepSeek |
|
||||
| 2026-08-17 16:41 | AI 编码实现 | 【回补】Sprint 1 骨架编码:config、SQLite五表DDL、感知/规划/行动/记忆/调度五模块、引擎桥接、Flask路由、前端四页面与中日语包 | config.py, src/db/, src/agent/, src/engine_bridge/, src/web/app.py, static/*.html, lang/zh.json, lang/ja.json | DeepSeek |
|
||||
| 2026-08-24 16:32 | 需求澄清 | 渲染引擎选型调研:ppt-master vs aura-ppt 十三维度对比,定选 aura-ppt 并形成移植可行性评估与缺口清单 | 项目资料/事前调查/引擎选型对比_ppt-master_vs_aura-ppt.md | DeepSeek |
|
||||
| 2026-08-24 16:35 | AI 编码实现 | 将 aura-ppt 引擎(6文件/112.7KB)拷入项目内副本作为真值,原技能路径转为只读参照 | engine/aura-ppt/ | DeepSeek |
|
||||
| 2026-08-24 16:40 | 需求澄清 | AGENTS.md 引擎管理章节同步新格局:aura-ppt 主渲染引擎 + ppt-master 辅助引擎双副本约束 | AGENTS.md | DeepSeek |
|
||||
| 2026-08-24 16:55 | AI 编码实现 | aura-ppt 首次实战:依据 aurak-wiki.md 生成日文本部长报告 PPT(16页 report-jp),修复章节页 content 数组格式与流程图分隔符两处适配问题,保真验证通过 | 华丹用文件/02.報告用資料/00.本部长报告/AI人材育成/plan_honbu_aurak.json | DeepSeek |
|
||||
| 2026-08-24 17:18 | AI 编码实现 | 追加公司模板版:--template 继承母版(白紙版式/10.83×7.5in),主次色 #0B3D91/#1976D2 与 report-jp 一致,保真验证通过,与纯设计系统版并存对比 | 华丹用文件/02.報告用資料/00.本部长报告/AI人材育成/*_テンプレート版.pptx | DeepSeek |
|
||||
| 2026-08-24 17:41 | AI 编码实现 | P0 渲染链路真化:config 双引擎路径、engine.py 注册五色设计系统(corp-blue/green/purple/orange/gray)、planning.py 改产出 plan.json(Markdown标题分页算法)、bridge.py 封装 render/verify、action.py 删占位SVG接通真渲染、并发信号量接通(2003);新增 tests 冒烟+e2e 全链路通过(注册→生成→下载真实PPTX) | config.py, src/agent/planning.py, src/agent/action.py, src/agent/orchestrator.py, src/engine_bridge/bridge.py, src/engine_bridge/__init__.py, engine/aura-ppt/scripts/engine.py, tests/smoke_build_plan.py, tests/e2e_web.py | DeepSeek |
|
||||
| 2026-08-24 17:46 | AI 编码实现 | 设计文档 v0.2:第5章重写为 plan.json 映射(五色板/分页算法/母版策略),第6章重写为 aura-ppt 集成方式,第1/4/9章散点同步;AGENTS.md 成果物③状态更新;回补6/25~8/23日志空档5条并归档当日摘要 | ppt-agent/docs/系统详细设计.md, AGENTS.md, _AI_USAGE_LOG.md, 项目资料/AI使用日志/2026-08-24_本次对话主要内容.md | DeepSeek |
|
||||
| 2026-08-25 07:57 | AI 编码实现 | 界面修复:工作台中间内容区去掉 max-width:900px 限制改为 width:100%,宽屏下表单卡片铺满浏览器,消除右侧空白条 | src/web/static/workspace.html | DeepSeek |
|
||||
| 2026-08-25 08:06 | AI 编码实现 | 工作台单屏化改造:中间区与配置面板改弹性布局(卡片/正文框自动伸缩、生成按钮钉底、间距压缩),1920×800 与 1366×768 双分辨率验证页面/主区/配置区均无滚动条 | src/web/static/workspace.html | DeepSeek |
|
||||
| 2026-08-25 08:30 | AI 编码实现 | 修复配置面板内部空白:定位到 .btn-generate 的 margin-top:auto 吸收全部 flex 自由空间导致 space-between 失效,去掉 auto 后六个配置区均匀分布铺满面板高度(1920×1080 验证) | src/web/static/workspace.html | DeepSeek |
|
||||
| 2026-08-25 09:40 | AI 编码实现 | Sprint 2 对话修改闭环:新增 chat.py(中日双语意图解析:换色/改标题/改页增删改 + plan.json 变更 + 重渲染 + 两版本落库);app.py 增加 /api/chat 与消息查询;前端对话弹窗;修复 save_version 缺 svg_dir、下载路由误取旧版、bridge 子进程 GBK 编码崩溃、plan 变更未写盘四个 bug;e2e_chat 16 项断言全绿 + 浏览器实测通过 | src/agent/chat.py, src/agent/memory.py, src/web/app.py, src/web/static/workspace.html, lang/zh.json, lang/ja.json, tests/e2e_chat.py, docs/系统详细设计.md, src/engine_bridge/bridge.py | DeepSeek |
|
||||
| 2026-08-25 10:04 | AI 编码实现 | 在线预览上线:PowerPoint COM(WPS 兜底)导出每页 PNG,按版本缓存;新增 preview_export.ps1 + bridge.export_preview_images(本地化文件名归一化 Slide<N>.PNG)+ app.py 预览两路由(归属校验/文件名白名单)+ 前端预览弹层;浏览器实测 7 页加载成功且预览反映对话修改后的科技紫版本 | src/engine_bridge/preview_export.ps1, src/engine_bridge/bridge.py, src/engine_bridge/__init__.py, src/web/app.py, src/web/static/workspace.html, lang/zh.json, lang/ja.json | DeepSeek |
|
||||
| 2026-08-25 12:09 | AI 编码实现 | 大模型接入(零Key方案):新建 llm.py 客户端复用 opencode serve(模型 mimo-v2.5-free 经三模型基准选定44s/大纲);四项能力落地——LLM内容规划(章节结构+信息量标题+每页个性化备注)、智能标题接口+按钮、对话意图LLM化(自动定位页码)、三级降级链(提示词约束→数字消毒器→保真失败自动降规则模式);新增 e2e_llm 与 bench_models 测试全绿,修复元组赋值顺序/用户标题被LLM改写两 bug;设计文档新增5.6节 | src/agent/llm.py, src/agent/planning.py, src/agent/chat.py, src/web/app.py, src/web/static/workspace.html, lang/zh.json, lang/ja.json, config.py, tests/e2e_llm.py, tests/bench_models.py, docs/系统详细设计.md | DeepSeek |
|
||||
| 2026-08-25 13:28 | AI 编码实现 | 状态文档同步:AGENTS.md 成果物③更新为 Sprint 2 完成(对话修改+在线预览+LLM零Key接入)并补技术栈 LLM 说明;系统详细设计升 v0.3——1.2 模块表、2.3 接口清单预览路由、4.1 文件树补 chat.py/llm.py、4.6 意图解析改 LLM 优先、新增 4.7 在线预览设计、9.3 补六个 LLM 配置项、第十章 Sprint 1/2 标注完成及实现偏差 | AGENTS.md, ppt-agent/docs/系统详细设计.md, ppt-agent/_AI_USAGE_LOG.md | DeepSeek |
|
||||
| 2026-08-25 16:33 | AI 编码实现 | Sprint 3 上传解析提前落地:新建 upload.py 解析模块(CLI 调 ppt-master source_to_md 五脚本,md/txt 直读,URL 抓取,产物清洗:去横幅/图片引用/反转义);app.py 新增 /api/upload(白名单+20MB+24h清理)与 /api/parse 两路由;前端上传区接通点击弹窗/拖拽/文件列表/URL行/解析回填正文;语言包补10键、requirements 补5依赖;e2e_upload 21项断言全绿 + 浏览器实测点击→选文件→解析回填全链路通过 | src/agent/upload.py, src/web/app.py, src/web/static/workspace.html, lang/zh.json, lang/ja.json, requirements.txt, tests/e2e_upload.py | DeepSeek |
|
||||
| 2026-08-25 16:52 | AI 编码实现 | Sprint 3 设置偏好+历史语言筛选落地:app.py 新增 Epic 6 四路由(GET /api/settings、PUT username/password/preferences,含 5001/5002/1001/1003 校验);settings.html 重构为账户安全+默认偏好双卡片并接真实接口(补 i18n 中日支持);memory.list_records 加 language 参数;history.html 加语言筛选下拉与记录语言标签;e2e_settings 18项断言全绿 + 浏览器实测保存偏好→工作台预填联动通过 | src/web/app.py, src/web/static/settings.html, src/web/static/history.html, src/agent/memory.py, lang/zh.json, lang/ja.json, tests/e2e_settings.py | DeepSeek |
|
||||
| 2026-08-25 17:49 | AI 编码实现 | UI 全语言收尾:login.html/history.html 接入中日语包(含动态文案),三页补侧栏/角落语言切换按钮,修复设置页标题键名冲突,语言包补18键;四页浏览器联测日英切换全通过 | src/web/static/login.html, src/web/static/history.html, src/web/static/settings.html, lang/zh.json, lang/ja.json | DeepSeek |
|
||||
| 2026-08-25 17:49 | AI 编码实现 | 网络配图管线落地:aura-ppt 引擎(项目内副本)新增 _place_image 右侧配图布局(等比缩放/居中/边框,r_content 左文右图)+ verify 跳过 image 键;新建 imaging.py 配图模块(CLI 调 image_search.py,test 本地通道/off 禁用,单页失败仅告警优雅降级);action.py 主流程与 LLM 降级分支挂载;config 增 IMAGE_SEARCH_* 四配置;引擎试跑 + e2e_image 9项断言全绿(含空图源降级场景);实测本机 openverse/wikimedia 不可达,真实源待换网络或配 Key 即启用 | engine/aura-ppt/scripts/engine.py, src/agent/imaging.py, src/agent/action.py, config.py, tests/e2e_image.py | DeepSeek |
|
||||
| 2026-08-26 11:18 | 反馈迭代 | 修复用户报告的110页照搬问题(产品决策:取消规则降级):planning.py 重写为纯 LLM 总结模式——失败抛 PlanningError 明确报错;新增长文档两级规划(分段通读提炼→汇总大纲,全文覆盖不再只读前3000字);页数硬约束(校验裁剪保证不超上限);action.py 重构为两轮完整尝试+具体失败原因透传;前端失败 toast 显示后端原因;chat.py 封面替换意图规范化为改标题;e2e_llm 15项全绿 + 8168字80节长文档冒烟:13页预算内、13/13带备注、后段章节覆盖 | src/agent/planning.py, src/agent/action.py, src/agent/chat.py, src/web/static/workspace.html, config.py, tests/e2e_llm.py | DeepSeek |
|
||||
| 2026-08-26 14:14 | 反馈迭代 | 修复要点碎片化问题(用户反馈:内容全是短语无完整句):两级提示词改写——通读阶段要求完整陈述句(15-40字含主语谓语)、大纲阶段明确完整句要求并附正误示例对照;_validate_slides 新增碎片化门禁(要点均长<10字打回重写);e2e 增加完整句断言,17项全绿(短文档均长22字/长文档26字) | src/agent/planning.py, tests/e2e_llm.py | DeepSeek |
|
||||
@@ -0,0 +1,93 @@
|
||||
# ===== PPT 自动生成 Agent 配置 =====
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 项目根目录
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
# 引擎路径(2026-08-24 选型:渲染=aura-ppt 主引擎;解析/搜图=ppt-master 辅助)
|
||||
# 所有引擎优化只在项目内副本做,技能原版路径只读
|
||||
AURA_ENGINE_PATH = Path(os.environ.get("AURA_ENGINE_PATH", BASE_DIR / "engine" / "aura-ppt"))
|
||||
AURA_ENGINE_SCRIPT = AURA_ENGINE_PATH / "scripts" / "engine.py"
|
||||
PPT_MASTER_PATH = Path(os.environ.get("PPT_MASTER_PATH", BASE_DIR / "engine" / "ppt-master"))
|
||||
|
||||
# 公司模板(--template 模式继承母版/Logo/页脚)
|
||||
COMPANY_TEMPLATE = Path(os.environ.get("COMPANY_TEMPLATE", AURA_ENGINE_PATH / "assets" / "company-template.pptx"))
|
||||
# 模板模式策略:always=始终用模板 / ja=仅日语生成用 / off=不用
|
||||
COMPANY_TEMPLATE_MODE = os.environ.get("COMPANY_TEMPLATE_MODE", "ja")
|
||||
|
||||
# ===== 大模型接入(2026-08-25)=====
|
||||
# backend: opencode=复用 opencode serve(零 Key) / openai=OpenAI 兼容接口(DeepSeek等) / off=关闭
|
||||
LLM_BACKEND = os.environ.get("LLM_BACKEND", "opencode")
|
||||
# opencode 模式(模型经基准测试选定:mimo-v2.5-free 结构化输出最快最稳,44s/大纲)
|
||||
OPENCODE_SERVE = os.environ.get("OPENCODE_SERVE", "http://127.0.0.1:4096")
|
||||
OPENCODE_SERVE_PORT = int(os.environ.get("OPENCODE_SERVE_PORT", "4096"))
|
||||
OPENCODE_MODEL = os.environ.get("OPENCODE_MODEL", "opencode/mimo-v2.5-free")
|
||||
# openai 兼容模式(DeepSeek: https://api.deepseek.com, model=deepseek-chat)
|
||||
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "")
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
||||
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "deepseek-chat")
|
||||
# LLM 调用超时(秒)
|
||||
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "180"))
|
||||
|
||||
# 长文档两级规划(2026-08-26):分段通读 → 汇总大纲
|
||||
PLAN_CHUNK_CHARS = int(os.environ.get("PLAN_CHUNK_CHARS", "6000"))
|
||||
PLAN_MAX_CHUNKS = int(os.environ.get("PLAN_MAX_CHUNKS", "8"))
|
||||
|
||||
# 生成任务输出根目录
|
||||
PROJECTS_PATH = Path(os.environ.get("PROJECTS_PATH", BASE_DIR / "src" / "web" / "data" / "projects"))
|
||||
|
||||
# SQLite 数据库路径
|
||||
DB_PATH = Path(os.environ.get("DB_PATH", BASE_DIR / "src" / "web" / "data" / "ppt.db"))
|
||||
|
||||
# Flask Session 密钥(环境变量注入,不进仓库)
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "ppt-agent-dev-secret-key-do-not-use-in-prod")
|
||||
|
||||
# 服务绑定
|
||||
HOST = os.environ.get("HOST", "127.0.0.1")
|
||||
PORT = int(os.environ.get("PORT", "5000"))
|
||||
DEBUG = os.environ.get("FLASK_DEBUG", "0") == "1"
|
||||
|
||||
# 上传限制
|
||||
MAX_UPLOAD_MB = 20
|
||||
UPLOAD_DIR = BASE_DIR / "src" / "web" / "data" / "uploads"
|
||||
|
||||
# 文件类型白名单
|
||||
ALLOWED_EXTENSIONS = {
|
||||
".pdf": "pdf",
|
||||
".docx": "doc",
|
||||
".doc": "doc",
|
||||
".xlsx": "excel",
|
||||
".xlsm": "excel",
|
||||
".pptx": "ppt",
|
||||
".md": "markdown",
|
||||
".txt": "text",
|
||||
".html": "html",
|
||||
".htm": "html",
|
||||
}
|
||||
|
||||
# 枚举白名单
|
||||
ALLOWED_SCENES = ("report", "training")
|
||||
ALLOWED_COLORS = ("blue", "green", "purple", "orange", "gray")
|
||||
ALLOWED_LANGS = ("zh", "ja")
|
||||
ALLOWED_CANVAS = ("ppt169", "ppt43")
|
||||
ALLOWED_IMAGE_STRATEGIES = ("web", "ai", "off")
|
||||
|
||||
# 配图策略(web 默认)
|
||||
DEFAULT_IMAGE_STRATEGY = "web"
|
||||
|
||||
# 生成任务并发上限
|
||||
MAX_CONCURRENT_GENERATION = 1
|
||||
|
||||
# ===== 网络搜图(2026-08-25)=====
|
||||
# web=调用 ppt-master image_search.py(openverse/wikimedia 零配置)
|
||||
# test=本地样例图通道(e2e 用,读 IMAGE_SEARCH_TEST_DIR)
|
||||
# off=禁用配图
|
||||
IMAGE_SEARCH_MODE = os.environ.get("IMAGE_SEARCH_MODE", "web")
|
||||
IMAGE_SEARCH_TEST_DIR = os.environ.get("IMAGE_SEARCH_TEST_DIR", "")
|
||||
IMAGE_SEARCH_TIMEOUT = int(os.environ.get("IMAGE_SEARCH_TIMEOUT", "90"))
|
||||
MAX_IMAGE_SLIDES = int(os.environ.get("MAX_IMAGE_SLIDES", "4"))
|
||||
|
||||
# 引擎剧本目录(会话用)
|
||||
GENERATION_SCRIPT = None
|
||||
@@ -0,0 +1,12 @@
|
||||
# 知识库平台季度汇报
|
||||
|
||||
## 项目背景
|
||||
为提升内部知识周转效率,启动知识库平台建设,已完成技术选型与原型验证。
|
||||
|
||||
## 本期成果
|
||||
- 部署完成基础服务,导入文档 120 份
|
||||
- 混合检索准确率显著优于基线方案
|
||||
|
||||
## 下一步计划
|
||||
- 扩大试点范围至三个部门
|
||||
- 建立内容运营与质量巡检机制
|
||||
@@ -0,0 +1,795 @@
|
||||
# 系统详细设计
|
||||
|
||||
> 项目:PPT 自动生成 Agent · 赛道一:Agent 开发实战赛 · 新规项目
|
||||
> 配套文档:PRD v1.2(`项目资料/需求与思路/02-需求文档/产品需求文档_PRD.md`)、设计文档(`项目资料/对外资料/设计文档.md`)
|
||||
> 版本:v0.4(2026-08-25 二次同步:UI 全语言收尾 + 网络配图管线;新增 6.6,更新 5.1/6.5/十章)
|
||||
> 前版:v0.3(2026-08-25 状态同步:Sprint 2 对话修改落地、在线预览上线、LLM 零Key 接入;新增 4.7 预览设计)
|
||||
> 前版:v0.2(2026-08-24 引擎切换改版:第5/6章改为 aura-ppt 方案,散点同步)
|
||||
> 初版:v0.1(第1批 · 骨架,2026-08-17)
|
||||
> 配套选型依据:`项目资料/事前调查/引擎选型对比_ppt-master_vs_aura-ppt.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、总体架构与分层
|
||||
|
||||
### 1.1 分层架构图
|
||||
|
||||
```
|
||||
前端(HTML + JS)
|
||||
│ HTTP / JSON
|
||||
▼
|
||||
Flask API 层(路由 / 鉴权 / 文件上传 / 静态资源)
|
||||
│
|
||||
▼
|
||||
Agent 核心(感知 → 规划 → 行动 → 记忆)
|
||||
│ ① 参数映射(标题/内容/配色/页数 → plan.json 页面规划)
|
||||
│ ② 状态机(生成任务调度 / 进度回传 / 失败重试)
|
||||
▼
|
||||
aura-ppt 渲染引擎(项目内副本:ppt-agent/engine/aura-ppt/)
|
||||
│ plan.json → 确定性渲染 PPTX → 内容保真验证
|
||||
│ (辅助:ppt-master 仅复用文档解析/搜图脚本,Sprint 3 接入)
|
||||
▼
|
||||
SQLite(users / preferences / ppt_records / versions / chat_messages)
|
||||
```
|
||||
|
||||
### 1.2 模块职责
|
||||
|
||||
| 模块 | 目录 | 职责 |
|
||||
|------|------|------|
|
||||
| Web 前端 | `src/web/static/` | 4 页面(登录/工作台/历史/设置)+ 样式 + JS |
|
||||
| Flask API | `src/web/app.py` | 路由注册、Session 鉴权、请求校验、错误统一处理 |
|
||||
| Agent 核心 | `src/agent/` | perception / planning / action / memory 四子模块 + chat(对话修改)/ llm(LLM 客户端) |
|
||||
| 引擎桥接 | `src/engine_bridge/` | 调用 aura-ppt CLI(渲染 / 保真验证)、预览图导出(COM);ppt-master 解析脚本已接入(upload.py),搜图待接 |
|
||||
| 配置 | `config.py` | 引擎路径、上传限制、数据库路径、DeepSeek API 等 |
|
||||
| 数据层 | `src/db/` | SQLite 连接管理、表结构 DDL |
|
||||
|
||||
### 1.3 关键设计约束
|
||||
|
||||
| 约束 | 说明 |
|
||||
|------|------|
|
||||
| 引擎路径可配置 | `config.AURA_ENGINE_PATH` 指向项目内 aura-ppt 副本(渲染),`config.PPT_MASTER_PATH` 指向 ppt-master 副本(解析/搜图),不硬编码全局路径 |
|
||||
| 引擎只经 CLI 调用 | Agent 不 import 引擎内部模块,渲染统一走 `engine.py --plan/--verify` + subprocess |
|
||||
| 生成异步化 | PPT 生成耗时长,采用后台任务 + 前端轮询进度(对应 US-2.7a 进度提示、US-2.11 跨页面状态)|
|
||||
| 状态机驱动 | 每个生成任务有任务 ID,状态迁移:pending → analyzing → planning → rendering → done / failed |
|
||||
|
||||
### 1.4 中日文切换设计
|
||||
|
||||
系统支持**双语言**,且拆分为两个**相互独立**的维度:
|
||||
|
||||
| 维度 | 含义 | 切换入口 | 存储 |
|
||||
|------|------|---------|------|
|
||||
| **UI 语言** | 界面文案(按钮/标签/提示)整体切换 | 顶栏语言下拉 + 设置页 | localStorage(未登录)→ preferences(登录后同步) |
|
||||
| **生成语言** | 生成的 PPT 内容与演讲备注的语言 | 工作台「生成配置」面板下拉 | 每次生成独立选择,默认取偏好值 |
|
||||
|
||||
**多语言实现**:
|
||||
- 前端 i18n 字典:`src/web/static/lang/zh.json` 与 `ja.json`,键值一一对应,支持动态切换无需刷新
|
||||
- 引擎侧:生成语言=ja 时,Agent 感知层输出的源 Markdown、执行器产生的演讲备注全部用日文,字体栈换为 Meiryo(参照试跑项目 ai-basics-jp)
|
||||
- 偏好默认值:`default_language` 默认 `zh`,用户可改
|
||||
|
||||
**语言洁癖约束**:任一语言环境下界面与生成内容均保持单一语言,禁止中日文混排。
|
||||
|
||||
---
|
||||
|
||||
## 二、REST API 规范
|
||||
|
||||
### 2.1 通用约定
|
||||
|
||||
| 项 | 约定 |
|
||||
|----|------|
|
||||
| 基础路径 | `/api` |
|
||||
| 内容类型 | 请求/响应均为 `application/json`(文件上传除外) |
|
||||
| 认证 | 除登录/注册外,所有接口需带 `session cookie`;未登录返回 `401` |
|
||||
| 响应结构 | 成功:`{"code":0,"data":{...}}`;失败:`{"code":<错误码>,"message":"中文提示"}` |
|
||||
| 错误码前缀 | `10xx` 认证 / `20xx` 生成 / `30xx` 上传解析 / `40xx` 历史 / `50xx` 设置 / `60xx` 引擎 |
|
||||
|
||||
### 2.2 错误码表
|
||||
|
||||
| 错误码 | 含义 | 前端提示文案 |
|
||||
|:--:|------|------|
|
||||
| 1001 | 用户名已存在 | "用户名已被使用" |
|
||||
| 1002 | 用户名或密码错误 | "用户名或密码错误" |
|
||||
| 1003 | 两次密码不一致 | "两次输入的密码不一致" |
|
||||
| 1004 | 未登录或会话过期 | "请先登录" |
|
||||
| 2001 | 内容字数不足 20 | "内容至少需要 20 个字" |
|
||||
| 2002 | 页数范围非法(min>max) | "最少页数不能大于最多页数" |
|
||||
| 2003 | 引擎繁忙 | "引擎繁忙,请稍后重试" |
|
||||
| 2004 | 生成失败(引擎侧) | "生成失败,请重试" |
|
||||
| 3001 | 不支持的文件类型 | "不支持的文件类型" |
|
||||
| 3002 | 文件超过 20MB | "文件大小不能超过 20MB" |
|
||||
| 3003 | 文件损坏无法读取 | "文件损坏,无法读取" |
|
||||
| 3004 | 解析内容为空 | "文件内容为空" |
|
||||
| 4001 | 历史记录不存在 | "该记录不存在或已被删除" |
|
||||
| 5001 | 原密码错误 | "原密码不正确" |
|
||||
| 6001 | 引擎内部错误 | "引擎内部异常,请联系管理员" |
|
||||
|
||||
### 2.3 接口清单
|
||||
|
||||
#### Epic 1 · 认证
|
||||
|
||||
| # | 方法 | 路径 | 说明 | 对应故事 |
|
||||
|---|------|------|------|---------|
|
||||
| 1 | POST | `/api/register` | 注册 | US-1.1 |
|
||||
| 2 | POST | `/api/login` | 登录 | US-1.2 |
|
||||
| 3 | POST | `/api/logout` | 退出登录 | US-1.3 |
|
||||
| 4 | GET | `/api/me` | 获取当前用户信息 | US-1.2 |
|
||||
|
||||
#### Epic 2 · PPT 生成
|
||||
|
||||
| # | 方法 | 路径 | 说明 | 对应故事 |
|
||||
|---|------|------|------|---------|
|
||||
| 5 | POST | `/api/generate` | 触发生成,返回任务 ID | US-2.7a |
|
||||
| 6 | GET | `/api/generate/<task_id>/status` | 查询生成进度 | US-2.7a / US-2.11 |
|
||||
| 7 | GET | `/api/generate/<task_id>/result` | 生成完成后取结果 | US-2.7b |
|
||||
| 8 | POST | `/api/title/suggest` | 智能生成标题 | US-2.4 |
|
||||
| 9 | GET | `/api/files/<record_id>/download` | 下载 PPTX | US-2.8 |
|
||||
| 10 | GET | `/api/files/<record_id>/preview` | 在线预览页数据(PNG 按版本缓存) | US-2.9 |
|
||||
|
||||
> 预览页图实际加载走 `GET /api/preview-img/<record_id>/<name>`(单页 PNG,归属校验 + 文件名白名单,见 4.7)。
|
||||
|
||||
#### Epic 3 · 上传解析
|
||||
|
||||
| # | 方法 | 路径 | 说明 | 对应故事 |
|
||||
|---|------|------|------|---------|
|
||||
| 11 | POST | `/api/upload` | 上传文档(multipart) | US-2.3a |
|
||||
| 12 | POST | `/api/parse` | 解析上传文件为文本 | US-2.3b |
|
||||
|
||||
#### Epic 4 · 对话修改
|
||||
|
||||
| # | 方法 | 路径 | 说明 | 对应故事 |
|
||||
|---|------|------|------|---------|
|
||||
| 13 | POST | `/api/chat` | 发送修改指令 | US-3.1 |
|
||||
| 14 | GET | `/api/chat/<record_id>/messages` | 获取本轮对话记录 | US-3.2 |
|
||||
|
||||
#### Epic 5 · 历史记录
|
||||
|
||||
| # | 方法 | 路径 | 说明 | 对应故事 |
|
||||
|---|------|------|------|---------|
|
||||
| 15 | GET | `/api/records` | 历史列表(搜索/筛选/分页) | US-4.1 / 4.2 / 4.3 |
|
||||
| 16 | GET | `/api/records/<id>` | 历史详情(含版本) | US-4.4 |
|
||||
| 17 | DELETE | `/api/records/<id>` | 删除记录 | US-4.6 |
|
||||
|
||||
#### Epic 6 · 个人设置
|
||||
|
||||
| # | 方法 | 路径 | 说明 | 对应故事 |
|
||||
|---|------|------|------|---------|
|
||||
| 18 | PUT | `/api/settings/username` | 修改用户名 | US-5.1 |
|
||||
| 19 | PUT | `/api/settings/password` | 修改密码 | US-5.2 |
|
||||
| 20 | PUT | `/api/settings/preferences` | 保存生成偏好 | US-5.3 |
|
||||
| 21 | GET | `/api/settings` | 获取设置与偏好 | US-5.1 / 5.3 |
|
||||
|
||||
### 2.4 关键接口详细定义
|
||||
|
||||
#### POST /api/generate
|
||||
|
||||
**请求体:**
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "七月项目周报",
|
||||
"content": "本月完成模块A开发……(≥20字)",
|
||||
"scene": "report", // report=社内汇报 / training=社内培训
|
||||
"language": "zh", // zh=中文 PPT / ja=日文 PPT
|
||||
"canvas": "ppt169", // ppt169=16:9(默认)/ ppt43=4:3
|
||||
"image_strategy": "web", // web=网络搜图(默认)/ ai=AI生成 / off=不用图
|
||||
"color_scheme": "blue", // blue/green/purple/orange/gray
|
||||
"page_min": 10,
|
||||
"page_max": 15
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"task_id": "gen_20260817_1015_3f8a",
|
||||
"status": "analyzing"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**状态迁移(US-2.7a 进度提示映射):**
|
||||
|
||||
| 状态 | 前端提示 | 对应引擎阶段 |
|
||||
|------|---------|-------------|
|
||||
| `analyzing` | "正在分析内容…" | 源内容处理 / 策略师分析 |
|
||||
| `planning` | "正在规划结构…" | 策略师八大确认解析 |
|
||||
| `rendering` | "正在排版渲染…" | 执行器 SVG 逐页生成 + 导出 |
|
||||
| `done` | 生成完成 | 导出完成 |
|
||||
| `failed` | 按错误码提示 | 任一环节失败 |
|
||||
|
||||
#### GET /api/generate/<task_id>/status
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"status": "rendering",
|
||||
"progress": 68, // 0-100,渲染阶段按已生成页/总页估算
|
||||
"message": "正在排版渲染…"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /api/files/<record_id>/download
|
||||
|
||||
`Content-Disposition: attachment; filename="标题_20260817_1015.pptx"`,返回二进制流。
|
||||
|
||||
---
|
||||
|
||||
## 三、SQLite 数据库设计
|
||||
|
||||
### 3.1 ER 概览
|
||||
|
||||
```
|
||||
users 1 ─── 1 preferences
|
||||
│
|
||||
└── 1 ─── n ppt_records 1 ─── 0..n versions
|
||||
│
|
||||
└── 1 ─── n chat_messages
|
||||
```
|
||||
|
||||
### 3.2 表结构
|
||||
|
||||
#### users(用户表)
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 主键 |
|
||||
| username | TEXT | UNIQUE NOT NULL | 用户名 |
|
||||
| password_hash | TEXT | NOT NULL | bcrypt 哈希,不存明文 |
|
||||
| created_at | TEXT | NOT NULL | 创建时间(ISO8601) |
|
||||
|
||||
#### preferences(偏好表)
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 主键 |
|
||||
| user_id | INTEGER | NOT NULL, FOREIGN KEY→users.id | 所属用户 |
|
||||
| default_scene | TEXT | DEFAULT 'training' | 默认场景 |
|
||||
| default_color | TEXT | DEFAULT 'blue' | 默认配色 |
|
||||
| default_language | TEXT | DEFAULT 'zh' | 默认生成语言(zh/ja) |
|
||||
| default_canvas | TEXT | DEFAULT 'ppt169' | 默认画布(ppt169/ppt43) |
|
||||
| default_image_strategy | TEXT | DEFAULT 'web' | 默认配图策略(web/ai/off) |
|
||||
| default_page_min | INTEGER | DEFAULT 10 | 默认页数下限 |
|
||||
| default_page_max | INTEGER | DEFAULT 15 | 默认页数上限 |
|
||||
|
||||
#### ppt_records(PPT 生成记录表)
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 主键 |
|
||||
| user_id | INTEGER | NOT NULL, FOREIGN KEY→users.id | 所属用户(索引) |
|
||||
| title | TEXT | NOT NULL | PPT 标题 |
|
||||
| content | TEXT | NOT NULL | 用户输入的源内容 |
|
||||
| scene | TEXT | NOT NULL | report / training |
|
||||
| language | TEXT | NOT NULL DEFAULT 'zh' | 生成语言(zh/ja),历史筛选依据 |
|
||||
| color_scheme | TEXT | NOT NULL | blue/green/purple/orange/gray |
|
||||
| page_min | INTEGER | NOT NULL | 页数下限 |
|
||||
| page_max | INTEGER | NOT NULL | 页数上限 |
|
||||
| status | TEXT | NOT NULL DEFAULT 'done' | 生成状态 |
|
||||
| file_size | INTEGER | NULL | PPTX 文件大小(字节) |
|
||||
| page_count | INTEGER | NULL | 实际生成页数 |
|
||||
| generate_duration | INTEGER | NULL | 生成耗时(秒) |
|
||||
| created_at | TEXT | NOT NULL | 生成时间(列表倒序排序依据) |
|
||||
|
||||
#### versions(版本表,对应 US-4.4 两版本策略)
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 主键 |
|
||||
| record_id | INTEGER | NOT NULL, FOREIGN KEY→ppt_records.id | 所属记录 |
|
||||
| version_no | INTEGER | NOT NULL | 版本号(1=第一版,2=修改后) |
|
||||
| file_path | TEXT | NOT NULL | PPTX 文件相对路径 |
|
||||
| svg_dir | TEXT | NULL | SVG 源目录相对路径 |
|
||||
| change_note | TEXT | NULL | 本次修改说明(对话修改时记录) |
|
||||
| created_at | TEXT | NOT NULL | 版本生成时间 |
|
||||
|
||||
> 两版本策略:**当前版本 + 上一版本**(决策日志 2026-07-20)。生成新版本时,旧"上一版"删除,原"当前版"降为"上一版"。
|
||||
|
||||
#### chat_messages(对话消息表)
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 主键 |
|
||||
| record_id | INTEGER | NOT NULL, FOREIGN KEY→ppt_records.id | 关联记录 |
|
||||
| role | TEXT | NOT NULL | user / assistant |
|
||||
| content | TEXT | NOT NULL | 消息内容 |
|
||||
| created_at | TEXT | NOT NULL | 时间戳 |
|
||||
|
||||
### 3.3 索引
|
||||
|
||||
| 表 | 索引 | 用途 |
|
||||
|----|------|------|
|
||||
| users | UNIQUE (username) | 登录查询、注册唯一性 |
|
||||
| ppt_records | (user_id, created_at DESC) | 历史列表按时间倒序 |
|
||||
| ppt_records | (user_id, scene) | 场景筛选(US-4.3)|
|
||||
| versions | (record_id) | 版本回查(US-4.4)|
|
||||
| chat_messages | (record_id, created_at) | 对话历史按时间顺序 |
|
||||
|
||||
### 3.4 SQLite 配置
|
||||
|
||||
```sql
|
||||
PRAGMA journal_mode = WAL; -- 读写并发优化
|
||||
PRAGMA foreign_keys = ON; -- 外键约束
|
||||
PRAGMA busy_timeout = 5000; -- 写入冲突等待 5s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Agent 核心模块设计
|
||||
|
||||
> 对应设计文档「感知→规划→行动→记忆」四层架构,每个模块对应一个 Python 子包。
|
||||
|
||||
### 4.1 模块划分与文件结构
|
||||
|
||||
```
|
||||
src/agent/
|
||||
├── __init__.py
|
||||
├── perception.py # 感知层:输入解析 + 场景/语言/偏好识别
|
||||
├── planning.py # 规划层:结构匹配 + 页数分配 + 配色应用 + 参数映射
|
||||
├── action.py # 行动层:引擎编排 + 状态机调度
|
||||
├── chat.py # 对话修改:意图解析(LLM 优先/规则兜底)+ plan.json 变更应用
|
||||
├── llm.py # LLM 客户端:opencode serve / OpenAI 兼容双后端
|
||||
├── memory.py # 记忆层:读写 SQLite + 两版本策略
|
||||
└── orchestrator.py # 总调度:串联四层,生成/对话/解析三条流程入口
|
||||
```
|
||||
|
||||
### 4.2 感知层(perception.py)
|
||||
|
||||
| 方法 | 输入 | 输出 | 职责 |
|
||||
|------|------|------|------|
|
||||
| `parse_input(title, content)` | 标题 + 正文 | 结构化内容对象 | 清洗文本、截断超长、提取行数/字数 |
|
||||
| `resolve_config(payload, user_id)` | 用户请求 | 完整生成配置 | 合并请求参数 + 用户偏好(未显式提供的项用偏好默认值) |
|
||||
| `detect_lang_override()` | — | 语言参数 | 生成语言显式传参,否则取偏好 `default_language` |
|
||||
|
||||
**校验规则**(对齐 US-2.2 / US-2.6):内容 ≥ 20 字、页数 min ≤ max,不满足回传相应错误码(2001/2002)。
|
||||
|
||||
### 4.3 规划层(planning.py)
|
||||
|
||||
**职责**:把用户配置映射为引擎可执行的方案,是"包装引擎"的核心。
|
||||
|
||||
| 方法 | 职责 |
|
||||
|------|------|
|
||||
| `build_plan(cfg)` | 配置 + 内容 → plan.json(Markdown 标题切块、页数预算合并/拆分、五色板/日式规格选择,详见第五章)|
|
||||
|
||||
### 4.4 行动层(action.py)
|
||||
|
||||
**职责**:编排生成任务,管理状态机,调用引擎。
|
||||
|
||||
```
|
||||
生成流程(on /api/generate):
|
||||
1. 校验通过 → 占并发槽位(满则 2003)→ 返回 task_id
|
||||
2. 后台线程:
|
||||
a. 规划层 build_plan 产出 plan.json 并写盘(状态: planning)
|
||||
b. 引擎桥接 render_plan 渲染 + verify_output 保真验证(rendering)
|
||||
c. 完成后写 versions 表 + 记录 file_size/page_count/duration → status=done
|
||||
d. 失败 → 记录失败原因 → status=failed
|
||||
3. 前端通过 status 接口轮询(1~2s 间隔)
|
||||
```
|
||||
|
||||
### 4.5 记忆层(memory.py)
|
||||
|
||||
| 方法 | 职责 |
|
||||
|------|------|
|
||||
| `create_record(...)` / `list_records(...)` | 历史记录增查(含搜索/筛选/分页)|
|
||||
| `save_version(record_id, file_path, change_note)` | 两版本策略写盘 |
|
||||
| `load_history(record_id)` | 版本回查(当前+上一版)|
|
||||
| `record_chat(record_id, role, content)` | 对话消息落库 |
|
||||
| `read_preferences(user_id)` / `save_preferences(...)` | 偏好读写 |
|
||||
|
||||
> 两版本策略规则(决策日志 7-20):新版本生成时,删除旧"上一版",原"当前版"降级为"上一版",新文件成为"当前版"。
|
||||
|
||||
### 4.6 对话修改流程(chat.py,Sprint 2 已实现)
|
||||
|
||||
```
|
||||
用户消息(POST /api/chat)
|
||||
→ 意图解析(chat.parse_intent:LLM 优先解析任意自然语言并自动定位页码,规则句式中/日双语兜底;降级链见 5.6)
|
||||
→ 应用变更到 plan.json(chat._apply_to_plan)并写回磁盘
|
||||
→ aura 全量重渲(秒级)+ 内容保真验证
|
||||
→ 记忆层:save_version 两版本策略(新版本=1,旧版本降为2)+ 对话落库
|
||||
→ 返回确认回复(含新版本号与变更明细)
|
||||
```
|
||||
|
||||
| 意图类型 | 指令示例(中/日) | 动作 |
|
||||
|---------|------------------|------|
|
||||
| change_color | 换成绿色 / カラーを緑に変更 | 切换 corp-* 设计系统重渲(ja 固定 report-jp,返回不支持提示) |
|
||||
| change_title | 把标题改成XX / タイトルを「XX」に変更 | 更新 plan.title + 封面页 + ppt_records.title |
|
||||
| edit_page | 第2页把A改成B / 第3页删除"xx" / 第2页添加"yy" | 定位 slides[n] 增删改要点 |
|
||||
| unknown | 其余 | 返回帮助文案,不重渲染 |
|
||||
|
||||
> **实现说明**:设计原则为"只重新生成受影响内容";aura-ppt 引擎全量重渲仅数秒,等效满足体验目标,且保证整册风格一致性(如换色必须全册生效)。plan.json 是唯一演进基准(见 6.3)。
|
||||
|
||||
### 4.7 在线预览设计(2026-08-25 上线)
|
||||
|
||||
```
|
||||
预览请求(前端弹层)
|
||||
→ GET /api/files/<id>/preview # 校验记录归属当前用户 → 返回版本列表与页面清单
|
||||
→ bridge.export_preview_images # PowerPoint COM 导出每页 PNG(WPS 兜底),按版本缓存
|
||||
# 落盘 projects/<slug>/preview/<版本>/Slide<N>.PNG(文件名归一化)
|
||||
→ GET /api/preview-img/<id>/<name> # 单页图片读取:归属校验 + 文件名白名单
|
||||
```
|
||||
|
||||
| 设计点 | 说明 |
|
||||
|--------|------|
|
||||
| 渲染方式 | PowerPoint COM 自动化导出(本机 Office,WPS 兜底),零额外服务依赖 |
|
||||
| 缓存策略 | 按版本缓存 PNG,同版本二次打开零等待;新版本自动增量导出 |
|
||||
| 安全 | 两路由均校验 record 归属当前用户;文件名白名单(Slide<N>.PNG),防路径穿越 |
|
||||
| 前端 | 工作台结果卡片「预览」按钮弹出翻页弹层,默认展示对话修改后的最新版本 |
|
||||
|
||||
---
|
||||
|
||||
## 五、Agent 参数映射规则
|
||||
|
||||
> 2026-08-24 引擎切换后改版:原「八大确认自动应答」方案废弃,改为 **用户配置 → aura-ppt plan.json** 的确定性映射。规划层不再模拟人工确认,直接产出引擎可执行的结构化页面规划。
|
||||
|
||||
### 5.1 配置 → plan.json 映射总表
|
||||
|
||||
| 用户配置 | plan.json 落点 | 映射规则 |
|
||||
|---------|---------------|---------|
|
||||
| title / content | `title` + slides 结构 | content 按 Markdown 标题切块分页(见 5.3) |
|
||||
| language=ja | `design: report-jp` | 日语固定走日式报告规格(法人蓝 #0B3D91,与本部长报告会母版对齐) |
|
||||
| language=zh + color_scheme | `design: corp-*` | 五色板设计系统(见 5.2),默认 corp-blue |
|
||||
| canvas | 画布尺寸 | 当前仅支持 ppt169(16:9);ppt43 自动回退并写入任务告警(引擎待验证项) |
|
||||
| page_min ~ page_max | slides 数量预算 | 扣除封面/目次/结尾 3 页后,对内容块做合并/拆分(见 5.3) |
|
||||
| scene | ppt_records 落库字段 | v1 分页算法与场景无关,场景值用于历史筛选,Sprint 2 增强为骨架模板 |
|
||||
| image_strategy | plan.slides[].image | web=内容页配图(见 6.6,失败降级无图);ai/off=落库字段,暂不配图 |
|
||||
|
||||
### 5.2 五色板设计系统(对应 US-2.5)
|
||||
|
||||
在引擎 `DESIGN_SYSTEMS` 注册表扩展(继承 business-report 浅色骨架,覆写颜色令牌):
|
||||
|
||||
| 方案名 | design 键 | 主色 | 辅色 | 卡片底 | 说明 |
|
||||
|--------|-----------|:--:|:--:|------|------|
|
||||
| 商务蓝(默认) | corp-blue | #1565C0 | #2196F3 | #E3F2FD | 产品默认 |
|
||||
| 清新绿 | corp-green | #2E7D32 | #34A853 | #E8F5E9 | |
|
||||
| 科技紫 | corp-purple | #5B21B6 | #7C3AED | #EDE9FE | |
|
||||
| 活力橙 | corp-orange | #D97706 | #F59E0B | #FEF3C7 | |
|
||||
| 深灰 | corp-gray | #374151 | #6B7280 | #F3F4F6 | 图表灰阶+蓝红点睛 |
|
||||
|
||||
> 日语生成走 report-jp(#0B3D91/#1976D2),即公司确认的强调色 R11 G61 B145 / R25 G118 B210。
|
||||
|
||||
### 5.3 内容分页算法(planning.build_plan)
|
||||
|
||||
```
|
||||
输入 content(Markdown 文本)
|
||||
1. 逐行扫描:#/##/### 标题行 → 新建页面块;"-/*"行 → 当前块要点;普通行 → 要点
|
||||
2. 页数预算 = clamp(page_min,page_max) - 3(封面/目次/结尾)
|
||||
3. 块数 > 预算上限:小块合并(无标题且合计 ≤5 条优先并给前一有题块)
|
||||
4. 块数 < 预算下限:最大块对半拆分(续页标题追加「(续)/(続き)」)
|
||||
5. 单块要点 >10 条:按每页 5 条拆分为多页
|
||||
输出 slides = [cover] + [toc(≥2 页时)] + [content × N] + [end]
|
||||
```
|
||||
|
||||
- 无标题块回退标题:中文「要点 n」/日文「ポイント n」
|
||||
- 目次条目即各 content 页标题;语言文案包内置 zh/ja 两套(目录/致谢页等)
|
||||
|
||||
### 5.4 公司母版继承策略(--template 模式)
|
||||
|
||||
| 配置 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `COMPANY_TEMPLATE` | `engine/aura-ppt/assets/公司模板.pptx` | 项目资产副本(技能原版只读) |
|
||||
| `COMPANY_TEMPLATE_MODE` | `ja` | always=全部用模板 / ja=仅日语生成用 / off=不用 |
|
||||
| 生效行为 | — | 继承母版背景/Logo/页脚,以「白紙」版式建页,引擎元素叠加;页面尺寸随母版(10.83×7.5in) |
|
||||
|
||||
### 5.5 语言映射
|
||||
|
||||
| 维度 | zh | ja |
|
||||
|------|----|----|
|
||||
| design | corp-{color} | report-jp(固定) |
|
||||
| 回退标题 | 要点 n | ポイント n |
|
||||
| 目录/结尾 | 目录 / 谢谢聆听 | 目次 / ご清聴ありがとうございました |
|
||||
| 续页标记 | (续) | (続き) |
|
||||
|
||||
### 5.6 LLM 智能规划模式(2026-08-25 接入,08-26 改版为无降级)
|
||||
|
||||
**后端**:默认复用 opencode serve(`opencode serve --port 4096`,零 Key,模型 mimo-v2.5-free 经基准选定);预留 OpenAI 兼容接口(DeepSeek 等,配 Key 即切)。配置项:`LLM_BACKEND / OPENCODE_SERVE / OPENCODE_MODEL / OPENAI_API_* / PLAN_CHUNK_CHARS / PLAN_MAX_CHUNKS`。
|
||||
|
||||
**产品决策(2026-08-26)**:**取消规则降级**——要么产出总结版,要么明确报生成失败(含具体原因),绝不静默产出照搬版。规划失败抛 `PlanningError` → 任务 failed → 前端 toast 透传原因。
|
||||
|
||||
**能力与流程**:
|
||||
|
||||
| 环节 | 实现 | 失败处理 |
|
||||
|------|------|---------|
|
||||
| 长文档两级规划 | >6000 字先分段通读(Map:每段提炼小节摘要+关键数据)→ 汇总产出大纲(Reduce);全文覆盖,不再只读前3000字 | 单段调用重试2次,仍失败报错 |
|
||||
| 短文档直通 | ≤6000 字直接出大纲 | 同上 |
|
||||
| 页数硬约束 | `_validate_slides` 裁剪保证最终页数不超上限;每轮尝试均为 LLM 规划,共两轮 | 两轮均败→任务失败 |
|
||||
| 智能标题 | /api/title/suggest 返回 3 个候选 | 首行截断 |
|
||||
| 对话意图解析 | parse_intent_llm + 封面替换规范化为改标题;规则句式兜底 | 返回帮助文案 |
|
||||
| 数字保真 | 提示词约束 → 数字消毒器 → 引擎 --verify 门禁 | 保真不过→下一轮重新规划→仍败则报错 |
|
||||
|
||||
**质量保障**:LLM 产出经 `_validate_slides` 严格校验(类型白名单/长度截断/首尾页规范/页数硬上限)+ `_sanitize_fidelity` 数字消毒;引擎 `--verify` 内容保真为最终门禁。
|
||||
|
||||
|
||||
## 六、aura-ppt 集成方式
|
||||
|
||||
> 2026-08-24 选型切换(对比依据:`项目资料/事前调查/引擎选型对比_ppt-master_vs_aura-ppt.md`)。渲染出口统一为 aura-ppt;ppt-master 降级为辅助引擎,仅复用其文档解析(source_to_md)与网络搜图(image_search)独立脚本。
|
||||
|
||||
### 6.1 渲染调用链(action.run_generation)
|
||||
|
||||
| 步骤 | 操作 | 实现 | 状态机 |
|
||||
|------|------|------|:--:|
|
||||
| ① 规划 | GenerateConfig → plan dict | `planning.build_plan(cfg)`(第五章算法) | planning |
|
||||
| ② 写盘 | `projects/<slug>_<ts>/` 建 sources/source.md + plan.json | action 直接写文件 | planning |
|
||||
| ③ 渲染 | `engine.py --plan plan.json --output out.pptx [--template 公司模板.pptx]` | `bridge.render_plan()` subprocess | rendering |
|
||||
| ④ 验证 | `engine.py --verify out.pptx --plan plan.json` | `bridge.verify_output()`,失败重试 1 次 | rendering |
|
||||
| ⑤ 落库 | ppt_records + versions(version_no=1) | memory.save_generated_record | done |
|
||||
|
||||
### 6.2 调用实现要点
|
||||
|
||||
| 要点 | 说明 |
|
||||
|------|------|
|
||||
| CLI 调用 | `subprocess.run([python, engine.py, ...], cwd=AURA_ENGINE_PATH)`,不 import 引擎内部模块 |
|
||||
| 编码 | 子进程强制 `PYTHONIOENCODING=utf-8` 语义(bridge 以 utf-8 解码),规避 GBK 控制台报错 |
|
||||
| 成功判据 | 渲染:退出码 0 且输出文件存在;验证:stdout 含「内容保真验证通过」且无「保真失败」 |
|
||||
| 失败处理 | EngineError → 任务 failed(错误码 2004);保真失败重试 1 次仍失败即终止 |
|
||||
| 并发控制 | 进程内信号量(MAX_CONCURRENT_GENERATION),占满返回 2003 引擎繁忙 |
|
||||
| 超时 | 渲染 600s / 验证 300s,超时抛 EngineError |
|
||||
| 模板模式 | 日语生成默认继承公司母版(5.4);模板模式页面尺寸随母版 |
|
||||
|
||||
### 6.3 生成目录结构
|
||||
|
||||
```
|
||||
src/web/data/projects/<标题slug>_<时间戳>/
|
||||
├── sources/
|
||||
│ └── source.md # 用户原始内容(留档/复盘)
|
||||
├── plan.json # 本次生成的完整页面规划(可独立重放渲染)
|
||||
└── exports/
|
||||
└── <slug>_<时间戳>.pptx # 成品文件(versions.file_path 相对引用)
|
||||
```
|
||||
|
||||
> plan.json 即「执行合同」:下载、预览、对话修改均以它为基准做增量演进(Sprint 2 局部重渲染)。
|
||||
|
||||
### 6.4 双引擎分工与配置
|
||||
|
||||
| 引擎 | 角色 | 路径配置 | 当前接入点 |
|
||||
|------|------|---------|-----------|
|
||||
| aura-ppt | 主渲染引擎 | `AURA_ENGINE_PATH` / `AURA_ENGINE_SCRIPT` | bridge.render_plan / verify_output |
|
||||
| ppt-master | 辅助:文档解析 + 网络搜图 | `PPT_MASTER_PATH` | 文档解析已接入(agent/upload.py 调 source_to_md 五脚本);配图(image_search)待接 |
|
||||
|
||||
### 6.5 上传解析链路(2026-08-25 落地)
|
||||
|
||||
```
|
||||
前端选择文件/粘贴URL → POST /api/upload(扩展名白名单 + 20MB + 24h 过期清理,存 UPLOAD_DIR)
|
||||
→ POST /api/parse → agent/upload.py 按扩展名分派:
|
||||
.md/.txt 直读;.pdf/.docx/.doc/.html/.xlsx/.xlsm/.pptx 经 subprocess 调
|
||||
ppt-master scripts/source_to_md/{pdf,doc,excel,ppt}_to_md.py;URL 调 web_to_md.py
|
||||
→ 产物清洗(去转换器横幅/内部文件名引用/图片与资源引用、Markdown 反转义、压空行、3万字截断)
|
||||
→ 回传 {text, chars},前端自动切回文本页并回填正文框
|
||||
错误映射:3001 类型不支持 / 3002 超 20MB / 3003 解析失败 / 3004 内容为空
|
||||
|
||||
### 6.6 网络配图链路(2026-08-25 落地)
|
||||
|
||||
```
|
||||
image_strategy=web → action 挂载 imaging.attach_images(plan.slides, project_dir)
|
||||
→ 取前 MAX_IMAGE_SLIDES(4) 个内容页,以页面标题为关键词
|
||||
→ IMAGE_SEARCH_MODE:
|
||||
web = subprocess 调 ppt-master scripts/image_search.py
|
||||
(openverse/wikimedia 零配置;pexels/pixabay 配 Key 即启用)
|
||||
test = 本地样例图通道(IMAGE_SEARCH_TEST_DIR,e2e 确定性)
|
||||
off = 禁用
|
||||
→ 成图落 projects/<slug>/images/slide_x.jpg,路径写入 plan.slides[].image 并随 plan.json 落盘
|
||||
→ aura-ppt 渲染:content 页左文右图(文本区收窄 58%,图片等比缩放/垂直居中/细边框)
|
||||
容错:单页失败仅记任务告警,全部失败降级为无图版式;verify 保真检查跳过 image 键。
|
||||
现状:本机网络 openverse/wikimedia 不可达(超时/重置),管线与降级已验证,
|
||||
真实搜图待更换网络环境或配置 pexels/pixabay Key 即自动启用。
|
||||
|
||||
### 6.5 引擎扩展点(已落地与待移植)
|
||||
|
||||
| 项 | 状态 |
|
||||
|----|------|
|
||||
| 五色板设计系统注册(corp-*) | ✅ 已落地(engine.py DESIGN_SYSTEMS) |
|
||||
| 公司模板 assets 副本 | ✅ 已拷入 engine/aura-ppt/assets/ |
|
||||
| KPI 卡片 / 数据源脚注 / 阴影开关 / 精选图标 | ⏳ 移植第一批(见选型对比文档第五章) |
|
||||
| 幻灯片插图片(content 页右图布局) | ✅ 已落地(_place_image,见 6.6) |
|
||||
| 4:3 画布 | ⏳ 待试跑验证后实现 |
|
||||
| 动画 | ✕ 放弃(python-pptx 限制) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 七、异常与错误码体系
|
||||
|
||||
> 对齐 PRD 4.3「错误提示以中文展示,不显示原始异常栈」。前端直接展示 `message` 字段,后端日志记录完整堆栈。
|
||||
|
||||
### 7.1 错误码全景表
|
||||
|
||||
| 错误码 | 触发场景 | 后端处理 | 前端提示文案 |
|
||||
|:--:|------|------|------|
|
||||
| **1001** | 注册用户名已存在 | 唯一索引冲突捕获 | 用户名已被使用 |
|
||||
| **1002** | 登录用户名或密码错误 | 哈希比对失败 | 用户名或密码错误 |
|
||||
| **1003** | 注册两次密码不一致 | 请求参数校验 | 两次输入的密码不一致 |
|
||||
| **1004** | 未登录访问受限接口 | Session 校验拦截 | 请先登录 |
|
||||
| **2001** | 生成内容不足 20 字 | 校验拦截 | 内容至少需要 20 个字 |
|
||||
| **2002** | 页数 min > max | 校验拦截 | 最少页数不能大于最多页数 |
|
||||
| **2003** | 引擎繁忙(并发上限)| 任务队列拒绝 | 引擎繁忙,请稍后重试 |
|
||||
| **2004** | 生成失败(引擎已重试)| 任务标记 failed | 生成失败,请重试 |
|
||||
| **2005** | 生成中断(服务重启/超时)| 任务标记 failed | 生成中断,请重新生成 |
|
||||
| **3001** | 上传文件类型不在白名单 | 扩展名校验拦截 | 不支持的文件类型 |
|
||||
| **3002** | 上传文件超过 20MB | 大小校验拦截 | 文件大小不能超过 20MB |
|
||||
| **3003** | 文档解析失败(损坏)| 解析器异常捕获 | 文件损坏,无法读取 |
|
||||
| **3004** | 解析结果为空 | 解析后空文本检测 | 文件内容为空 |
|
||||
| **3005** | 文件上传失败 | 存储异常捕获 | 上传失败,请重试 |
|
||||
| **4001** | 历史记录不存在/已被删除 | 查询无结果 | 该记录不存在或已被删除 |
|
||||
| **4002** | 下载文件不存在 | 文件系统检查 | 文件已丢失,请重新生成 |
|
||||
| **5001** | 修改密码原密码错误 | 哈希比对失败 | 原密码不正确 |
|
||||
| **5002** | 用户名/密码格式非法 | 正则校验 | 用户名 3-20 位,密码至少 6 位 |
|
||||
| **6001** | 引擎进程非零退出 | subprocess stderr 解析 | 引擎内部异常,请联系管理员 |
|
||||
| **6002** | 引擎配置文件缺失 | 启动自检 | 系统配置异常,请稍后重试 |
|
||||
|
||||
### 7.2 统一错误响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 2001,
|
||||
"message": "内容至少需要 20 个字",
|
||||
"detail": null
|
||||
}
|
||||
```
|
||||
|
||||
> `detail` 仅后端调试日志输出,**永不返回前端**。
|
||||
|
||||
### 7.3 引擎失败分类与重试策略
|
||||
|
||||
| 分类 | 判定 | 动作 | 前端码 |
|
||||
|------|------|------|:--:|
|
||||
| 可恢复(网络/限速/图片源超时)| 退出码非零 + stderr 匹配网络关键字 | 重试 1 次(指数退避 2s→4s)| 重试成功继续,失败 2004 |
|
||||
| 不可恢复(脚本缺参/SVG 质检 error 无法修复)| 退出码非零 + 逻辑错误 | 直接失败 | 2004 / 6001 |
|
||||
| 资源性(同时任务超限)| 任务队列满载 | 拒绝入队 | 2003 |
|
||||
| 中断性(服务重启)| 任务表 status=processing 且服务重启 | 标记 failed | 2005 |
|
||||
|
||||
### 7.4 前端错误处理约定
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 字段校验错误 | Toast 提示对应文案,不清空用户输入 |
|
||||
| 接口 401 | 跳转登录页 |
|
||||
| 联网失败(fetch 异常)| 提示"网络异常,请检查网络后重试",保留页面状态 |
|
||||
| 生成中失败 | 结果卡片红色区域展示错误文案 +「重新生成」按钮(US-2.7b)|
|
||||
|
||||
---
|
||||
|
||||
## 八、安全设计
|
||||
|
||||
> 对齐 PRD 4.2。本项目为内网自用系统,采用适度防护,不做过度设计。
|
||||
|
||||
| 维度 | 措施 | 实现 |
|
||||
|------|------|------|
|
||||
| **密码存储** | bcrypt 哈希,不存明文 | `werkzeug.security.generate_password_hash`,成本因子 12 |
|
||||
| **会话管理** | Flask Session + 签名 Cookie | `SECRET_KEY` 存于 `config.SECRET_KEY`(环境变量注入,不进仓库)|
|
||||
| **CSRF 防护** | 同源校验 | 所有写接口校验 `Origin`/`Referer` 头 + Session 内 CSRF Token |
|
||||
| **文件上传** | 扩展名白名单 + 大小限制 | 8 种格式白名单,≤20MB,存储到非 web 根目录 |
|
||||
| **SQL 注入防护** | 参数化查询 | 全部 SQL 用 `?` 占位符,禁止字符串拼接 |
|
||||
| **路径穿越** | 下载路径校验 | record_id 反查数据库后拼接相对路径,禁止直接拼接用户输入文件名 |
|
||||
| **日志脱敏** | 不记录密码/令牌 | 统一日志过滤器屏蔽敏感字段 |
|
||||
| **内网安全** | 仅监听 127.0.0.1 | Flask 运行绑定本机回环地址,不开放公网 |
|
||||
|
||||
---
|
||||
|
||||
## 九、目录结构与部署
|
||||
|
||||
### 9.1 项目目录树
|
||||
|
||||
```
|
||||
ppt-agent/
|
||||
├── src/
|
||||
│ ├── web/
|
||||
│ │ ├── app.py # Flask 入口 + 路由 + 中间件
|
||||
│ │ ├── static/
|
||||
│ │ │ ├── css/
|
||||
│ │ │ ├── js/
|
||||
│ │ │ └── lang/ # zh.json / ja.json(UI 语言包)
|
||||
│ │ ├── templates/ # 4 页面 Jinja2 模板
|
||||
│ │ └── data/
|
||||
│ │ ├── ppt.db # SQLite
|
||||
│ │ └── projects/ # 生成任务输出目录
|
||||
│ ├── agent/ # Agent 核心(第4章)
|
||||
│ ├── engine_bridge/ # 引擎 CLI 调用封装
|
||||
│ └── db/ # 连接管理 + DDL
|
||||
├── engine/
|
||||
│ ├── aura-ppt/ # ⚠️ 主渲染引擎副本(真值,优化在此)
|
||||
│ │ └── assets/公司模板.pptx
|
||||
│ └── ppt-master/ # 辅助引擎副本(解析/搜图脚本复用)
|
||||
├── tests/ # 单元/集成测试
|
||||
├── scripts/ # 辅助脚本(DB 初始化等)
|
||||
├── requirements.txt
|
||||
├── config.py # 常量与配置
|
||||
├── .gitignore
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### 9.2 运行方式
|
||||
|
||||
```
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 初始化数据库(幂等,可重复执行)
|
||||
python -m src.db.init
|
||||
|
||||
# 启动 Web 服务(默认绑定 127.0.0.1:5000)
|
||||
python src/web/app.py
|
||||
```
|
||||
|
||||
### 9.3 配置项(config.py)
|
||||
|
||||
| 配置 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `AURA_ENGINE_PATH` | `engine/aura-ppt/` | 主渲染引擎目录(可配置)|
|
||||
| `PPT_MASTER_PATH` | `engine/ppt-master/` | 辅助引擎目录(解析/搜图)|
|
||||
| `COMPANY_TEMPLATE` | `engine/aura-ppt/assets/公司模板.pptx` | 公司母版(--template 模式)|
|
||||
| `COMPANY_TEMPLATE_MODE` | ja | 模板模式策略:always/ja/off |
|
||||
| `PROJECTS_PATH` | `src/web/data/projects/` | 生成任务输出根目录 |
|
||||
| `DB_PATH` | `src/web/data/ppt.db` | SQLite 文件路径 |
|
||||
| `SECRET_KEY` | 环境变量 | Flask Session 密钥 |
|
||||
| `MAX_UPLOAD_MB` | 20 | 上传大小上限 |
|
||||
| `ALLOWED_EXT` | 8 种格式 | 扩展名白名单 |
|
||||
| `ALLOWED_SCENE` | report/training | 场景枚举 |
|
||||
| `ALLOWED_COLOR` | blue/green/purple/orange/gray | 配色枚举 |
|
||||
| `ALLOWED_LANG` | zh/ja | 生成语言枚举 |
|
||||
| `ALLOWED_CANVAS` | ppt169/ppt43 | 画布枚举 |
|
||||
| `ALLOWED_IMAGE` | web/ai/off | 配图策略枚举 |
|
||||
| `LLM_BACKEND` | opencode | 大模型后端:opencode(免费零 Key)/ openai(兼容接口) |
|
||||
| `OPENCODE_SERVE` | http://127.0.0.1:4096 | opencode serve 地址(--port 4096 常驻) |
|
||||
| `OPENCODE_MODEL` | opencode/mimo-v2.5-free | 免费模型(三模型基准选定,44s/大纲) |
|
||||
| `OPENAI_API_BASE` / `OPENAI_API_KEY` / `OPENAI_MODEL` | 环境变量 | DeepSeek 等 OpenAI 兼容后端切换入口(配 Key 即切) |
|
||||
| `LLM_TIMEOUT` | 180 | LLM 请求超时(秒) |
|
||||
|
||||
---
|
||||
|
||||
## 十、里程碑映射
|
||||
|
||||
> 对齐 PRD「五、用户故事与开发阶段对应」的 3 个 Sprint,补充到接口/数据表/模块粒度。
|
||||
|
||||
### 10.1 Sprint 1(MVP:登录→生成→下载)✅ 已完成(2026-08-24)
|
||||
|
||||
**目标**:跑通"登录 → 输入文字 → 配置 → 生成 → 下载"完整链路。
|
||||
|
||||
| 内容 | 明细 |
|
||||
|------|------|
|
||||
| 接口 | 1-9 号(认证 4 + 生成 6)|
|
||||
| 数据表 | users + preferences + ppt_records + versions(versions 先建表,Sprint 2 启用回退)|
|
||||
| Agent 模块 | perception(基础解析)+ action(生成编排)+ orchestrator(生成流程)|
|
||||
| 引擎集成 | 7 步全流程 + branding 模板(blue)+ 质检门禁 |
|
||||
| 语言 | zh / ja 生成 + UI zh/ja 双语言包(UI 语言切换第一期启用)|
|
||||
|
||||
### 10.2 Sprint 2(对话修改 + 历史闭环)✅ 已完成(2026-08-25)
|
||||
|
||||
**目标**:生成→修改→回退 完整闭环 + 在线预览。
|
||||
|
||||
| 内容 | 明细 |
|
||||
|------|------|
|
||||
| 接口 | 10、13-17 号(预览 + 对话 + 历史 CRUD)|
|
||||
| 数据表 | chat_messages 启用、versions 回退逻辑启用 |
|
||||
| Agent 模块 | planning(修改意图解析)+ memory(版本/对话读写)|
|
||||
| 引擎集成 | 只重生成受影响页面(局部渲染)|
|
||||
| 前端 | 对话弹窗接入真实轮询 + 历史两版本展示 |
|
||||
|
||||
> **完成情况与偏差**:接口 13/14 号、chat_messages 落库、两版本回退均已落地,e2e_chat 16 项断言全绿;「只重生成受影响页面」调整为 plan.json 变更 + aura 全量重渲(秒级,等效满足体验且保证整册风格一致,见 4.6);在线预览以 COM 导出 PNG 实现(见 4.7);智能标题(原 Sprint 3 内容)随 LLM 接入提前完成。
|
||||
|
||||
### 10.3 Sprint 3(体验完善)
|
||||
|
||||
**目标**:上传解析 + 智能标题 + 偏好 + UI 全语言。
|
||||
|
||||
| 内容 | 明细 |
|
||||
|------|------|
|
||||
| 接口 | 11-12、18-21 号(上传解析 + 设置)|
|
||||
| Agent 模块 | memory 偏好读写、upload 解析链路 |
|
||||
| 引擎集成 | `source_to_md/*.py` 8 格式转换 + 智能标题 DeepSeek 调用 |
|
||||
| 前端 | 设置页生成偏好绑定 + 历史页语言筛选(预留,本期可选)|
|
||||
|
||||
> **进度**:上传解析已提前落地(2026-08-25,见 6.5,e2e_upload 21 项断言全绿);智能标题已于 LLM 接入时完成;设置偏好与历史语言筛选已落地(e2e_settings 18 项断言全绿);UI 全语言四页收尾完成(login/history 补 i18n 与切换入口);网络配图管线落地(见 6.6,e2e_image 9 项断言全绿)。**Sprint 3 全部用户故事已覆盖**,余一项环境依赖:真实搜图源需换网络或配 Key。
|
||||
|
||||
### 10.4 里程碑与成果物对齐
|
||||
|
||||
| 里程碑 | 关联成果物 | 验收口径 |
|
||||
|--------|-----------|---------|
|
||||
| Sprint 1 完成 | 03 源码(主体)| US-1.1~2.8 通过测试顾问验收 |
|
||||
| Sprint 2 完成 | 03 源码(完整)+ 04 实验报告起点 | 修改/回退闭环可演示 |
|
||||
| Sprint 3 完成 | 04 实验报告 | 全部 23 条用户故事覆盖 |
|
||||
| 全量定稿 | 05 AI使用日志 + 06 演示视频 | 整理 8 月日志 + 录制完整流程 |
|
||||
|
||||
---
|
||||
|
||||
*全 10 章完成。v0.3 已同步 Sprint 2 落地状态(对话修改 / 在线预览 / LLM 零Key 接入),e2e_web、e2e_chat、e2e_llm 三套测试全绿。*
|
||||
@@ -0,0 +1,311 @@
|
||||
---
|
||||
name: aura-ppt
|
||||
description: >
|
||||
专业的演示文稿(PPT/PPTX)生成与美化技能。综合国际顶级AI PPT工具的能力:
|
||||
Gamma的设计感、WPS AI的中文排版理解、7牛AI PPT的内容保真、Beautiful.ai的
|
||||
无损导出——全部通过本地python-pptx引擎实现,零云端依赖、数据不出本机。
|
||||
触发场景:用户要求"生成PPT/制作演示文稿/做一份汇报材料/把文档转成PPT/
|
||||
美化这个PPT/给方案配一份PPT/做路演材料"等。支持四种模式:全流程生成(从
|
||||
大纲到成品)、文档转PPT(Word/Markdown/文本)、已有PPT美化、数据图表页。
|
||||
适用于中文/日文/英文汇报、方案陈述、管理层汇报、技术分享等场景。
|
||||
---
|
||||
|
||||
# Aura PPT — 本地AI演示文稿引擎
|
||||
|
||||
## 核心定位
|
||||
|
||||
**一站式本地PPT生成技能**:把国际AI PPT工具的顶级能力编码为可复现的本地渲染引擎。
|
||||
|
||||
| 能力来源 | 编码进技能的方式 |
|
||||
|---|---|
|
||||
| Gamma 设计感 | 品牌级设计系统(design_system.py):统一配色、字体层级、间距网格、组件库 |
|
||||
| WPS AI 中文理解 | 中文排版规范(references/chinese-typography.md):中英混排、标点、字体回退 |
|
||||
| 7牛AI PPT 内容保真 | **内容保真规则**:用户提供的数据/术语/数字 100% 原样保留,禁止改写 |
|
||||
| Beautiful.ai 无损导出 | python-pptx 原生输出,零转换损失,交付后完全可编辑 |
|
||||
| python-pptx 可控性 | 全矢量形状,任何人可用 PowerPoint/WPS 二次编辑 |
|
||||
|
||||
**硬性合规优势**:全程本地渲染,数据不出本机——适合处理客户机密材料(对日外包、NDA场景)。
|
||||
|
||||
---
|
||||
|
||||
## 触发条件
|
||||
|
||||
以下场景应调用本技能(只要用户意图包含其一):
|
||||
|
||||
- "生成/做一份 PPT / 演示文稿 / 幻灯片"
|
||||
- "把这个文档/方案/报告转成 PPT"
|
||||
- "美化 / 优化 / 重排这个 PPT"
|
||||
- "给 XX 配一份汇报材料 / 路演材料 / 提案"
|
||||
- "做一份管理层汇报 / 技术分享 / 项目总结"
|
||||
|
||||
若用户只是问PPT工具选型、PPT技巧问答(非生成请求)→ 不触发。
|
||||
|
||||
---
|
||||
|
||||
## 四种工作模式
|
||||
|
||||
| 模式 | 触发词 | 流程 |
|
||||
|---|---|---|
|
||||
| **A. 全流程生成** | 从零生成、做一份 | 大纲 → 页面规划(JSON) → 渲染 → 验证 |
|
||||
| **B. 文档转PPT** | 把XX转成PPT | 解析源文档 → 提取结构 → 页面规划 → 渲染 |
|
||||
| **C. 已有PPT美化** | 美化、优化、重排 | 打开现有PPTX → 重新应用设计系统 → 输出 |
|
||||
| **D. 数据图表页** | 数据可视化、图表 | 解析数据(CSV/Excel) → 图表类型选择 → 渲染 |
|
||||
|
||||
---
|
||||
|
||||
## 工作流
|
||||
|
||||
### Step 1:需求澄清(30秒内完成,不要过度提问)
|
||||
|
||||
确认以下最小集,其余用合理默认值:
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| 页数 | 8-12页 | 内容多可自动扩展 |
|
||||
| 设计系统 | `aura-dark`(品牌深色) | 见 references/design-system.md |
|
||||
| 语言 | 跟随用户内容(中文/日文/英文) | 中日双语混合时开启术语对齐 |
|
||||
| 输出目录 | 用户工作目录 | 询问一次即可 |
|
||||
| 16:9 / 4:3 | 16:9 | 现代汇报默认 |
|
||||
|
||||
**如果用户提到"公司/品牌"→ 询问是否有品牌色/Logo**,有则调用 `assets/` 下的品牌配置。
|
||||
|
||||
### Step 2:规划页面结构(Plan JSON)
|
||||
|
||||
生成结构化页面规划。**这是内容保真的关键步骤**:
|
||||
|
||||
```
|
||||
plan.json 结构:
|
||||
{
|
||||
"title": "演示文稿标题",
|
||||
"design": "aura-dark",
|
||||
"mode": "generate | doc2ppt | improve | charts",
|
||||
"source_doc": "可选:源文档路径",
|
||||
"slides": [
|
||||
{
|
||||
"type": "cover | toc | section | content | two_column |
|
||||
table | chart | timeline | quote | end",
|
||||
"title": "页面标题",
|
||||
"subtitle": "副标题(可选)",
|
||||
"content": "正文要点(每行一个要点,支持 **加粗** 和 - 子项)",
|
||||
"data": {"表格数据/图表数据,可选"},
|
||||
"chart": {"type": "bar|line|pie|donut", "source": "csv路径或内联数据"},
|
||||
"notes": "演讲者备注(可选)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3:调用渲染引擎
|
||||
|
||||
```bash
|
||||
# 标准生成(使用内置设计系统)
|
||||
python scripts/engine.py --plan plan.json --output output.pptx [--design aura-dark]
|
||||
|
||||
# 模板生成(继承指定PPTX的母版背景/页脚/Logo/版式)
|
||||
python scripts/engine.py --plan plan.json --output output.pptx --template 模板.pptx
|
||||
```
|
||||
|
||||
`--template` 模板模式:
|
||||
- 打开指定 PPTX 作为模板,**继承其母版、版式、背景、页脚、Logo**
|
||||
- 自动清空模板自带的旧幻灯片,只保留母版结构
|
||||
- 用模板的"白紙"版式创建页面,引擎的绘制元素叠加其上
|
||||
- 适合:公司已有固定PPT模板/母版,希望新生成的内容保持公司风格一致
|
||||
- 模板模式下不绘制引擎的纯色背景(继承模板背景)
|
||||
|
||||
**区域对齐**:引擎的 header/body/footer 坐标严格遵循设计系统的 `layout` 配置。
|
||||
`report-jp` 的 layout 已与公司母版对齐(参考 010_【本部長報告会】):
|
||||
|
||||
| 区域 | 模板规格 | layout 配置 |
|
||||
|---|---|---|
|
||||
| Header 章节行 | y0.32 (20pt 含编号) | header_top=0.32 |
|
||||
| Header 主题行 | y0.88 (16pt) | subtitle_top=0.88 |
|
||||
| Body 起始 | y1.74 | body_top=1.60 |
|
||||
| Body 结束 | y6.4 页脚条上方 | body_bottom=6.30 |
|
||||
| Footer | y6.4~7.1 | footer_top=7.05 |
|
||||
| 页码 | y7.16 右下角 | page_num_y=7.16 |
|
||||
|
||||
report-jp 的 header 采用模板风格:**章节行20pt在上 + 主题行16pt在下**(与
|
||||
"竖条+大标题"的通用风格不同),通过 `_header_jp()` 专用方法渲染。
|
||||
|
||||
### Step 4:验证输出(必须执行)
|
||||
|
||||
```bash
|
||||
python scripts/engine.py --verify output.pptx [--plan plan.json]
|
||||
```
|
||||
|
||||
验证项:
|
||||
1. ✅ 页数符合预期
|
||||
2. ✅ 所有文本无溢出(超出页面边界检测)
|
||||
3. ✅ **内容保真**:用户提供的数据/术语与源输入逐项比对(有差异即报错)
|
||||
4. ✅ 图表数据点完整
|
||||
|
||||
---
|
||||
|
||||
## 内容保真规则(最高优先级,不可违反)
|
||||
|
||||
> 灵感来源:7牛AI PPT 的"保持原文"模式。**AI只负责结构、排版、设计,绝不改写用户的数据。**
|
||||
|
||||
1. **数字原样保留**:用户提供的任何数值(`96.8%`、`¥5,000万`、`2024年3月`)在最终PPT中必须逐字符一致
|
||||
2. **术语原样保留**:专有名词、产品名、客户名、日文术语(`テスト仕様書`、`要件定義`)不得意译、不得替换
|
||||
3. **禁止四舍五入**:`23.6%` 不得写成 `约24%`;`3.14` 不得写成 `3`
|
||||
4. **引用原样保留**:用户提供的引用、条款、数据来源标注,一字不改
|
||||
5. **结构可重组,内容不可改写**:AI 可以调整内容的组织方式(分页、分组、层级),但不得修改内容本身的含义和数据
|
||||
6. **LLM生成的新内容**(用户未提供、由AI补充的过渡句/总结)必须与用户内容区分:可用 `[AI补充]` 标注或在演讲者备注中说明
|
||||
|
||||
**验证**:渲染后必须运行 `--verify`,自动比对源数据与输出文本。
|
||||
|
||||
---
|
||||
|
||||
## 中文排版规范(中文场景必读)
|
||||
|
||||
> 灵感来源:WPS AI 的中文理解。遵循 references/chinese-typography.md 的完整规则。
|
||||
|
||||
核心规则速览:
|
||||
1. **中英混排加空格**:`使用 Python 开发`(中文与英文/数字之间插入半角空格)
|
||||
2. **标点规范**:中文语境用全角标点(,。:;);引号用「」或“”
|
||||
3. **日文文本**:日文与中文之间不加空格;日文假名用全角;保留日文原字符(テスト、仕様書)
|
||||
4. **数字**:千位分隔(1,000,000);百分比紧跟数字(96.8%)
|
||||
5. **字体**:中文微软雅黑、日文Yu Gothic/游ゴシック、英文Segoe UI;字体回退链见 references
|
||||
6. **标题层级**:封面标题40pt+ → 章节标题32pt → 页标题28pt → 正文16-18pt → 注释12pt
|
||||
7. **行长**:每行不超过28个中文字符,长文自动换行
|
||||
8. **段距**:正文行距1.3-1.5,段间距8-12pt
|
||||
|
||||
---
|
||||
|
||||
## 设计系统
|
||||
|
||||
内置 3 套设计系统(见 references/design-system.md):
|
||||
|
||||
| 系统名 | 风格 | 适用 |
|
||||
|---|---|---|
|
||||
| `aura-dark`(默认) | 深色高级(深蓝黑 + 电光青) | 科技感、对外提案、产品发布 |
|
||||
| `business-report` | **浅色商务(白底 + 火焰蓝 + 商务红)** | 商务报告、管理层汇报、咨询风格 |
|
||||
| `report-jp` | **日式商务报告(法人蓝 + 浅天蓝卡片 + Meiryo UI)** | **日本客户汇报、本部報告会、对日业务** |
|
||||
|
||||
设计系统选型建议:
|
||||
- 用户提到"日本/日方/報告会/本部/日企" → 用 `report-jp`(10.83×7.5in 非标准比例,Meiryo UI 字体,章节编号页眉,支持「機密情報」机密标记)
|
||||
- 用户提到"商务报告/汇报风格/正式/咨询/打印" → 用 `business-report`
|
||||
- 用户提到"科技感/高级/对外/发布会" → 用 `aura-dark`
|
||||
|
||||
选择方式:plan.json 的 `design` 字段;CLI 用 `--design`。
|
||||
|
||||
### 通用 plan.json 顶层字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `design` | 设计系统名(aura-dark / business-report / report-jp) |
|
||||
| `company` | 页脚公司名 |
|
||||
| `date` | 页脚日期 |
|
||||
| `notes_template` | true 时自动为无备注页面生成结构化演讲备注 |
|
||||
| `confidential` | 机密标记文本(如「機密情報」「社外秘」),显示在每页右上角红色标签 |
|
||||
|
||||
### 页面类型(每种支持 section_no / confidential 字段)
|
||||
|
||||
cover / toc / section / content / two_column / table / chart / timeline / **flowchart** / quote / end
|
||||
|
||||
**flowchart 流程图**(参考日企报告会资料):
|
||||
- 横向流程:`content: ["阶段A|描述", "阶段B|描述", ...]`
|
||||
- 泳道模式:`rows: ["泳道1", "泳道2"], content: [["步骤A","步骤B"], ["步骤C"]]`
|
||||
- 浅天蓝卡片 + 法人蓝标题 + 箭头连接,日企报告标准样式
|
||||
|
||||
### 高级能力(引擎内置,plan 字段触发)
|
||||
|
||||
**cover 封面大数字**(`stats` 字段):
|
||||
```json
|
||||
"stats": [["362件", "概要設計案を作成"], ["5案件", "に横断参画"]]
|
||||
```
|
||||
渲染为右侧竖排大数字卡片墙(彩色顶条+大数字+说明),标题区自动压缩。
|
||||
|
||||
**content 要点卡片**(自动识别):
|
||||
- `content` 中 `**标题** | 描述` 格式的行 → 自动渲染为**卡片网格**(2列,左色条+标题+描述+投影)
|
||||
- 纯文本行 → 普通文本布局;混合 → 卡片区+底部说明
|
||||
- 卡片≥3行时字号自动压缩防溢出
|
||||
|
||||
**table 混合页**(`charts` 字段):
|
||||
- 表格下方渲染一个或多个图表(`chart` 单图 / `charts` 多图并排)
|
||||
|
||||
**chart 多图**(`charts` 字段):
|
||||
```json
|
||||
"charts": [{"type": "bar", "title": "图1", "data": {...}}, {"type": "bar", "title": "图2", "data": {...}}]
|
||||
```
|
||||
- 多图并排,每个带独立子标题
|
||||
- 柱状图:**渐变柱体**(顶部提亮45%→底部原色)+ 水平网格线 + 数值/类别标签
|
||||
|
||||
**全局视觉能力**:
|
||||
- 卡片柔和投影(`add_shape_shadow`):所有卡片容器自动带投影,立体感
|
||||
- 协调渐变色板:每个设计系统 6 色光谱序列(冷→暖过渡)
|
||||
- 圆角卡片(radius 8%)
|
||||
- 页眉装饰线(report-jp:主色细线+左端锚点块)
|
||||
- 章节页装饰(竖条+横线)
|
||||
- 内容区背景层(report-jp 模板模式:body 区极浅色分区)
|
||||
|
||||
### 日式页眉(report-jp 特色)
|
||||
|
||||
plan 页面支持 `section_no` 字段(如 "1"),渲染为日式页眉编号:
|
||||
```
|
||||
1. 【トピックス】AI能力構築の全体像のご報告(1/2)
|
||||
```
|
||||
配合 `confidential` 字段显示右上角「機密情報」红色标记。
|
||||
|
||||
组件库(三种系统共用):
|
||||
- 封面/目录/章节/内容页/双栏/表格/图表/时间线/流程图/引言/结束页
|
||||
- 统一网格:页边距 0.5in,垂直节奏 0.2in 网格
|
||||
- 圆角卡片(radius 8%)+ 柔和投影
|
||||
- 图表渐变柱体 + 协调色板
|
||||
|
||||
如需其他风格,在 engine.py 的 DESIGN_SYSTEMS 注册表中扩展(继承 DesignSystem 覆写令牌)。
|
||||
|
||||
---
|
||||
|
||||
## 数据图表页(模式D)
|
||||
|
||||
```bash
|
||||
# 方式1:CSV/Excel 数据源
|
||||
python scripts/engine.py --plan plan.json --output out.pptx
|
||||
# plan.json 中 slide.chart = {"type": "bar", "source": "data.csv", "title": "季度营收"}
|
||||
|
||||
# 方式2:内联数据
|
||||
# slide.chart = {"type": "pie", "data": {"A": 30, "B": 45, "C": 25}}
|
||||
```
|
||||
|
||||
支持的图表类型:bar(柱状)、line(折线)、pie(饼图)、donut(环形)、stacked_bar(堆叠柱)。
|
||||
|
||||
图表规范:
|
||||
- 数据标签必须显示实际数值(不省略)
|
||||
- 轴标签用中文
|
||||
- 图例位置统一右下角
|
||||
- 色板取自设计系统的 6 色序列
|
||||
|
||||
---
|
||||
|
||||
## 已有PPT美化(模式C)
|
||||
|
||||
```bash
|
||||
python scripts/engine.py --improve input.pptx --output output.pptx [--design aura-dark]
|
||||
```
|
||||
|
||||
美化规则:
|
||||
- 统一背景色与主题色
|
||||
- 统一标题字体/字号/颜色
|
||||
- 统一正文排版(字体、行距、段落间距)
|
||||
- 添加页脚/页码(可选)
|
||||
- **内容零改动**:只动样式,不动文字内容
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q1:内容保真和AI生成冲突怎么办?**
|
||||
不冲突。规则是:**用户提供的内容一个字不改;AI补充的新内容明确标注**。汇报材料中,数据准确 > 文字华丽。
|
||||
|
||||
**Q2:支持日文PPT吗?**
|
||||
支持。日文字体(Yu Gothic)已纳入字体回退链;日文内容原样渲染。中日混合时遵循"日文与中文间不加空格"规则。
|
||||
|
||||
**Q3:输出文件能在WPS/PowerPoint里编辑吗?**
|
||||
能。python-pptx 输出标准 PPTX,所有元素都是原生矢量形状,可完全二次编辑——这正是"导出无损"的实现方式。
|
||||
|
||||
**Q4:需要网络/API吗?**
|
||||
不需要。渲染引擎是纯本地Python(python-pptx)。LLM负责规划,引擎负责渲染。客户机密材料可以放心处理。
|
||||
|
||||
**Q5:为什么不用AI生图做背景?**
|
||||
本技能采用"矢量形状 + 设计系统"路线:文字清晰、可编辑、无版权风险、渲染确定性。需要AI插画背景时,可用 image-generation 技能单独生成图片后插入。
|
||||
Binary file not shown.
@@ -0,0 +1,84 @@
|
||||
# aura-ppt 中文排版规范(Chinese Typography Rules)
|
||||
|
||||
> 灵感来源:WPS AI 中文理解(9.5分)的排版能力。目标是让生成的PPT在中文场景下"看起来就是中国人做的"。
|
||||
|
||||
## 1. 中英混排规则
|
||||
|
||||
| 场景 | 规则 | 正确 | 错误 |
|
||||
|---|---|---|---|
|
||||
| 中文与英文之间 | 加半角空格 | 使用 Python 开发 | 使用Python开发 |
|
||||
| 中文与数字之间 | 加半角空格 | 共 25 个项目 | 共25个项目 |
|
||||
| 中文与括号之间 | 不加空格 | 系统(SaaS 模式) | 系统 (SaaS 模式) |
|
||||
| 全角标点后 | 不加空格 | 项目,包括 | 项目, 包括 |
|
||||
|
||||
## 2. 标点规范
|
||||
|
||||
- 中文语境:全角标点 `,。:;!?()「」`
|
||||
- 引用书名:`《》`;强调引用:`「」` 或 `“”`
|
||||
- 英文/数字语境:半角标点 `,.:;!?()`
|
||||
- 列表序号:`1. 2. 3.` 后跟半角空格
|
||||
- **禁止**:中文文本中出现半角逗号、句号(`,` 不能写成 `,`)
|
||||
|
||||
## 3. 日文文本规则(对日业务)
|
||||
|
||||
- 日文与中文之间**不加空格**(区别于中英混排)
|
||||
- 日文假名(ひらがな/カタカナ)保持全角原样
|
||||
- 日文术语原样保留:`要件定義書`、`テスト仕様書`、`結合テスト`、`納品物`
|
||||
- 日文引号用「」;中文引号用「」或“”(两者视觉接近,混排时可统一)
|
||||
- 中日双语并列时建议:中文为主行,日文为副行(括号内或下方小字)
|
||||
|
||||
## 4. 数字与单位
|
||||
|
||||
- 千位分隔:`1,000,000`(四个零以上必须分隔)
|
||||
- 百分比:紧贴数字 `96.8%`(**不得四舍五入**,见内容保真规则)
|
||||
- 金额:中文用 `万元/亿元`,日文用 `万円/億円`
|
||||
- 日期:中文 `2024年3月15日`;日文 `2024年3月15日`(可互用)
|
||||
- 版本号:`v1.2.0` 保持原样
|
||||
|
||||
## 5. 字体回退链
|
||||
|
||||
```
|
||||
中文:微软雅黑 (Microsoft YaHei) → 思源黑体 → 系统默认
|
||||
日文:Yu Gothic (游ゴシック) → メイリオ (Meiryo) → 微软雅黑
|
||||
英文:Segoe UI → Arial → Helvetica
|
||||
```
|
||||
|
||||
**字体选择逻辑**:
|
||||
- 页面文本含中文字符 → 用中文字体(日文字体也能显示中文,但笔画风格不同)
|
||||
- 页面文本仅日文 → 用日文字体(Yu Gothic),注意日本汉字与中文字形差异(如 骨/骨、直/直)
|
||||
- 页面文本纯英文/数字 → 用英文字体
|
||||
- 同一文本框内中日混合 → 统一用中文字体(微软雅黑对日文假名支持良好)
|
||||
|
||||
## 6. 字号层级
|
||||
|
||||
| 层级 | 字号 | 字重 | 用途 |
|
||||
|---|---|---|---|
|
||||
| 封面主标题 | 40-44pt | Bold | 仅封面 |
|
||||
| 章节页标题 | 32-36pt | Bold | section 页 |
|
||||
| 页面标题 | 24-28pt | Bold | header |
|
||||
| 小标题 | 18-20pt | SemiBold | 卡片标题 |
|
||||
| 正文 | 15-17pt | Regular | 主体内容 |
|
||||
| 辅助文字 | 12-13pt | Regular | 图例、注释 |
|
||||
| 脚注 | 10-11pt | Regular | 备注、来源 |
|
||||
|
||||
## 7. 排版规则
|
||||
|
||||
- **行长限制**:每行不超过 28 个中文字符(约 14.5cm 宽度 @16pt)
|
||||
- **行距**:正文 1.3-1.5 倍;标题 1.1-1.2 倍
|
||||
- **段距**:段间 8-12pt(比行距略大,形成视觉分组)
|
||||
- **对齐**:中文正文用左对齐(禁两端对齐导致字距拉伸);标题左对齐
|
||||
- **避头尾**:行首禁标点(。,、;:)——用换行处理
|
||||
- **孤行控制**:禁止标题出现在页面最后一行
|
||||
|
||||
## 8. 中日双语排版示例
|
||||
|
||||
```
|
||||
正确示例(中文主行 + 日文副行):
|
||||
テスト工程の品質向上
|
||||
测试工程品质提升
|
||||
(テスト仕様書 23 件 → 欠陥検出率 96.8%)
|
||||
|
||||
错误示例:
|
||||
混合排版且无逻辑分组,中英日混杂在一个段落中,
|
||||
读者难以快速定位关键信息
|
||||
```
|
||||
@@ -0,0 +1,115 @@
|
||||
# aura-ppt 内容保真规则(Content Fidelity Rules)
|
||||
|
||||
> 灵感来源:7牛AI PPT"保持原文"模式(内容保真实测最优)。
|
||||
> **最高原则:AI只负责结构、排版、设计,绝不改写用户的数据与术语。**
|
||||
|
||||
## 为什么内容保真如此重要
|
||||
|
||||
对日外包场景的汇报材料通常包含:
|
||||
- 客户合同的精确数字(工数、单价、納期)
|
||||
- 测试结果数据(欠陥検出率、テストケース数)
|
||||
- 日本客户的专业术语(仕様書、結合テスト、納品物)
|
||||
- 法律/合规条款原文
|
||||
|
||||
**任何一处数字被"润色",都可能造成客户信任危机或合规问题。** 这正是我们把内容保真设为最高优先级的原因。
|
||||
|
||||
## 保真规则(按优先级排序)
|
||||
|
||||
### R1. 数字逐字符保留
|
||||
|
||||
用户提供的任何数值在最终PPT中必须**逐字符一致**:
|
||||
|
||||
```
|
||||
输入: 96.8% 输出: 96.8% ❌ 禁止: 约97% / 近97% / 96.8%左右
|
||||
输入: 23.6% 输出: 23.6% ❌ 禁止: 24% / 23% / 约24%
|
||||
输入: ¥5,000万 输出: ¥5,000万 ❌ 禁止: 5000万 / ¥5000万 / 数千万
|
||||
输入: 3.14 输出: 3.14 ❌ 禁止: 3 / 3.1 / π
|
||||
输入: 2024年3月 输出: 2024年3月 ❌ 禁止: 2024年Q1 / 2024年 / 去年3月
|
||||
```
|
||||
|
||||
### R2. 术语原样保留
|
||||
|
||||
专有名词、产品名、客户名、日文术语**不得意译、不得替换、不得加释义**:
|
||||
|
||||
```
|
||||
输入: テスト仕様書 输出: テスト仕様書 ❌ 禁止: 测试规格书 / 测试规格说明书
|
||||
输入: 要件定義 输出: 要件定義 ❌ 禁止: 需求定义(除非用户明确要求双语)
|
||||
输入: [顧客A] 输出: [顧客A] ❌ 禁止: 客户A / 甲方 / 某客户
|
||||
输入: SAP ERP 输出: SAP ERP ❌ 禁止: SAP / 企业资源计划系统
|
||||
```
|
||||
|
||||
### R3. 引用与条款原文保留
|
||||
|
||||
用户提供的引用、条款、数据来源标注,**一字不改**,包括标点:
|
||||
|
||||
```
|
||||
输入: 「納品物とは、要件定義書、設計書、テスト仕様書及びプログラム一式をいう。」
|
||||
输出: 必须逐字呈现(含「」与日文标点)
|
||||
```
|
||||
|
||||
### R4. 结构可重组,内容不可改写
|
||||
|
||||
允许:
|
||||
- ✅ 重新分页、分组、调整层级(把5个要点分到2页)
|
||||
- ✅ 提炼副标题、加导航性文字("本章节将说明…")
|
||||
- ✅ 调整顺序(按逻辑重排用户内容)
|
||||
|
||||
禁止:
|
||||
- ❌ 改写用户句子含义(保持原意是底线)
|
||||
- ❌ 合并/拆分用户数据点
|
||||
- ❌ 为"更简洁"删除用户的限定词(如"约"、"可能"、"部分")
|
||||
- ❌ 补充用户未提供且无依据的数据
|
||||
|
||||
### R5. AI补充内容必须区分
|
||||
|
||||
LLM为连接上下文而生成的新内容(过渡句、总结、建议),必须与用户内容可区分:
|
||||
- 方式1:加 `[AI补充]` 前缀(用户可见时)
|
||||
- 方式2:放入演讲者备注(notes字段),正文只放用户内容
|
||||
- 方式3:内容基调明显不同(如"本章总结"这类导航句)
|
||||
|
||||
**推荐方式2**:正文保真,AI的加工藏在备注里。
|
||||
|
||||
## 实施机制
|
||||
|
||||
### 渲染引擎内置检查(--verify)
|
||||
|
||||
```
|
||||
验证流程:
|
||||
1. 从 plan.json 提取所有源数字(正则: -?\d+[\d,]*\.?\d*\s*[%%]?)
|
||||
2. 从输出PPTX提取全部文本
|
||||
3. 逐一比对:每个源数字必须出现在输出文本中
|
||||
4. 术语检查:含中日文字符的长片段(≥6字符)必须原样出现
|
||||
5. 任何缺失 → 验证失败 → 返回缺失清单
|
||||
|
||||
运行:
|
||||
python scripts/engine.py --verify output.pptx --plan plan.json
|
||||
```
|
||||
|
||||
### 流程保障
|
||||
|
||||
1. **plan.json 是唯一内容源**:渲染引擎只读 plan.json,不产生任何内容
|
||||
2. **LLM只负责规划**:LLM把用户内容结构化进 plan.json,不改写内容本身
|
||||
3. **渲染零改写**:engine.py 是纯渲染器,代码中不存在任何文本改写逻辑
|
||||
4. **生成后必验证**:每次生成自动跑 --verify,偏差立即报告
|
||||
|
||||
## 常见违规与正确做法
|
||||
|
||||
| 场景 | 错误(违规) | 正确 |
|
||||
|---|---|---|
|
||||
| 数字美化 | 32个数据点"96.8%"→"约97%" | 96.8% 原样 |
|
||||
| 术语翻译 | 要件定義→需求定义 | 要件定義(除非用户要求双语) |
|
||||
| 删除限定 | "部分系统不支持"→"系统不支持" | 保留"部分" |
|
||||
| 补数据 | 用户没说→AI加"市占率30%" | 不添加无依据数据 |
|
||||
| 简化条款 | 合并两条合同条款 | 逐字呈现 |
|
||||
|
||||
## 与"AI辅助"的边界
|
||||
|
||||
内容保真 ≠ 拒绝一切AI加工。AI可以:
|
||||
- ✅ 优化组织(分页、分组、加导航)
|
||||
- ✅ 补充演讲者备注(notes)
|
||||
- ✅ 生成目录、章节标题、过渡句(明确为AI补充)
|
||||
- ✅ 设计排版(字色、字号、图表类型)
|
||||
|
||||
AI不可以:
|
||||
- ❌ 改用户的数据、术语、引用、限定词
|
||||
- ❌ 编造用户未提供的信息
|
||||
@@ -0,0 +1,296 @@
|
||||
# aura-ppt 设计系统规范
|
||||
|
||||
> 灵感来源:Gamma 设计质量(9.2分)。内置多套深度定制的设计系统。
|
||||
|
||||
## 设计系统清单
|
||||
|
||||
| 系统名 | 风格 | 适用场景 |
|
||||
|---|---|---|
|
||||
| `aura-dark`(默认) | 深色高级(深蓝黑+电光青) | 科技感、对外提案、产品发布 |
|
||||
| `business-report` | 浅色商务(白底+火焰蓝+商务红) | **商务报告、管理层汇报、咨询风格** |
|
||||
| `report-jp` | 日式商务报告(法人蓝+浅天蓝卡片+Meiryo UI) | **日本客户汇报、本部報告会、对日业务** |
|
||||
|
||||
---
|
||||
|
||||
## 0. 全局视觉规范(所有设计系统共用)
|
||||
|
||||
### 卡片视觉
|
||||
- **柔和投影**:所有卡片容器自动带外投影(blur 90000 / dist 45000 / 25%透明度深灰),立体悬浮感
|
||||
- **圆角**:radius 8%(圆角矩形 adjustment)
|
||||
- 卡片左色条(随图表色板序号变色)
|
||||
|
||||
### 图表视觉
|
||||
- **渐变柱体**:柱状图垂直渐变(顶部提亮45% → 底部原色),模拟立体感(XML gradFill)
|
||||
- **协调色板**:每系统6色光谱序列(冷→暖过渡),相邻柱区分度清晰
|
||||
- 水平网格线(3条辅助)+ 数值标签 + 类别标签(防截断)
|
||||
|
||||
### 排版层级(typography)
|
||||
统一语义化字号,修改只需改设计系统 `typography` 字典:
|
||||
|
||||
| 语义 | 字号 | 用途 |
|
||||
|---|---|---|
|
||||
| cover_title | 38 | 封面主标题 |
|
||||
| cover_subtitle | 18 | 封面副标题 |
|
||||
| section_title | 34 | 章节页标题 |
|
||||
| page_title | 22 | 页面标题 |
|
||||
| card_title | 16 | 卡片标题 |
|
||||
| body | 15 | 正文 |
|
||||
| body_small | 13 | 双栏/卡片内文字 |
|
||||
| table_head / table_cell | 13 / 12 | 表格 |
|
||||
| chart_label / chart_cat | 12 / 9 | 图表标签 |
|
||||
| footer / notes | 9 / 10 | 页脚/辅助 |
|
||||
|
||||
### 页面结构装饰
|
||||
- report-jp 页眉装饰线(主色细线 + 左端1.6in锚点块)
|
||||
- 章节页装饰(左侧竖条 + 底部横线)
|
||||
- 内容区背景层(模板模式:body 区极浅色分区,与母版背景区分)
|
||||
|
||||
---
|
||||
|
||||
## 3. report-jp 设计系统(日式商务报告)
|
||||
|
||||
> 参考:日本本部报告会资料(010_【本部長報告会】2026年7月度_AI推進部.pptx)。
|
||||
> 法人蓝 #0B3D91 + 浅天蓝卡片 #B7DEE8 + Meiryo UI 字体,日企报告标准样式。
|
||||
|
||||
### 设计令牌
|
||||
|
||||
| 令牌 | 色值 | 用途 |
|
||||
|---|---|---|
|
||||
| `page` | **10.83 × 7.5 in**(非标准比例) | 页面画布 |
|
||||
| `bg` | `#FFFFFF` | 页面背景(纯白) |
|
||||
| `bg2` | `#F5F7FB` | 交替行/次级背景 |
|
||||
| `panel` | `#E8EAF6` | 面板(浅蓝灰) |
|
||||
| `panel2` | `#DDE3F2` | 面板暗 |
|
||||
| `card_fill` | `#B7DEE8` | **卡片填充(浅天蓝)** |
|
||||
| `line` | `#C9D4E8` | 分隔线 |
|
||||
| `text` | `#1A1A1A` | 正文(黑色,日企惯例) |
|
||||
| `text_dim` | `#556070` | 辅助文本 |
|
||||
| `primary` | `#0B3D91` | 主色:**法人蓝** |
|
||||
| `secondary` | `#1976D2` | 次级:Material蓝 |
|
||||
| `accent` | `#C00000` | 强调红(警示/机密) |
|
||||
| `success` | `#2E7D32` | 正向(绿) |
|
||||
| `danger` | `#C00000` | 警示(红) |
|
||||
|
||||
### 图表色板(协调渐变:蓝→青→绿→金→橙)
|
||||
|
||||
```
|
||||
#0B3D91 深法人蓝 → #2E74B5 中蓝 → #0099CC 青蓝 → #26A69A 青绿 → #F2B134 琥珀金 → #EF6C00 橙
|
||||
```
|
||||
|
||||
**渐变柱体**:柱状图使用垂直渐变(顶部提亮45% → 底部原色),模拟立体感。
|
||||
|
||||
### 字体
|
||||
|
||||
```
|
||||
日文:Meiryo UI(日企标准,微软雅黑日文版)
|
||||
中文:微软雅黑 | 英文:Segoe UI
|
||||
```
|
||||
|
||||
### 特色组件
|
||||
|
||||
1. **日式页眉**:页面 plan 支持 `section_no`(如 "1"),渲染为 `1. 标题(x/y)` 编号格式
|
||||
2. **机密标记**:plan 顶层或页面支持 `confidential`(如「機密情報」「社外秘」),右上角红色标签
|
||||
3. **流程图**:`flowchart` 页面类型,浅天蓝卡片 + 法人蓝标题 + 箭头(支持横向流程与泳道两种模式)
|
||||
4. **卡片式布局**:参考PPT的浅天蓝卡片(#B7DEE8)作为内容容器
|
||||
|
||||
### 风格要点
|
||||
- 法人蓝(#0B3D91)是日本企业标准的"企业蓝",庄重、权威
|
||||
- 正文用黑色(日企报告惯例,不同于中式报告的深灰)
|
||||
- 卡片用浅天蓝而非浅灰——日式报告特有的清爽感
|
||||
- 标题字号相对 16:9 版本略小(22pt),因为页面更窄(10.83in)
|
||||
- 机密标记红色标签是日企报告标配(機密情報/社外秘/取扱注意)
|
||||
|
||||
---
|
||||
|
||||
## 1. business-report 设计系统(商务报告风格 · 火焰蓝)
|
||||
|
||||
> 灵感来源:麦肯锡/BCG 咨询报告、PowerPoint 商务模板。浅色底、克制的信息层次、清晰可打印。
|
||||
> 主色为**火焰蓝**(消防救援标志蓝 `#1E50A2`)——庄重、专业、辨识度高。
|
||||
|
||||
### 设计令牌
|
||||
|
||||
| 令牌 | 色值 | 用途 |
|
||||
|---|---|---|
|
||||
| `bg` | `#FFFFFF` | 页面背景(纯白) |
|
||||
| `bg2` | `#F5F6FA` | 交替行/次级背景 |
|
||||
| `panel` | `#F4F6FA` | 卡片/面板背景(浅灰) |
|
||||
| `panel2` | `#ECEFF5` | 面板暗色变体 |
|
||||
| `line` | `#D8DEE9` | 分隔线/边框(浅灰) |
|
||||
| `text` | `#1F2937` | 主文本(深灰) |
|
||||
| `text_dim` | `#6B7280` | 辅助文本(中灰) |
|
||||
| `primary` | `#1E50A2` | 主色:**火焰蓝**(标题/强调) |
|
||||
| `accent` | `#C00000` | 强调色:商务红(关键数字/警示) |
|
||||
| `secondary` | `#3B78C4` | 次级:亮蓝 |
|
||||
| `success` | `#228B22` | 正向(墨绿) |
|
||||
| `danger` | `#C00000` | 警示(红) |
|
||||
|
||||
### 图表色板(火焰蓝系列)
|
||||
|
||||
```
|
||||
#1E50A2 火焰蓝 → #3B78C4 亮蓝 → #C00000 红 → #ED7D31 橙 → #70AD47 绿 → #7030A0 紫
|
||||
```
|
||||
|
||||
### 风格要点
|
||||
- **白色底 + 火焰蓝标题**:商务报告经典组合,庄重专业,打印友好
|
||||
- **商务红点睛**:与火焰蓝形成经典"蓝+红"组合,只用于关键数字、强调语句、风险标识
|
||||
- **浅灰卡片**:F4F6FA 面板 + D8DEE9 细边框,层次靠灰阶而非色块
|
||||
- **克制的装饰**:无光晕、无渐变背景,纯网格对齐——信息优先
|
||||
- **表格**:表头火焰蓝底白字(primary),交替行 bg2/panel
|
||||
- **pill 文字**:浅色系统下 pill 上文字用白色(深色系统用底色)
|
||||
|
||||
---
|
||||
|
||||
## 2. aura-dark 设计系统(深色高级)
|
||||
|
||||
### 设计令牌(Design Tokens)
|
||||
|
||||
| 令牌 | 色值 | 用途 |
|
||||
|---|---|---|
|
||||
| `bg` | `#0B0E14` | 页面背景(深蓝黑) |
|
||||
| `bg2` | `#11151F` | 交替行/次级背景 |
|
||||
| `panel` | `#171D2B` | 卡片/面板背景 |
|
||||
| `panel2` | `#121722` | 卡片暗色变体 |
|
||||
| `line` | `#273042` | 分隔线/边框 |
|
||||
| `text` | `#F0F2F5` | 主文本(近白) |
|
||||
| `text_dim` | `#8A93A3` | 辅助文本(灰蓝) |
|
||||
| `primary` | `#2ECCFF` | 主色:电光青(强调/标题/链接) |
|
||||
| `accent` | `#FFC94D` | 强调色:琥珀金(里程碑/高亮) |
|
||||
| `secondary` | `#A78BFA` | 辅助色:霓虹紫(次强调) |
|
||||
| `success` | `#3DD68C` | 成功/正向(绿色) |
|
||||
| `danger` | `#FF6B6B` | 警示/风险(红色) |
|
||||
|
||||
### 图表色板(6色序列)
|
||||
|
||||
```
|
||||
#2ECCFF → #FFC94D → #A78BFA → #3DD68C → #FF8A65 → #5C9DFF
|
||||
```
|
||||
|
||||
### 度量
|
||||
|
||||
| 令牌 | 值 | 说明 |
|
||||
|---|---|---|
|
||||
| `margin` | 0.55 in | 页面四边距 |
|
||||
| `grid` | 0.2 in | 垂直节奏网格 |
|
||||
| `radius` | 6% | 圆角卡片弧度 |
|
||||
| `content_w` | 12.23 in | 内容区宽度(16:9) |
|
||||
| `page` | 13.333 × 7.5 in | 16:9 画布 |
|
||||
|
||||
### 字体
|
||||
|
||||
```
|
||||
标题: 微软雅黑 Bold(日文场景: Yu Gothic Bold)
|
||||
正文: 微软雅黑 Regular(日文场景: Yu Gothic)
|
||||
英文/数字: Segoe UI
|
||||
```
|
||||
|
||||
## 2. 组件库
|
||||
|
||||
### 2.1 封面(cover)
|
||||
|
||||
```
|
||||
[左上光晕装饰] [kicker 标签: 14pt 主色]
|
||||
[主标题: 42pt Bold 近白]
|
||||
[副标题: 18pt 灰蓝]
|
||||
[内容要点: 15pt 灰蓝, 行距1.5]
|
||||
[主色横线: 2.8in × 2.5pt]
|
||||
[底部深色条: 0.6in 高] [备注/日期: 11pt]
|
||||
```
|
||||
|
||||
### 2.2 目录(toc)
|
||||
|
||||
```
|
||||
[页头: 左侧主色竖条 + 标题 + 分隔线]
|
||||
[内容区: 2列网格, 卡片 5.9×1.35in]
|
||||
每卡片: [编号 28pt 主色] + [条目 16pt Bold]
|
||||
```
|
||||
|
||||
### 2.3 章节页(section)
|
||||
|
||||
```
|
||||
[kicker: "SECTION 01" 14pt 主色]
|
||||
[标题: 36pt Bold]
|
||||
[内容: 16pt 灰蓝]
|
||||
留白为主,营造节奏感(章节间呼吸)
|
||||
```
|
||||
|
||||
### 2.4 内容页(content)
|
||||
|
||||
```
|
||||
[页头: 标题 26pt Bold + 可选副标题 + 分隔线]
|
||||
[正文: 16pt, 行距1.4, 段距8pt]
|
||||
[要点: 每个要点一行, 支持 **加粗** 强调]
|
||||
[页脚: 页码 10pt 右下角]
|
||||
```
|
||||
|
||||
### 2.5 双栏页(two_column)
|
||||
|
||||
```
|
||||
[两卡片 5.9in 宽, 间隔0.4in]
|
||||
每卡片: [栏目标题 18pt Bold 主色] + [内容 14pt 行距1.4]
|
||||
```
|
||||
|
||||
### 2.6 表格页(table)
|
||||
|
||||
```
|
||||
[表头: 深色背景 + 主色文字 13pt Bold, 居中]
|
||||
[交替行: bg2/panel]
|
||||
[单元格: 12pt, 垂直居中]
|
||||
[行高: 0.62in, 表头高0.55in]
|
||||
```
|
||||
|
||||
### 2.7 图表页(chart)
|
||||
|
||||
```
|
||||
[图表卡片: panel背景, 圆角]
|
||||
[图例: 右上角横排, 色块+标签 11pt]
|
||||
[数据标签: 11pt Bold 显示实际值(不省略)]
|
||||
[柱状图: 圆角柱, 2倍柱宽间距]
|
||||
[折线图: 主色3pt线 + 数据点圆点]
|
||||
[饼图: 扇区 + 数值标签]
|
||||
```
|
||||
|
||||
### 2.8 时间线(timeline)
|
||||
|
||||
```
|
||||
[水平主线: 2pt 灰蓝]
|
||||
[节点: 主色圆点 0.16in]
|
||||
[阶段标题: 13pt Bold 主色]
|
||||
[描述: 10pt 灰蓝, 最多2行]
|
||||
```
|
||||
|
||||
### 2.9 引言页(quote)
|
||||
|
||||
```
|
||||
[左侧主色竖条 0.09×2.2in]
|
||||
[引言: 26pt Bold, 行距1.4]
|
||||
[出处: 13pt Bold 琥珀金]
|
||||
```
|
||||
|
||||
### 2.10 结束页(end)
|
||||
|
||||
```
|
||||
[标题: 40pt Bold "谢谢" / "Thank you"]
|
||||
[内容: 16pt 灰蓝]
|
||||
[主色横线: 2.8in × 2.5pt]
|
||||
```
|
||||
|
||||
## 3. 布局原则
|
||||
|
||||
1. **留白优先**:每页元素不超过 6 个视觉组,大量留白营造高级感
|
||||
2. **对齐网格**:所有元素对齐 0.2in 网格,杜绝随意摆放
|
||||
3. **主色克制**:主色只用于强调(标题条、关键数字、链接),大面积用中性色
|
||||
4. **视觉节奏**:章节页制造呼吸感,内容页信息密度适中
|
||||
5. **统一性**:同类型页面必须使用相同组件与布局(可预测性)
|
||||
|
||||
## 4. 对比度与可读性
|
||||
|
||||
- 正文对比度:`#F0F2F5` on `#0B0E14`(≈15.8:1,远超 WCAG AA)
|
||||
- 主色文本 on 深底:`#2ECCFF` on `#0B0E14`(≈9.1:1)
|
||||
- 辅助文本 on 深底:`#8A93A3` on `#0B0E14`(≈4.6:1,满足 AA)
|
||||
- 琥珀金 on 深底:`#FFC94D` on `#0B0E14`(≈11.5:1)
|
||||
- **禁止**:主色/琥珀金作为大面积背景(只用于点缀与强调)
|
||||
|
||||
## 5. 扩展设计系统
|
||||
|
||||
新增设计系统时,在 `engine.py` 的 `DesignSystem` 类中创建同名子类并重写令牌。
|
||||
plan.json 的 `design` 字段引用系统名(默认 `aura-dark`)。
|
||||
@@ -0,0 +1,1870 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
aura-ppt 渲染引擎
|
||||
综合 AI PPT 工具优点:Gamma 设计感 / WPS 中文排版 / 7牛 内容保真 / Beautiful.ai 无损导出
|
||||
用法:
|
||||
python engine.py --plan plan.json --output out.pptx [--design aura-dark]
|
||||
python engine.py --improve input.pptx --output out.pptx [--design aura-dark]
|
||||
python engine.py --verify output.pptx [--plan plan.json]
|
||||
"""
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt, Emu
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.oxml.ns import qn
|
||||
except ImportError:
|
||||
sys.exit("[aura-ppt] 缺少依赖: pip install python-pptx")
|
||||
|
||||
# ---------------------------------------------------------------- 设计系统
|
||||
class DesignSystem:
|
||||
"""aura-dark: 品牌深色设计系统(默认,深度定制)"""
|
||||
|
||||
def __init__(self):
|
||||
self.name = "aura-dark"
|
||||
self.dark = True # 是否为深色系
|
||||
self.glow = True # 是否绘制左上光晕装饰
|
||||
self.bg = RGBColor(0x0B, 0x0E, 0x14)
|
||||
self.bg2 = RGBColor(0x11, 0x15, 0x1F)
|
||||
self.panel = RGBColor(0x17, 0x1D, 0x2B)
|
||||
self.panel2 = RGBColor(0x12, 0x17, 0x22)
|
||||
self.line = RGBColor(0x27, 0x30, 0x42)
|
||||
self.text = RGBColor(0xF0, 0xF2, 0xF5)
|
||||
self.text_dim = RGBColor(0x8A, 0x93, 0xA3)
|
||||
self.primary = RGBColor(0x2E, 0xCC, 0xFF) # 电光青
|
||||
self.accent = RGBColor(0xFF, 0xC9, 0x4D) # 琥珀金
|
||||
self.secondary = RGBColor(0xA7, 0x8B, 0xFA) # 霓虹紫
|
||||
self.success = RGBColor(0x3D, 0xD6, 0x8C)
|
||||
self.danger = RGBColor(0xFF, 0x6B, 0x6B)
|
||||
# 卡片填充(深色系统的流程图卡片色)
|
||||
self.card_fill = RGBColor(0x1F, 0x2A, 0x3D)
|
||||
# 图表色板(协调渐变色系:青→蓝→紫→绿→橙→金)
|
||||
self.chart_colors = [
|
||||
RGBColor(0x2E, 0xCC, 0xFF), RGBColor(0x5C, 0x9D, 0xFF),
|
||||
RGBColor(0xA7, 0x8B, 0xFA), RGBColor(0x3D, 0xD6, 0x8C),
|
||||
RGBColor(0xFF, 0x8A, 0x65), RGBColor(0xFF, 0xC9, 0x4D),
|
||||
]
|
||||
# 字体回退链:中 → 日 → 英
|
||||
self.font_cn = "Microsoft YaHei"
|
||||
self.font_jp = "Yu Gothic"
|
||||
self.font_en = "Segoe UI"
|
||||
self.margin = Inches(0.55)
|
||||
self.page_w = Inches(13.333)
|
||||
self.page_h = Inches(7.5)
|
||||
# 区域布局(inches,相对页面)
|
||||
# 模板对齐模式:布局坐标与公司PPT母版一致
|
||||
self.layout = {
|
||||
"header_top": 0.46, # 页眉标题顶部
|
||||
"header_h": 0.65, # 页眉标题高度
|
||||
"subtitle_top": 1.05, # 副标题顶部
|
||||
"divider_y": 1.48, # 分隔线y
|
||||
"body_top": 1.85, # 正文内容顶部
|
||||
"body_bottom": 6.35, # 正文内容底部(footer上方)
|
||||
"footer_top": 7.08, # 页脚文字顶部
|
||||
"page_num_y": 7.16, # 页码顶部
|
||||
"page_num_x": 0.9, # 页码距右侧距离
|
||||
}
|
||||
# 统一排版层级(字号语义化,保证一致性)
|
||||
self.typography = {
|
||||
"cover_title": 38, # 封面主标题
|
||||
"cover_subtitle": 18, # 封面副标题
|
||||
"cover_body": 15, # 封面内容
|
||||
"section_title": 34, # 章节页标题
|
||||
"section_kicker": 14, # 章节页kicker
|
||||
"page_title": 22, # 页面标题
|
||||
"page_subtitle": 12, # 页面副标题
|
||||
"card_title": 16, # 卡片标题
|
||||
"body": 15, # 正文
|
||||
"body_small": 13, # 小正文(双栏/卡片内)
|
||||
"table_head": 13, # 表头
|
||||
"table_cell": 12, # 表格单元格
|
||||
"chart_label": 12, # 图表数值标签
|
||||
"chart_cat": 9, # 图表类别标签
|
||||
"timeline_title": 13, # 时间线阶段标题
|
||||
"timeline_desc": 10, # 时间线描述
|
||||
"footer": 9, # 页脚
|
||||
"notes": 10, # 备注/辅助
|
||||
}
|
||||
|
||||
def font_for(self, text):
|
||||
"""统一字体:默认全部使用中文字体(微软雅黑,含拉丁字形,混排统一)
|
||||
report-jp 日文场景使用日文字体"""
|
||||
has_jp = re.search(r'[\u3040-\u30ff\u31f0-\u31ff]', text)
|
||||
if self.name == "report-jp" and has_jp:
|
||||
return self.font_jp
|
||||
return self.font_cn
|
||||
|
||||
|
||||
class BusinessReport(DesignSystem):
|
||||
"""business-report: 商务报告风格(浅色 · 咨询报告质感)
|
||||
灵感来源:麦肯锡/四大咨询报告、PowerPoint 商务模板。
|
||||
特点:白色底、火焰蓝主色、商务红强调、清晰表格、克制的信息层次。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "business-report"
|
||||
self.dark = False
|
||||
self.glow = False # 浅色风格不画光晕
|
||||
self.bg = RGBColor(0xFF, 0xFF, 0xFF) # 纯白底
|
||||
self.bg2 = RGBColor(0xF5, 0xF6, 0xFA) # 极浅灰(交替行)
|
||||
self.panel = RGBColor(0xF4, 0xF6, 0xFA) # 浅灰面板
|
||||
self.panel2 = RGBColor(0xEC, 0xEF, 0xF5) # 面板暗
|
||||
self.line = RGBColor(0xD8, 0xDE, 0xE9) # 浅灰分隔线
|
||||
self.text = RGBColor(0x1F, 0x29, 0x37) # 深灰文本
|
||||
self.text_dim = RGBColor(0x6B, 0x72, 0x80) # 辅助灰
|
||||
self.primary = RGBColor(0x1E, 0x50, 0xA2) # 火焰蓝(主色)
|
||||
self.accent = RGBColor(0xC0, 0x00, 0x00) # 商务红(强调)
|
||||
self.secondary = RGBColor(0x3B, 0x78, 0xC4) # 亮蓝(次级)
|
||||
self.success = RGBColor(0x22, 0x8B, 0x22) # 墨绿
|
||||
self.danger = RGBColor(0xC0, 0x00, 0x00) # 红
|
||||
# 卡片填充(浅蓝灰,用于流程图卡片)
|
||||
self.card_fill = RGBColor(0xE8, 0xEE, 0xF7)
|
||||
# 图表色板(火焰蓝系列)
|
||||
self.chart_colors = [
|
||||
RGBColor(0x1E, 0x50, 0xA2), RGBColor(0x3B, 0x78, 0xC4),
|
||||
RGBColor(0xC0, 0x00, 0x00), RGBColor(0xED, 0x7D, 0x31),
|
||||
RGBColor(0x70, 0xAD, 0x47), RGBColor(0x70, 0x30, 0xA0),
|
||||
]
|
||||
|
||||
|
||||
class ReportJP(DesignSystem):
|
||||
"""report-jp: 日式商务报告风格
|
||||
参考:日本本部报告会资料(法人蓝 #0B3D91 + 浅天蓝卡片 #B7DEE8 + Meiryo UI)。
|
||||
特点:非标准页面比例(10.83x7.5in)、章节编号页眉、机密标记、卡片+流程图布局。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "report-jp"
|
||||
self.dark = False
|
||||
self.glow = False
|
||||
self.page_w = Inches(10.83) # 参考PPT比例
|
||||
self.page_h = Inches(7.5)
|
||||
self.bg = RGBColor(0xFF, 0xFF, 0xFF) # 纯白底
|
||||
self.bg2 = RGBColor(0xF5, 0xF7, 0xFB) # 极浅蓝灰(交替行)
|
||||
self.panel = RGBColor(0xE8, 0xEA, 0xF6) # 浅蓝灰面板
|
||||
self.panel2 = RGBColor(0xDD, 0xE3, 0xF2) # 面板暗
|
||||
self.line = RGBColor(0xC9, 0xD4, 0xE8) # 浅蓝分隔线
|
||||
self.text = RGBColor(0x1A, 0x1A, 0x1A) # 黑色正文(日企惯例)
|
||||
self.text_dim = RGBColor(0x55, 0x60, 0x70) # 辅助灰
|
||||
self.primary = RGBColor(0x0B, 0x3D, 0x91) # 法人蓝(主色)
|
||||
self.accent = RGBColor(0xC0, 0x00, 0x00) # 强调红(警示)
|
||||
self.secondary = RGBColor(0x19, 0x76, 0xD2) # Material蓝(次级)
|
||||
self.success = RGBColor(0x2E, 0x7D, 0x32) # 绿
|
||||
self.danger = RGBColor(0xC0, 0x00, 0x00) # 红
|
||||
# 卡片填充浅天蓝(参考PPT主填充)
|
||||
self.card_fill = RGBColor(0xB7, 0xDE, 0xE8)
|
||||
# 图表色板(协调渐变色系:蓝→青→绿→金→橙,冷暖过渡)
|
||||
self.chart_colors = [
|
||||
RGBColor(0x0B, 0x3D, 0x91), # 深法人蓝(主)
|
||||
RGBColor(0x2E, 0x74, 0xB5), # 中蓝
|
||||
RGBColor(0x00, 0x99, 0xCC), # 青蓝
|
||||
RGBColor(0x26, 0xA6, 0x9A), # 青绿
|
||||
RGBColor(0xF2, 0xB1, 0x34), # 琥珀金
|
||||
RGBColor(0xEF, 0x6C, 0x00), # 橙
|
||||
]
|
||||
# 字体:日企标准 Meiryo UI(对日业务核心字体)
|
||||
self.font_cn = "Microsoft YaHei"
|
||||
self.font_jp = "Meiryo UI"
|
||||
self.font_en = "Segoe UI"
|
||||
# 区域布局:与公司PPT母版对齐(参考 010_【本部長報告会】)
|
||||
# 模板规格:章节行 y0.32(20pt) / 主题行 y0.88(16pt) / 卡片标题 y1.74
|
||||
# 页脚条 y6.4~7.1 / 页码 y7.16 右下角 / 安全区 x0.38~10.61
|
||||
self.layout = {
|
||||
"header_top": 0.32, # 章节行顶部(模板 y0.32,20pt)
|
||||
"header_h": 0.45, # 章节行高度
|
||||
"subtitle_top": 0.88, # 主题行顶部(模板 y0.88,16pt)
|
||||
"divider_y": 1.55, # 分隔线y(不用,模板无分隔线)
|
||||
"body_top": 1.60, # 正文内容顶部(模板卡片标题 y1.74 上方留白)
|
||||
"body_bottom": 6.30, # 正文内容底部(模板页脚条 y6.4)
|
||||
"footer_top": 7.05, # 页脚文字顶部
|
||||
"page_num_y": 7.16, # 页码顶部(模板页码 y7.16~7.57)
|
||||
"page_num_x": 0.57, # 页码距右侧距离(模板页码 x10.26)
|
||||
}
|
||||
|
||||
def font_for(self, text):
|
||||
"""日式报告统一 Meiryo UI(含拉丁字形,日中文混排统一)"""
|
||||
return self.font_jp
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# 项目五色板设计系统(2026-08-24 PPT自动生成Agent 移植扩展)
|
||||
# 继承 business-report 浅色骨架,按产品五色板覆写主次色/卡片/图表色
|
||||
# 对应 PRD US-2.5:blue/green/purple/orange/gray
|
||||
# ----------------------------------------------------------------
|
||||
class _CorpPalette(BusinessReport):
|
||||
"""五色板公共骨架:浅色商务风,子类只换颜色令牌"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = self._palette_name()
|
||||
self._apply_palette()
|
||||
|
||||
def _palette_name(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _apply_palette(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class CorpBlue(_CorpPalette):
|
||||
"""corp-blue: 商务蓝(产品默认配色,对应品牌色 #1565C0/#2196F3)"""
|
||||
|
||||
def _palette_name(self):
|
||||
return "corp-blue"
|
||||
|
||||
def _apply_palette(self):
|
||||
self.primary = RGBColor(0x15, 0x65, 0xC0)
|
||||
self.secondary = RGBColor(0x21, 0x96, 0xF3)
|
||||
self.card_fill = RGBColor(0xE3, 0xF2, 0xFD)
|
||||
self.panel = RGBColor(0xF0, 0xF6, 0xFC)
|
||||
self.panel2 = RGBColor(0xE3, 0xF2, 0xFD)
|
||||
self.chart_colors = [
|
||||
RGBColor(0x15, 0x65, 0xC0), RGBColor(0x21, 0x96, 0xF3),
|
||||
RGBColor(0x26, 0xC6, 0xDA), RGBColor(0x66, 0xBB, 0x6A),
|
||||
RGBColor(0xFF, 0xA7, 0x26), RGBColor(0xAB, 0x47, 0xBC),
|
||||
]
|
||||
|
||||
|
||||
class CorpGreen(_CorpPalette):
|
||||
"""corp-green: 清新绿(#34A853/#81C995)"""
|
||||
|
||||
def _palette_name(self):
|
||||
return "corp-green"
|
||||
|
||||
def _apply_palette(self):
|
||||
self.primary = RGBColor(0x2E, 0x7D, 0x32)
|
||||
self.secondary = RGBColor(0x34, 0xA8, 0x53)
|
||||
self.card_fill = RGBColor(0xE8, 0xF5, 0xE9)
|
||||
self.panel = RGBColor(0xF1, 0xF8, 0xF2)
|
||||
self.panel2 = RGBColor(0xE8, 0xF5, 0xE9)
|
||||
self.chart_colors = [
|
||||
RGBColor(0x2E, 0x7D, 0x32), RGBColor(0x34, 0xA8, 0x53),
|
||||
RGBColor(0x81, 0xC9, 0x95), RGBColor(0x00, 0x89, 0x7B),
|
||||
RGBColor(0xF9, 0xA8, 0x25), RGBColor(0x5C, 0x6B, 0xC0),
|
||||
]
|
||||
|
||||
|
||||
class CorpPurple(_CorpPalette):
|
||||
"""corp-purple: 科技紫(#7C3AED/#A78BFA)"""
|
||||
|
||||
def _palette_name(self):
|
||||
return "corp-purple"
|
||||
|
||||
def _apply_palette(self):
|
||||
self.primary = RGBColor(0x5B, 0x21, 0xB6)
|
||||
self.secondary = RGBColor(0x7C, 0x3A, 0xED)
|
||||
self.card_fill = RGBColor(0xED, 0xE9, 0xFE)
|
||||
self.panel = RGBColor(0xF5, 0xF3, 0xFF)
|
||||
self.panel2 = RGBColor(0xED, 0xE9, 0xFE)
|
||||
self.chart_colors = [
|
||||
RGBColor(0x5B, 0x21, 0xB6), RGBColor(0x7C, 0x3A, 0xED),
|
||||
RGBColor(0xA7, 0x8B, 0xFA), RGBColor(0xEC, 0x48, 0x99),
|
||||
RGBColor(0xF5, 0x9E, 0x0B), RGBColor(0x0E, 0xA5, 0xE9),
|
||||
]
|
||||
|
||||
|
||||
class CorpOrange(_CorpPalette):
|
||||
"""corp-orange: 活力橙(#F59E0B/#FBBF24)"""
|
||||
|
||||
def _palette_name(self):
|
||||
return "corp-orange"
|
||||
|
||||
def _apply_palette(self):
|
||||
self.primary = RGBColor(0xD9, 0x77, 0x06)
|
||||
self.secondary = RGBColor(0xF5, 0x9E, 0x0B)
|
||||
self.card_fill = RGBColor(0xFE, 0xF3, 0xC7)
|
||||
self.panel = RGBColor(0xFF, 0xFB, 0xEB)
|
||||
self.panel2 = RGBColor(0xFE, 0xF3, 0xC7)
|
||||
self.chart_colors = [
|
||||
RGBColor(0xD9, 0x77, 0x06), RGBColor(0xF5, 0x9E, 0x0B),
|
||||
RGBColor(0xFB, 0xBF, 0x24), RGBColor(0xEF, 0x44, 0x44),
|
||||
RGBColor(0x10, 0xB9, 0x81), RGBColor(0x63, 0x66, 0xF1),
|
||||
]
|
||||
|
||||
|
||||
class CorpGray(_CorpPalette):
|
||||
"""corp-gray: 深灰稳重(#374151/#6B7280),图表以灰阶+蓝红点睛区分系列"""
|
||||
|
||||
def _palette_name(self):
|
||||
return "corp-gray"
|
||||
|
||||
def _apply_palette(self):
|
||||
self.primary = RGBColor(0x37, 0x41, 0x51)
|
||||
self.secondary = RGBColor(0x6B, 0x72, 0x80)
|
||||
self.card_fill = RGBColor(0xF3, 0xF4, 0xF6)
|
||||
self.panel = RGBColor(0xF5, 0xF6, 0xFA)
|
||||
self.panel2 = RGBColor(0xE5, 0xE7, 0xEB)
|
||||
self.accent = RGBColor(0xC0, 0x00, 0x00)
|
||||
self.chart_colors = [
|
||||
RGBColor(0x1F, 0x29, 0x37), RGBColor(0x4B, 0x55, 0x63),
|
||||
RGBColor(0x9C, 0xA3, 0xAF), RGBColor(0x15, 0x65, 0xC0),
|
||||
RGBColor(0xC0, 0x00, 0x00), RGBColor(0x0E, 0xA5, 0xE9),
|
||||
]
|
||||
|
||||
|
||||
# 设计系统注册表
|
||||
DESIGN_SYSTEMS = {
|
||||
"aura-dark": DesignSystem,
|
||||
"business-report": BusinessReport,
|
||||
"report-jp": ReportJP,
|
||||
"corp-blue": CorpBlue,
|
||||
"corp-green": CorpGreen,
|
||||
"corp-purple": CorpPurple,
|
||||
"corp-orange": CorpOrange,
|
||||
"corp-gray": CorpGray,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 工具函数
|
||||
def set_run_font(run, size, bold, color, font_name):
|
||||
run.font.size = Pt(size)
|
||||
run.font.bold = bold
|
||||
run.font.color.rgb = color
|
||||
run.font.name = font_name
|
||||
rPr = run._r.get_or_add_rPr()
|
||||
for tag in ('a:ea', 'a:cs'):
|
||||
el = rPr.find(qn(tag))
|
||||
if el is None:
|
||||
el = rPr.makeelement(qn(tag), {})
|
||||
rPr.append(el)
|
||||
el.set('typeface', font_name)
|
||||
|
||||
|
||||
def add_textbox(slide, x, y, w, h, ds, anchor=MSO_ANCHOR.TOP):
|
||||
tb = slide.shapes.add_textbox(x, y, w, h)
|
||||
tf = tb.text_frame
|
||||
tf.word_wrap = True
|
||||
tf.vertical_anchor = anchor
|
||||
tf.margin_left = Pt(4)
|
||||
tf.margin_right = Pt(4)
|
||||
tf.margin_top = Pt(2)
|
||||
tf.margin_bottom = Pt(2)
|
||||
return tb, tf
|
||||
|
||||
|
||||
def add_text(slide, x, y, w, h, text, ds, size=16, bold=False,
|
||||
color=None, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP,
|
||||
line_spacing=1.25, space_after=6):
|
||||
"""支持 **加粗** 与 - 子项标记"""
|
||||
if color is None:
|
||||
color = ds.text
|
||||
tb, tf = add_textbox(slide, x, y, w, h, ds, anchor)
|
||||
lines = str(text).split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
p.alignment = align
|
||||
p.line_spacing = line_spacing
|
||||
p.space_after = Pt(space_after)
|
||||
# 解析 **bold** 与 - 子项
|
||||
is_sub = line.strip().startswith('-')
|
||||
content = line.strip()[1:].strip() if is_sub else line
|
||||
indent = 0 if not is_sub else 1
|
||||
if indent:
|
||||
p.level = 1
|
||||
parts = re.split(r'(\*\*.*?\*\*)', content)
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
r = p.add_run()
|
||||
if part.startswith('**') and part.endswith('**'):
|
||||
r.text = part[2:-2]
|
||||
set_run_font(r, size, True, color or ds.text, ds.font_for(part))
|
||||
else:
|
||||
r.text = part
|
||||
set_run_font(r, size, bold, color or ds.text, ds.font_for(part))
|
||||
return tb
|
||||
|
||||
|
||||
def add_rect(slide, x, y, w, h, ds, fill=None, line=None, radius=False,
|
||||
line_w=1.0, shadow=False):
|
||||
st = MSO_SHAPE.ROUNDED_RECTANGLE if radius else MSO_SHAPE.RECTANGLE
|
||||
shp = slide.shapes.add_shape(st, x, y, w, h)
|
||||
if radius:
|
||||
try:
|
||||
shp.adjustments[0] = 0.08
|
||||
except Exception:
|
||||
pass
|
||||
if fill is None:
|
||||
shp.fill.background()
|
||||
else:
|
||||
shp.fill.solid()
|
||||
shp.fill.fore_color.rgb = fill
|
||||
if line is None:
|
||||
shp.line.fill.background()
|
||||
else:
|
||||
shp.line.color.rgb = line
|
||||
shp.line.width = Pt(line_w)
|
||||
shp.shadow.inherit = False
|
||||
if shadow:
|
||||
add_shape_shadow(shp, ds)
|
||||
tf = shp.text_frame
|
||||
tf.word_wrap = True
|
||||
tf.margin_left = Pt(8)
|
||||
tf.margin_right = Pt(8)
|
||||
tf.margin_top = Pt(4)
|
||||
tf.margin_bottom = Pt(4)
|
||||
return shp
|
||||
|
||||
|
||||
def add_shape_shadow(shp, ds, blur=90000, dist=45000, alpha=25000):
|
||||
"""给形状添加柔和外投影(XML 层操作)"""
|
||||
import lxml.etree as etree
|
||||
spPr = shp._element.spPr
|
||||
# 移除已有 effectLst
|
||||
for el in spPr.findall(qn('a:effectLst')):
|
||||
spPr.remove(el)
|
||||
effect = etree.SubElement(spPr, qn('a:effectLst'))
|
||||
outer = etree.SubElement(effect, qn('a:outerShdw'))
|
||||
outer.set('blurRad', str(blur))
|
||||
outer.set('dist', str(dist))
|
||||
outer.set('dir', '5400000')
|
||||
outer.set('rotWithShape', '0')
|
||||
srgb = etree.SubElement(outer, qn('a:srgbClr'))
|
||||
srgb.set('val', '1F2937')
|
||||
alpha_el = etree.SubElement(srgb, qn('a:alpha'))
|
||||
alpha_el.set('val', str(alpha))
|
||||
|
||||
|
||||
def add_gradient_rect(slide, x, y, w, h, ds, color_top, color_bottom,
|
||||
radius=False, angle=90):
|
||||
"""垂直渐变填充矩形(顶部亮色 → 底部深色,模拟立体柱体)"""
|
||||
st = MSO_SHAPE.ROUNDED_RECTANGLE if radius else MSO_SHAPE.RECTANGLE
|
||||
shp = slide.shapes.add_shape(st, x, y, w, h)
|
||||
if radius:
|
||||
try:
|
||||
shp.adjustments[0] = 0.12
|
||||
except Exception:
|
||||
pass
|
||||
shp.shadow.inherit = False
|
||||
# 设置渐变填充(通过 XML:gradFill)
|
||||
spPr = shp._element.spPr
|
||||
# 移除已有填充
|
||||
for tag in ('a:noFill', 'a:solidFill', 'a:gradFill', 'a:pattFill'):
|
||||
el = spPr.find(qn(tag))
|
||||
if el is not None:
|
||||
spPr.remove(el)
|
||||
grad = spPr.makeelement(qn('a:gradFill'), {})
|
||||
gsLst = spPr.makeelement(qn('a:gsLst'), {})
|
||||
# 渐变停止点:顶部亮色(0%) → 底部深色(100%)
|
||||
stops = [
|
||||
(0, color_top),
|
||||
(100, color_bottom),
|
||||
]
|
||||
for pos, color in stops:
|
||||
gs = spPr.makeelement(qn('a:gs'), {'pos': str(pos)})
|
||||
srgb = spPr.makeelement(qn('a:srgbClr'), {'val': '%02X%02X%02X' % (
|
||||
color[0], color[1], color[2])})
|
||||
gs.append(srgb)
|
||||
gsLst.append(gs)
|
||||
grad.append(gsLst)
|
||||
# 线性渐变方向
|
||||
lin = spPr.makeelement(qn('a:lin'), {
|
||||
'ang': str(angle * 60000), 'scaled': '1'})
|
||||
grad.append(lin)
|
||||
# 插入到 spPr(fill 应位于 line 之前)
|
||||
line_el = spPr.find(qn('a:ln'))
|
||||
if line_el is not None:
|
||||
line_el.addprevious(grad)
|
||||
else:
|
||||
spPr.append(grad)
|
||||
# 无边框
|
||||
shp.line.fill.background()
|
||||
tf = shp.text_frame
|
||||
tf.word_wrap = True
|
||||
return shp
|
||||
|
||||
|
||||
def add_shape(slide, st, x, y, w, h, ds, fill=None, line=None):
|
||||
shp = slide.shapes.add_shape(st, x, y, w, h)
|
||||
if fill is None:
|
||||
shp.fill.background()
|
||||
else:
|
||||
shp.fill.solid()
|
||||
shp.fill.fore_color.rgb = fill
|
||||
if line is None:
|
||||
shp.line.fill.background()
|
||||
else:
|
||||
shp.line.color.rgb = line
|
||||
shp.line.width = Pt(1.0)
|
||||
shp.shadow.inherit = False
|
||||
return shp
|
||||
|
||||
|
||||
def pill(slide, x, y, w, h, text, ds, fill, text_color=None, size=12):
|
||||
p = add_rect(slide, x, y, w, h, ds, fill=fill, radius=True)
|
||||
tf = p.text_frame
|
||||
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
p0 = tf.paragraphs[0]
|
||||
p0.alignment = PP_ALIGN.CENTER
|
||||
r = p0.add_run()
|
||||
r.text = text
|
||||
# 深色系: pill 上文字用底色(深色);浅色系: pill 上文字用白色
|
||||
if text_color is None:
|
||||
text_color = ds.bg if ds.dark else RGBColor(0xFF, 0xFF, 0xFF)
|
||||
set_run_font(r, size, True, text_color, ds.font_for(text))
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 页面渲染器
|
||||
class SlideRenderer:
|
||||
def __init__(self, prs, ds, template=False):
|
||||
self.prs = prs
|
||||
self.ds = ds
|
||||
self.template = template
|
||||
# 使用模板时选择空白版式(L6 白紙);无模板时用 blank index 6
|
||||
self.blank = prs.slide_layouts[6]
|
||||
self.margin = ds.margin
|
||||
self.content_w = Emu(int(ds.page_w - 2 * ds.margin))
|
||||
# 统一字号体系(设计系统 typography)
|
||||
self.ty = getattr(ds, "typography", {})
|
||||
|
||||
def tp(self, key, default=14):
|
||||
"""获取统一排版字号"""
|
||||
return self.ty.get(key, default)
|
||||
|
||||
def new(self):
|
||||
s = self.prs.slides.add_slide(self.blank)
|
||||
if not self.template:
|
||||
# 非模板模式:绘制纯色背景(模板模式继承母版背景)
|
||||
add_rect(s, 0, 0, self.ds.page_w, self.ds.page_h, self.ds,
|
||||
fill=self.ds.bg)
|
||||
# 左上光晕装饰(仅深色系)
|
||||
if self.ds.glow:
|
||||
for i in range(5, 0, -1):
|
||||
add_shape(s, MSO_SHAPE.OVAL,
|
||||
Inches(-1.0 + i * 0.05), Inches(-1.3 + i * 0.05),
|
||||
Inches(2.6 - i * 0.1), Inches(2.6 - i * 0.1),
|
||||
self.ds, fill=None, line=self.ds.primary)
|
||||
elif self.ds.name == "report-jp":
|
||||
# report-jp 模板模式:内容区极浅蓝灰背景(视觉分区,区别于纯白母版)
|
||||
lay = self.ds.layout
|
||||
bg_top = Inches(lay["body_top"] - 0.15)
|
||||
bg_h = Emu(int(Inches(lay["body_bottom"] + 0.15) - bg_top))
|
||||
add_rect(s, self.margin, bg_top, self.content_w, bg_h,
|
||||
self.ds, fill=RGBColor(0xF7, 0xF9, 0xFC))
|
||||
return s
|
||||
|
||||
def header(self, s, title, subtitle=None, page=None, total=None,
|
||||
section_no=None, confidential=None):
|
||||
"""页眉:设计系统 layout 坐标。
|
||||
report-jp 采用模板风格:章节行20pt在上(y0.32) + 主题行在下(y0.88)
|
||||
"""
|
||||
lay = self.ds.layout
|
||||
# 模板风格:章节行(编号+标题)在顶部小字
|
||||
if self.ds.name == "report-jp":
|
||||
self._header_jp(s, title, subtitle, section_no, confidential)
|
||||
return
|
||||
header_top = Inches(lay["header_top"])
|
||||
header_h = Inches(lay["header_h"])
|
||||
sub_top = Inches(lay["subtitle_top"])
|
||||
div_y = Inches(lay["divider_y"])
|
||||
add_rect(s, self.margin, header_top, Inches(0.07), header_h,
|
||||
self.ds, fill=self.ds.primary)
|
||||
# 日式页眉:章节编号 + 标题
|
||||
if section_no:
|
||||
add_text(s, Inches(0.2), header_top, Inches(0.4), header_h,
|
||||
f"{section_no}.", self.ds, size=self.tp('section_kicker', 14),
|
||||
bold=True, color=self.ds.primary)
|
||||
title_x = Inches(0.65)
|
||||
else:
|
||||
title_x = Inches(0.82)
|
||||
# 标题宽度:到机密标记左侧为止(动态适配页面宽度)
|
||||
title_w = Emu(int(self.ds.page_w - Inches(1.35) - title_x))
|
||||
add_text(s, title_x, header_top, title_w, header_h,
|
||||
title, self.ds, size=self.tp('page_title', 22), bold=True)
|
||||
if subtitle:
|
||||
sub_w = Emu(int(self.ds.page_w - Inches(1.35) - title_x))
|
||||
add_text(s, title_x, sub_top, sub_w, Inches(0.35),
|
||||
subtitle, self.ds, size=self.tp('page_subtitle', 12),
|
||||
color=self.ds.text_dim)
|
||||
add_rect(s, self.margin, div_y, self.content_w, Pt(1.2),
|
||||
self.ds, fill=self.ds.line)
|
||||
# 机密标记(右上角,红色标签)
|
||||
if confidential:
|
||||
# 根据页面宽度动态定位(右上角)
|
||||
cw = Inches(0.9)
|
||||
cx = Emu(int(self.ds.page_w - Inches(1.0)))
|
||||
label = add_rect(s, cx, header_top - Inches(0.04), cw, Inches(0.34),
|
||||
self.ds, fill=self.ds.danger)
|
||||
tf = label.text_frame
|
||||
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
p0 = tf.paragraphs[0]
|
||||
p0.alignment = PP_ALIGN.CENTER
|
||||
r = p0.add_run()
|
||||
r.text = confidential
|
||||
set_run_font(r, 9, True, RGBColor(0xFF, 0xFF, 0xFF),
|
||||
self.ds.font_for(confidential))
|
||||
|
||||
def _header_jp(self, s, title, subtitle, section_no, confidential):
|
||||
"""report-jp 模板风格页眉:
|
||||
章节行 y0.32 (20pt 含编号) → 主题行 y0.88 (16pt) → 装饰线 y1.52
|
||||
"""
|
||||
# 章节行(编号+标题 单行,20pt)
|
||||
if section_no:
|
||||
head_text = f"{section_no}. {title}"
|
||||
else:
|
||||
head_text = title
|
||||
add_text(s, Inches(0.30), Inches(0.32), Inches(10.0), Inches(0.45),
|
||||
head_text, self.ds, size=20, bold=False,
|
||||
color=self.ds.text)
|
||||
# 主题/副标题行(y0.88,16pt,模板为页面说明)
|
||||
if subtitle:
|
||||
add_text(s, Inches(0.38), Inches(0.88), Inches(10.0), Inches(0.6),
|
||||
subtitle, self.ds, size=16, bold=False,
|
||||
color=self.ds.text)
|
||||
# 精致装饰线(页眉与内容区视觉分隔,主色细线+渐变尾)
|
||||
line_y = Inches(1.52)
|
||||
add_rect(s, self.margin, line_y, self.content_w, Pt(1.2),
|
||||
self.ds, fill=self.ds.line)
|
||||
# 装饰线左端主色块(视觉锚点)
|
||||
add_rect(s, self.margin, line_y - Inches(0.015),
|
||||
Inches(1.6), Pt(2.8), self.ds, fill=self.ds.primary)
|
||||
# 机密标记(右上角,与模板位置一致 y0.42)
|
||||
if confidential:
|
||||
cw = Inches(0.9)
|
||||
cx = Emu(int(self.ds.page_w - Inches(1.0)))
|
||||
label = add_rect(s, cx, Inches(0.42), cw, Inches(0.34),
|
||||
self.ds, fill=self.ds.danger)
|
||||
tf = label.text_frame
|
||||
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
p0 = tf.paragraphs[0]
|
||||
p0.alignment = PP_ALIGN.CENTER
|
||||
r = p0.add_run()
|
||||
r.text = confidential
|
||||
set_run_font(r, 9, True, RGBColor(0xFF, 0xFF, 0xFF),
|
||||
self.ds.font_for(confidential))
|
||||
|
||||
def footer(self, s, idx, total, company="", date_text="", skip=False):
|
||||
"""页脚:公司名(左)+ 页码(右),商务报告惯例。skip 用于封面/结束页"""
|
||||
if skip:
|
||||
return
|
||||
lay = self.ds.layout
|
||||
fy = Inches(lay["footer_top"])
|
||||
foot_x = self.margin
|
||||
if company:
|
||||
add_text(s, foot_x, fy, Inches(4.0), Inches(0.3),
|
||||
company, self.ds, size=9, color=self.ds.text_dim)
|
||||
foot_x = Emu(int(foot_x + Inches(4.2)))
|
||||
if date_text:
|
||||
add_text(s, foot_x, fy, Inches(3.5), Inches(0.3),
|
||||
date_text, self.ds, size=9, color=self.ds.text_dim)
|
||||
if idx:
|
||||
# 页码:与模板对齐(右下角)
|
||||
px = Emu(int(self.ds.page_w - Inches(lay["page_num_x"]) - Inches(0.6)))
|
||||
py = Inches(lay["page_num_y"])
|
||||
add_text(s, px, py, Inches(0.6), Inches(0.35),
|
||||
f"{idx:02d}", self.ds, size=11,
|
||||
color=self.ds.text_dim, align=PP_ALIGN.RIGHT)
|
||||
|
||||
def render(self, slide_plan, idx, total, company="", date_text="",
|
||||
notes_template=False):
|
||||
stype = slide_plan.get("type", "content")
|
||||
fn = getattr(self, f"r_{stype}", self.r_content)
|
||||
fn(slide_plan, idx, total)
|
||||
# 页脚(封面/结束页不显示页码)——页码定位随页面宽度
|
||||
skip_footer = stype in ("cover", "end")
|
||||
self.footer(self.prs.slides[-1], idx, total, company, date_text,
|
||||
skip=skip_footer)
|
||||
# 将 notes 写入真正的演讲者备注区(不显示在页面上)
|
||||
notes_text = slide_plan.get("notes")
|
||||
if notes_template and not notes_text:
|
||||
notes_text = self._auto_notes(slide_plan, idx, total)
|
||||
if notes_text:
|
||||
try:
|
||||
notes_slide = self.prs.slides[-1].notes_slide
|
||||
tf = notes_slide.notes_text_frame
|
||||
tf.text = str(notes_text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _auto_notes(self, p, idx, total):
|
||||
"""自动生成结构化演讲者备注(当 plan 未提供 notes 时)
|
||||
模板:要点 / 数据提醒 / 过渡提示"""
|
||||
lines = []
|
||||
t = p.get("type", "content")
|
||||
title = p.get("title", "")
|
||||
if title:
|
||||
lines.append(f"【本页主题】{title}")
|
||||
# 数据提醒:提取 content 中的数字(排除目录序号/短编号)
|
||||
nums = []
|
||||
for key in ("content", "data"):
|
||||
v = p.get(key)
|
||||
if isinstance(v, list):
|
||||
for item in v:
|
||||
if isinstance(item, str):
|
||||
for m in re.findall(r'\d+(?:[.,]\d+)?\s*[%%]?', item):
|
||||
m = m.strip()
|
||||
# 排除目录编号 "01." 类
|
||||
if re.fullmatch(r'\d{1,2}', m) and not re.search(r'[%%]', m):
|
||||
continue
|
||||
if m and m not in nums:
|
||||
nums.append(m)
|
||||
if nums:
|
||||
lines.append("【数据提醒】本页包含关键数字:" + "、".join(nums[:8]))
|
||||
# 内容要点(去掉标记)
|
||||
points = []
|
||||
for key in ("content",):
|
||||
v = p.get(key)
|
||||
if isinstance(v, list):
|
||||
for item in v:
|
||||
if isinstance(item, str) and item.strip():
|
||||
pts = item.strip()
|
||||
pts = pts.replace('**', '') # 先移除加粗标记
|
||||
pts = re.sub(r'^[-*]\s*', '', pts) # 再去行首符号
|
||||
if len(pts) > 3:
|
||||
points.append(pts)
|
||||
if points:
|
||||
lines.append("【核心要点】")
|
||||
for pt in points[:3]:
|
||||
lines.append(f" · {pt[:50]}")
|
||||
# 过渡提示
|
||||
lines.append("【过渡提示】总结本页要点后自然过渡到下一页。")
|
||||
return "\n".join(lines)
|
||||
|
||||
# ---- cover(支持 stats 大数字统计 + 装饰图形)
|
||||
def r_cover(self, p, idx, total):
|
||||
s = self.new()
|
||||
pw = self.ds.page_w
|
||||
# 底部渐变条
|
||||
add_rect(s, Inches(0), Inches(6.9), pw, Inches(0.6),
|
||||
self.ds, fill=self.ds.panel2)
|
||||
# 右上角装饰圆环(视觉焦点)
|
||||
add_shape(s, MSO_SHAPE.OVAL,
|
||||
Emu(int(pw - Inches(2.2))), Inches(-0.8),
|
||||
Inches(3.0), Inches(3.0), self.ds,
|
||||
fill=None, line=self.ds.primary)
|
||||
add_text(s, self.margin, Inches(0.8), Inches(5.0), Inches(0.4),
|
||||
p.get("kicker", ""), self.ds,
|
||||
size=self.tp('section_kicker', 14),
|
||||
color=self.ds.primary)
|
||||
# 标题宽度自适应页面
|
||||
title_w = Emu(int(pw - 2 * self.margin))
|
||||
# 有 stats 时标题区压缩(数字墙占右侧)
|
||||
has_stats = p.get("stats")
|
||||
if has_stats:
|
||||
title_w = Emu(int(pw - self.margin - Inches(3.6)))
|
||||
# 标题字号按长度和可用宽度自适应(长标题/窄页面自动缩小)
|
||||
cover_title = p.get("title", "演示文稿")
|
||||
base_sz = self.tp('cover_title', 38)
|
||||
title_chars = len(cover_title)
|
||||
if title_chars > 20:
|
||||
cover_sz = min(base_sz, 26)
|
||||
elif title_chars > 14:
|
||||
cover_sz = min(base_sz, 30)
|
||||
else:
|
||||
cover_sz = base_sz
|
||||
# stats 存在时标题区更窄,再缩一档
|
||||
if has_stats and cover_sz > 24:
|
||||
cover_sz = min(cover_sz, 24)
|
||||
add_text(s, self.margin, Inches(2.2), title_w, Inches(1.4),
|
||||
cover_title, self.ds,
|
||||
size=cover_sz, bold=True, line_spacing=1.2)
|
||||
if p.get("subtitle"):
|
||||
add_text(s, self.margin, Inches(3.6), title_w, Inches(0.6),
|
||||
p["subtitle"], self.ds,
|
||||
size=self.tp('cover_subtitle', 18),
|
||||
color=self.ds.text_dim)
|
||||
if p.get("content"):
|
||||
add_text(s, self.margin, Inches(4.4),
|
||||
Emu(int(title_w - Inches(0.5))), Inches(1.6),
|
||||
"\n".join(p["content"]), self.ds,
|
||||
size=self.tp('cover_body', 15),
|
||||
color=self.ds.text_dim, line_spacing=1.5)
|
||||
add_rect(s, self.margin, Inches(5.7), Inches(2.8), Pt(2.5),
|
||||
self.ds, fill=self.ds.primary)
|
||||
# 大数字统计墙(右侧,封面视觉焦点)
|
||||
if has_stats:
|
||||
self._cover_stats(s, p["stats"], pw)
|
||||
|
||||
def _cover_stats(self, s, stats, pw):
|
||||
"""封面大数字统计(右侧竖排卡片)"""
|
||||
x = Emu(int(pw - Inches(3.3)))
|
||||
y0 = Inches(1.9)
|
||||
card_w = Inches(2.7)
|
||||
card_h = Inches(1.15)
|
||||
for i, (num, label) in enumerate(stats):
|
||||
y = y0 + i * (card_h + Inches(0.22))
|
||||
# 数字卡片
|
||||
add_rect(s, x, y, card_w, card_h, self.ds,
|
||||
fill=self.ds.panel, radius=True, shadow=True)
|
||||
add_rect(s, x, y, card_w, Inches(0.06), self.ds,
|
||||
fill=self.ds.chart_colors[i % 6])
|
||||
add_text(s, x, y + Inches(0.08), card_w, Inches(0.62),
|
||||
num, self.ds, size=26, bold=True,
|
||||
color=self.ds.chart_colors[i % 6],
|
||||
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||||
add_text(s, x + Inches(0.1), y + Inches(0.74),
|
||||
card_w - Inches(0.2), Inches(0.35),
|
||||
label, self.ds, size=11,
|
||||
color=self.ds.text_dim, align=PP_ALIGN.CENTER)
|
||||
|
||||
# ---- toc
|
||||
def r_toc(self, p, idx, total):
|
||||
s = self.new()
|
||||
self.header(s, p.get("title", "目录"),
|
||||
p.get("subtitle"), idx, total,
|
||||
section_no=p.get("section_no"),
|
||||
confidential=p.get("confidential"))
|
||||
items = p.get("content", [])
|
||||
x0, y0 = self.margin, Inches(self.ds.layout["body_top"])
|
||||
# 卡片宽度自适应:两列 + 间距
|
||||
gap = Inches(0.3)
|
||||
cw = Emu(int((self.content_w - gap) / 2))
|
||||
ch = Inches(1.35)
|
||||
for i, item in enumerate(items):
|
||||
col, row = i % 2, i // 2
|
||||
x = x0 + col * (cw + gap)
|
||||
y = y0 + row * (ch + Inches(0.28))
|
||||
card = add_rect(s, x, y, cw, ch, self.ds, fill=self.ds.panel,
|
||||
radius=True, shadow=True)
|
||||
# 左侧色条(彩色,随条目序号变化)
|
||||
add_rect(s, x, y, Inches(0.07), ch, self.ds,
|
||||
fill=self.ds.chart_colors[i % 6])
|
||||
num = item.split('.')[0] if '.' in item else f"{i+1:02d}"
|
||||
title = item.split('.', 1)[1].strip() if '.' in item else item
|
||||
# 序号:彩色圆形徽章
|
||||
badge_r = Inches(0.34)
|
||||
badge = add_shape(s, MSO_SHAPE.OVAL,
|
||||
x + Inches(0.3), y + Inches(0.34),
|
||||
badge_r * 2, badge_r * 2, self.ds,
|
||||
fill=self.ds.chart_colors[i % 6])
|
||||
btf = badge.text_frame
|
||||
btf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
bp = btf.paragraphs[0]
|
||||
bp.alignment = PP_ALIGN.CENTER
|
||||
br = bp.add_run()
|
||||
br.text = num
|
||||
set_run_font(br, 16, True, RGBColor(0xFF, 0xFF, 0xFF),
|
||||
self.ds.font_cn)
|
||||
# 条目标题(徽章右侧)
|
||||
add_text(s, x + Inches(1.05), y + Inches(0.4),
|
||||
cw - Inches(1.3), Inches(0.6),
|
||||
title, self.ds,
|
||||
size=self.tp('body', 14), bold=True)
|
||||
|
||||
# ---- section
|
||||
def r_section(self, p, idx, total):
|
||||
s = self.new()
|
||||
pw = self.ds.page_w
|
||||
title_w = Emu(int(pw - 2 * self.margin))
|
||||
# 左侧装饰竖条
|
||||
add_rect(s, self.margin, Inches(2.4), Inches(0.1), Inches(1.6),
|
||||
self.ds, fill=self.ds.primary)
|
||||
add_text(s, Emu(int(self.margin + Inches(0.4))), Inches(2.6),
|
||||
Inches(5.0), Inches(0.5),
|
||||
p.get("kicker", "SECTION"), self.ds,
|
||||
size=self.tp('section_kicker', 14),
|
||||
color=self.ds.primary)
|
||||
add_text(s, Emu(int(self.margin + Inches(0.4))), Inches(3.1),
|
||||
title_w, Inches(1.2),
|
||||
p.get("title", ""), self.ds,
|
||||
size=self.tp('section_title', 34), bold=True)
|
||||
if p.get("content"):
|
||||
add_text(s, Emu(int(self.margin + Inches(0.4))), Inches(4.5),
|
||||
Emu(int(title_w - Inches(0.5))), Inches(1.2),
|
||||
"\n".join(p["content"]), self.ds,
|
||||
size=self.tp('body', 16), color=self.ds.text_dim)
|
||||
# 底部主色横线(视觉收尾)
|
||||
add_rect(s, self.margin, Inches(5.9), Inches(2.8), Pt(2.5),
|
||||
self.ds, fill=self.ds.primary)
|
||||
|
||||
# ---- content(智能布局:自动识别要点卡片 / 普通文本)
|
||||
def r_content(self, p, idx, total):
|
||||
s = self.new()
|
||||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||||
section_no=p.get("section_no"),
|
||||
confidential=p.get("confidential"))
|
||||
content = p.get("content", [])
|
||||
y0 = Inches(self.ds.layout["body_top"])
|
||||
body_h = Inches(self.ds.layout["body_bottom"] - self.ds.layout["body_top"])
|
||||
|
||||
# 右侧配图(plan 页面级 image 字段,文件存在才启用左文右图布局)
|
||||
img_path = p.get("image")
|
||||
has_img = bool(img_path) and Path(str(img_path)).exists()
|
||||
avail_w = Emu(int(self.content_w * 0.58)) if has_img else self.content_w
|
||||
|
||||
# 智能检测:是否有 **标题** 开头的要点(渲染为卡片网格)
|
||||
items = []
|
||||
plain_lines = []
|
||||
for line in content:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("**") and "**" in stripped[2:]:
|
||||
# 提取 **标题** 和后续内容
|
||||
end = stripped.find("**", 2)
|
||||
title = stripped[2:end]
|
||||
rest = stripped[end+2:].strip()
|
||||
items.append((title, rest))
|
||||
elif stripped:
|
||||
plain_lines.append(stripped)
|
||||
|
||||
if items and not plain_lines:
|
||||
# 卡片网格布局(2列,每卡片带色条+标题+描述)
|
||||
self._content_cards(s, items, y0, body_h, w=avail_w)
|
||||
elif items and plain_lines:
|
||||
# 混合:要点卡片 + 底部说明文字
|
||||
self._content_cards(s, items, y0, Emu(int(body_h * 0.8)), w=avail_w)
|
||||
if plain_lines:
|
||||
add_text(s, self.margin,
|
||||
Emu(int(y0 + body_h * 0.85)),
|
||||
avail_w, Emu(int(body_h * 0.15)),
|
||||
"\n".join(plain_lines), self.ds,
|
||||
size=self.tp('body_small', 12),
|
||||
color=self.ds.text_dim, line_spacing=1.3)
|
||||
else:
|
||||
# 普通文本布局
|
||||
add_text(s, self.margin, y0, avail_w, body_h,
|
||||
"\n".join(content), self.ds, size=self.tp('body', 15),
|
||||
line_spacing=1.4, space_after=8)
|
||||
|
||||
if has_img:
|
||||
self._place_image(s, str(img_path), y0, body_h)
|
||||
|
||||
def _place_image(self, s, img_path, y0, body_h):
|
||||
"""在内容区右侧放置配图:宽占 42%,等比缩放、垂直居中,带细边框。
|
||||
图片缺失/损坏时静默跳过(返回 False),不影响整页渲染。"""
|
||||
zone_w = Emu(int(self.content_w * 0.40))
|
||||
zx = Emu(int(self.margin + self.content_w - zone_w))
|
||||
max_h = Emu(int(body_h - Inches(0.12)))
|
||||
try:
|
||||
pic = s.shapes.add_picture(img_path, zx, y0, width=zone_w)
|
||||
except Exception:
|
||||
return False
|
||||
if pic.height > max_h:
|
||||
ratio = max_h / pic.height
|
||||
new_w = Emu(int(pic.width * ratio))
|
||||
pic.height = max_h
|
||||
pic.width = new_w
|
||||
pic.left = Emu(int(zx + int((zone_w - new_w) / 2)))
|
||||
if pic.height < body_h:
|
||||
pic.top = Emu(int(y0 + int((body_h - pic.height) / 2)))
|
||||
try:
|
||||
pic.line.color.rgb = self.ds.line
|
||||
pic.line.width = Pt(1)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def _content_cards(self, s, items, y0, body_h, w=None):
|
||||
"""要点卡片网格:2列 × N行,每卡片带左侧色条+标题+描述"""
|
||||
n = len(items)
|
||||
if not n:
|
||||
return
|
||||
grid_w = w if w is not None else self.content_w
|
||||
gap = Inches(0.3)
|
||||
col_w = Emu(int((grid_w - gap) / 2))
|
||||
# 卡片高度自适应(最多3行卡片)
|
||||
rows = (n + 1) // 2
|
||||
card_h = Emu(int((body_h - gap * (rows - 1)) / rows))
|
||||
# 卡片多时压缩字号(避免描述溢出)
|
||||
desc_sz = self.tp('body_small', 12)
|
||||
title_sz = self.tp('card_title', 16)
|
||||
desc_ls = 1.35
|
||||
if rows >= 3 or card_h < Inches(1.5):
|
||||
desc_sz = max(10, desc_sz - 1)
|
||||
title_sz = max(13, title_sz - 1)
|
||||
desc_ls = 1.25
|
||||
for i, (title, desc) in enumerate(items):
|
||||
col, row = i % 2, i // 2
|
||||
x = self.margin + col * (col_w + gap)
|
||||
y = y0 + row * (card_h + gap)
|
||||
# 卡片背景(带柔和投影)
|
||||
add_rect(s, x, y, col_w, card_h, self.ds,
|
||||
fill=self.ds.panel, radius=True, shadow=True)
|
||||
# 左侧色条
|
||||
add_rect(s, x, y, Inches(0.07), card_h, self.ds,
|
||||
fill=self.ds.chart_colors[i % 6])
|
||||
# 标题
|
||||
add_text(s, x + Inches(0.28), y + Inches(0.12),
|
||||
col_w - Inches(0.5), Inches(0.38),
|
||||
title, self.ds, size=title_sz,
|
||||
bold=True, color=self.ds.primary, line_spacing=1.15)
|
||||
# 描述
|
||||
if desc:
|
||||
add_text(s, x + Inches(0.28), y + Inches(0.56),
|
||||
col_w - Inches(0.5), card_h - Inches(0.72),
|
||||
desc, self.ds, size=desc_sz,
|
||||
color=self.ds.text, line_spacing=desc_ls)
|
||||
|
||||
# ---- two_column
|
||||
def r_two_column(self, p, idx, total):
|
||||
s = self.new()
|
||||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||||
section_no=p.get("section_no"),
|
||||
confidential=p.get("confidential"))
|
||||
cols = p.get("content", [])
|
||||
gap = Inches(0.35)
|
||||
cw = Emu(int((self.content_w - gap) / 2))
|
||||
body_top = Inches(self.ds.layout["body_top"])
|
||||
body_bottom = Inches(self.ds.layout["body_bottom"])
|
||||
card_h = Emu(int(body_bottom - body_top))
|
||||
# 窄页面/长内容时字号自动适配:卡片可用高度不足则缩小
|
||||
card_font = 13
|
||||
if cw < Inches(5.0):
|
||||
card_font = 12
|
||||
for ci in range(2):
|
||||
x = self.margin + ci * (cw + gap)
|
||||
y = body_top
|
||||
card = add_rect(s, x, y, cw, card_h, self.ds,
|
||||
fill=self.ds.panel, radius=True, shadow=True)
|
||||
head = p.get(f"col{ci+1}_title", f"栏目 {ci+1}")
|
||||
add_text(s, x + Inches(0.3), y + Inches(0.25), cw - Inches(0.6),
|
||||
Inches(0.5), head, self.ds, size=self.tp('card_title', 16),
|
||||
bold=True, color=self.ds.primary)
|
||||
if ci < len(cols):
|
||||
# 文本区自适应卡片剩余高度
|
||||
text_top = y + Inches(0.9)
|
||||
text_h = Emu(int(card_h - Inches(1.15)))
|
||||
add_text(s, x + Inches(0.3), text_top,
|
||||
cw - Inches(0.6), text_h,
|
||||
"\n".join(cols[ci]), self.ds,
|
||||
size=self.tp('body_small', 13),
|
||||
line_spacing=1.3, space_after=6)
|
||||
|
||||
# ---- table(支持可选底部图表 chart)
|
||||
def r_table(self, p, idx, total):
|
||||
s = self.new()
|
||||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||||
section_no=p.get("section_no"),
|
||||
confidential=p.get("confidential"))
|
||||
data = p.get("data", [])
|
||||
if not data:
|
||||
return
|
||||
rows, cols = len(data), len(data[0])
|
||||
lay = self.ds.layout
|
||||
x0, y0 = self.margin, Inches(lay["body_top"])
|
||||
tw = self.content_w
|
||||
body_avail = Emu(int(Inches(lay["body_bottom"]) - Inches(lay["body_top"])))
|
||||
# 表格+图表混合时,表格占上部 58%,图表占下部 42%
|
||||
has_chart = p.get("chart") or p.get("charts")
|
||||
if has_chart:
|
||||
table_area = Emu(int(body_avail * 0.58))
|
||||
else:
|
||||
table_area = body_avail
|
||||
# 行高自适应:表格头 + (rows-1) 行 分配 table_area
|
||||
th = Inches(0.5)
|
||||
rh = Emu(int((table_area - th) / max(rows - 1, 1)))
|
||||
# 行高下限保护:按 12pt 文字+边距计算最小行高(约0.42in)
|
||||
# 空间不足时优先压缩表头,保证数据行有足够高度
|
||||
min_rh = Inches(0.42)
|
||||
if rh < min_rh:
|
||||
rh = min_rh
|
||||
# 表头高度 = 剩余空间(若负则回到可接受最小值)
|
||||
th_remain = Emu(int(table_area - rh * (rows - 1)))
|
||||
th = th_remain if th_remain > Inches(0.35) else Inches(0.35)
|
||||
cw = Emu(int(tw / cols))
|
||||
# header
|
||||
for c in range(cols):
|
||||
add_rect(s, x0 + c * cw, y0, cw, th, self.ds,
|
||||
fill=self.ds.panel2)
|
||||
add_text(s, x0 + c * cw + Inches(0.1), y0 + Inches(0.12),
|
||||
cw - Inches(0.2), Inches(0.35), str(data[0][c]),
|
||||
self.ds, size=self.tp('table_head', 13), bold=True,
|
||||
color=self.ds.primary,
|
||||
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||||
for r in range(1, rows):
|
||||
cy = y0 + th + (r - 1) * rh
|
||||
fill = self.ds.bg2 if r % 2 == 0 else self.ds.panel
|
||||
for c in range(cols):
|
||||
add_rect(s, x0 + c * cw, cy, cw, rh - Inches(0.04),
|
||||
self.ds, fill=fill)
|
||||
add_text(s, x0 + c * cw + Inches(0.1), cy + Inches(0.1),
|
||||
cw - Inches(0.2), rh - Inches(0.2),
|
||||
str(data[r][c]), self.ds,
|
||||
size=self.tp('table_cell', 12),
|
||||
color=self.ds.text, align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.MIDDLE)
|
||||
# 底部图表(单个或多个并排)
|
||||
if has_chart:
|
||||
table_actual_h = Emu(int(th + rh * (rows - 1)))
|
||||
chart_top = Emu(int(y0 + table_actual_h + Inches(0.2)))
|
||||
chart_bottom = Emu(int(Inches(lay["body_bottom"])))
|
||||
if p.get("charts"):
|
||||
self._draw_charts(s, p.get("charts"), chart_top,
|
||||
chart_bottom - chart_top)
|
||||
elif p.get("chart"):
|
||||
self._draw_charts(s, [p.get("chart")], chart_top,
|
||||
chart_bottom - chart_top)
|
||||
|
||||
# ---- two_table(左右双表并排)
|
||||
def r_two_table(self, p, idx, total):
|
||||
"""左右两个表格并排。plan 结构:
|
||||
tables: [{title, data, col_ratio?}, ...] 或 table1/table2
|
||||
"""
|
||||
s = self.new()
|
||||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||||
section_no=p.get("section_no"),
|
||||
confidential=p.get("confidential"))
|
||||
tables = p.get("tables", [])
|
||||
if not tables:
|
||||
return
|
||||
lay = self.ds.layout
|
||||
y0 = Inches(lay["body_top"])
|
||||
body_bottom = Inches(lay["body_bottom"])
|
||||
gap = Inches(0.3)
|
||||
n = len(tables)
|
||||
# 表格宽度:等分或按 col_ratio
|
||||
ratios = [t.get("col_ratio", 1) for t in tables]
|
||||
total_r = sum(ratios)
|
||||
gap_total = gap * (n - 1)
|
||||
avail_w = self.content_w - Emu(int(gap_total))
|
||||
widths = [Emu(int(avail_w * r / total_r)) for r in ratios]
|
||||
for ti, t in enumerate(tables):
|
||||
tdata = t.get("data", [])
|
||||
if not tdata:
|
||||
continue
|
||||
rows, cols = len(tdata), len(tdata[0])
|
||||
tx = self.margin + sum(widths[:ti]) + ti * gap
|
||||
tw = widths[ti]
|
||||
# 子标题(表格上方)
|
||||
sub_title = t.get("title", "")
|
||||
if sub_title:
|
||||
add_text(s, tx, y0, tw, Inches(0.3),
|
||||
sub_title, self.ds, size=11, bold=True,
|
||||
color=self.ds.primary, align=PP_ALIGN.CENTER)
|
||||
# 表头高度:长文本(日文/多字符)加高,支持换行
|
||||
head_sz = 10
|
||||
if tw < Inches(4.5) or cols >= 5:
|
||||
head_sz = 9
|
||||
th = Inches(0.42)
|
||||
# 检查表头是否有长文本需要更高
|
||||
head_max_len = max(len(str(tdata[0][c])) for c in range(cols))
|
||||
if head_max_len > 10:
|
||||
th = Inches(0.55)
|
||||
# 行高:按表格区域分配(双表用全部 body 高度)
|
||||
table_area = Emu(int(body_bottom - y0 - Inches(0.42)))
|
||||
rh = Emu(int((table_area - th) / max(rows - 1, 1)))
|
||||
cell_sz = 10
|
||||
if tw < Inches(4.5) or cols >= 5:
|
||||
cell_sz = 9
|
||||
min_rh = Inches(cell_sz / 72 * 1.6 + 0.12)
|
||||
if rh < min_rh:
|
||||
rh = min_rh
|
||||
cw = Emu(int(tw / cols))
|
||||
# 表头
|
||||
for c in range(cols):
|
||||
add_rect(s, tx + c * cw, y0, cw, th, self.ds,
|
||||
fill=self.ds.panel2)
|
||||
add_text(s, tx + c * cw + Inches(0.04), y0 + Inches(0.05),
|
||||
cw - Inches(0.08), th - Inches(0.08),
|
||||
str(tdata[0][c]), self.ds, size=head_sz,
|
||||
bold=True, color=self.ds.primary,
|
||||
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE,
|
||||
line_spacing=1.0)
|
||||
# 数据行
|
||||
for r in range(1, rows):
|
||||
cy = y0 + th + (r - 1) * rh
|
||||
fill = self.ds.bg2 if r % 2 == 0 else self.ds.panel
|
||||
for c in range(cols):
|
||||
add_rect(s, tx + c * cw, cy, cw, rh - Inches(0.02),
|
||||
self.ds, fill=fill)
|
||||
add_text(s, tx + c * cw + Inches(0.04), cy + Inches(0.04),
|
||||
cw - Inches(0.08), rh - Inches(0.08),
|
||||
str(tdata[r][c]), self.ds, size=cell_sz,
|
||||
color=self.ds.text, align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.0)
|
||||
|
||||
# ---- chart(支持单图 chart 或多图 charts 数组)
|
||||
def r_chart(self, p, idx, total):
|
||||
s = self.new()
|
||||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||||
section_no=p.get("section_no"),
|
||||
confidential=p.get("confidential"))
|
||||
lay = self.ds.layout
|
||||
cx, cy = self.margin, Inches(lay["body_top"])
|
||||
cw = self.content_w
|
||||
ch = Emu(int(Inches(lay["body_bottom"]) - Inches(lay["body_top"])))
|
||||
if p.get("charts"):
|
||||
self._draw_charts(s, p.get("charts"), cy, ch)
|
||||
elif p.get("chart"):
|
||||
self._draw_charts(s, [p.get("chart")], cy, ch)
|
||||
|
||||
def _draw_charts(self, s, charts, cy, ch):
|
||||
"""绘制一个或多个图表(并排布局)"""
|
||||
n = len(charts)
|
||||
if not n:
|
||||
return
|
||||
gap = Inches(0.3)
|
||||
cw = Emu(int((self.content_w - gap * (n - 1)) / n))
|
||||
cx = self.margin
|
||||
for i, chart in enumerate(charts):
|
||||
x = cx + i * (cw + gap)
|
||||
self._draw_chart_single(s, chart, x, cy, cw, ch)
|
||||
|
||||
def _draw_chart_single(self, s, chart, cx, cy, cw, ch):
|
||||
"""绘制单个图表(带标题),供单图/多图/表格混合调用"""
|
||||
ctype = chart.get("type", "bar")
|
||||
data = self._resolve_chart_data(chart)
|
||||
if not data:
|
||||
return
|
||||
# 面板背景
|
||||
panel = add_rect(s, cx, cy, cw, ch, self.ds, fill=self.ds.panel,
|
||||
radius=True, shadow=True)
|
||||
# 子标题(图表标题,顶部居中)
|
||||
sub_title = chart.get("title", "")
|
||||
if sub_title:
|
||||
add_text(s, cx + Inches(0.2), cy + Inches(0.12),
|
||||
cw - Inches(0.4), Inches(0.35),
|
||||
sub_title, self.ds, size=self.tp('card_title', 14),
|
||||
bold=True, color=self.ds.primary,
|
||||
align=PP_ALIGN.CENTER)
|
||||
# 绘图区(面板内,标题下方)
|
||||
inner_cy = Emu(int(cy + Inches(0.5)))
|
||||
inner_ch = Emu(int(ch - Inches(0.55)))
|
||||
# 图例:仅饼图/多系列时显示
|
||||
show_legend = ctype == "pie" or len(data) > 6
|
||||
if show_legend:
|
||||
legend_y = Emu(int(inner_cy + Inches(0.05)))
|
||||
for i, (k, _v) in enumerate(data.items()):
|
||||
lx = cx + Inches(0.3) + i * Inches(1.8)
|
||||
add_shape(s, MSO_SHAPE.RECTANGLE, lx, legend_y + Inches(0.05),
|
||||
Inches(0.2), Inches(0.2), self.ds,
|
||||
fill=self.ds.chart_colors[i % 6])
|
||||
add_text(s, lx + Inches(0.28), legend_y, Inches(1.4),
|
||||
Inches(0.3), k, self.ds,
|
||||
size=self.tp('table_cell', 10),
|
||||
color=self.ds.text_dim)
|
||||
inner_cy = Emu(int(inner_cy + Inches(0.35)))
|
||||
inner_ch = Emu(int(inner_ch - Inches(0.35)))
|
||||
if ctype == "pie":
|
||||
self._draw_pie(s, cx, inner_cy, cw, inner_ch, data)
|
||||
elif ctype == "line":
|
||||
self._draw_line(s, cx, inner_cy, cw, inner_ch, data)
|
||||
else: # bar
|
||||
self._draw_bar(s, cx, inner_cy, cw, inner_ch, data)
|
||||
|
||||
def _resolve_chart_data(self, chart):
|
||||
src = chart.get("source")
|
||||
if src and os.path.exists(src):
|
||||
ext = os.path.splitext(src)[1].lower()
|
||||
if ext == ".csv":
|
||||
with open(src, "r", encoding="utf-8-sig") as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
if rows:
|
||||
labels = [r[0] for r in rows[1:]] or [r[0] for r in rows]
|
||||
values = [float(r[1]) for r in rows[1:]]
|
||||
return dict(zip(labels, values))
|
||||
return chart.get("data", {})
|
||||
|
||||
def _draw_bar(self, s, cx, cy, cw, ch, data):
|
||||
items = list(data.items())
|
||||
if not items:
|
||||
return
|
||||
# 绘图区布局:标题/图例下方留出类别标签空间
|
||||
label_h = Inches(0.55) # 类别标签区(支持2行)
|
||||
plot_x = cx + Inches(0.7)
|
||||
plot_y = cy + Inches(0.7)
|
||||
plot_w = cw - Inches(1.4)
|
||||
plot_h = ch - Inches(0.7) - label_h - Inches(0.15)
|
||||
max_v = max(v for _, v in items) or 1
|
||||
n = len(items)
|
||||
# 柱宽:按数据量自适应
|
||||
bar_gap = Inches(0.22) if n <= 6 else Inches(0.14)
|
||||
bar_w = Emu(int((plot_w - bar_gap * (n - 1)) / n))
|
||||
# 网格线(3条水平辅助线,增强可读性)
|
||||
for gi in range(1, 4):
|
||||
gy = plot_y + Emu(int(plot_h * gi / 4))
|
||||
add_rect(s, plot_x, gy, plot_w, Pt(0.75), self.ds,
|
||||
fill=self.ds.line)
|
||||
# 基线
|
||||
base_y = plot_y + plot_h
|
||||
add_rect(s, plot_x, base_y, plot_w, Pt(2), self.ds,
|
||||
fill=self.ds.line)
|
||||
max_i = max(range(n), key=lambda i: items[i][1]) if n else 0
|
||||
for i, (k, v) in enumerate(items):
|
||||
hgt = Emu(int(plot_h * (v / max_v)))
|
||||
# 最小柱高(避免0值柱不可见)
|
||||
if hgt < Inches(0.08):
|
||||
hgt = Inches(0.08)
|
||||
bx = plot_x + i * (bar_w + bar_gap)
|
||||
by = base_y - hgt
|
||||
base_color = self.ds.chart_colors[i % 6]
|
||||
# 渐变柱体:顶部提亮40% → 底部原色(立体感)
|
||||
lighter = RGBColor(
|
||||
min(255, int(base_color[0] + (255 - base_color[0]) * 0.45)),
|
||||
min(255, int(base_color[1] + (255 - base_color[1]) * 0.45)),
|
||||
min(255, int(base_color[2] + (255 - base_color[2]) * 0.45)))
|
||||
add_gradient_rect(s, bx, by, bar_w, hgt, self.ds,
|
||||
lighter, base_color, radius=True)
|
||||
# 数值标签(柱顶上方)
|
||||
add_text(s, bx, by - Inches(0.36), bar_w, Inches(0.3),
|
||||
str(v), self.ds,
|
||||
size=self.tp('chart_label', 12), bold=True,
|
||||
color=self.ds.text, align=PP_ALIGN.CENTER)
|
||||
# 类别标签(柱下方,支持2行)
|
||||
add_text(s, bx - Inches(0.05),
|
||||
base_y + Inches(0.08),
|
||||
bar_w + Inches(0.1), label_h,
|
||||
k, self.ds, size=self.tp('chart_cat', 9),
|
||||
color=self.ds.text_dim, align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.TOP, line_spacing=1.15)
|
||||
|
||||
def _draw_line(self, s, cx, cy, cw, ch, data):
|
||||
items = list(data.items())
|
||||
if len(items) < 2:
|
||||
self._draw_bar(s, cx, cy, cw, ch, data)
|
||||
return
|
||||
plot_x = cx + Inches(0.9)
|
||||
plot_y = cy + Inches(1.0)
|
||||
plot_w = cw - Inches(1.6)
|
||||
plot_h = ch - Inches(1.6)
|
||||
max_v = max(v for _, v in items) or 1
|
||||
min_v = min(v for _, v in items) or 0
|
||||
span = (max_v - min_v) or 1
|
||||
pts = []
|
||||
step = Emu(int(plot_w / (len(items) - 1)))
|
||||
for i, (k, v) in enumerate(items):
|
||||
px = plot_x + i * step
|
||||
py = plot_y + plot_h - Emu(int(plot_h * ((v - min_v) / span)))
|
||||
pts.append((px, py))
|
||||
# 连线
|
||||
for i in range(len(pts) - 1):
|
||||
x1, y1 = pts[i]
|
||||
x2, y2 = pts[i + 1]
|
||||
mid_y = Emu(int((y1 + y2) / 2))
|
||||
ln = add_shape(s, MSO_SHAPE.RECTANGLE, x1, y1,
|
||||
Emu(max(int(x2 - x1), 1000)), Pt(3), self.ds,
|
||||
fill=self.ds.primary)
|
||||
# 点与标签
|
||||
for i, ((px, py), (k, v)) in enumerate(zip(pts, items)):
|
||||
add_shape(s, MSO_SHAPE.OVAL, px - Inches(0.07),
|
||||
py - Inches(0.07), Inches(0.14), Inches(0.14),
|
||||
self.ds, fill=self.ds.chart_colors[i % 6])
|
||||
add_text(s, px - Inches(0.5), py - Inches(0.4), Inches(1.0),
|
||||
Inches(0.3), str(v), self.ds, size=10, bold=True,
|
||||
color=self.ds.text, align=PP_ALIGN.CENTER)
|
||||
add_text(s, px - Inches(0.6), plot_y + plot_h + Inches(0.06),
|
||||
Inches(1.2), Inches(0.3), k, self.ds, size=9,
|
||||
color=self.ds.text_dim, align=PP_ALIGN.CENTER)
|
||||
|
||||
def _draw_pie(self, s, cx, cy, cw, ch, data):
|
||||
items = list(data.items())
|
||||
total = sum(v for _, v in items) or 1
|
||||
r = Inches(1.6)
|
||||
pcx = cx + Inches(3.0)
|
||||
pcy = cy + Inches(2.6)
|
||||
angle = -90.0
|
||||
from pptx.enum.shapes import MSO_SHAPE as MS
|
||||
for i, (k, v) in enumerate(items):
|
||||
frac = v / total
|
||||
sweep = frac * 360.0
|
||||
shp = add_shape(s, MS.PIE, pcx - r, pcy - r, 2 * r, 2 * r,
|
||||
self.ds, fill=self.ds.chart_colors[i % 6])
|
||||
try:
|
||||
shp.adjustments[0] = angle
|
||||
shp.adjustments[1] = angle + sweep
|
||||
except Exception:
|
||||
pass
|
||||
# 标签
|
||||
mid = math.radians(angle + sweep / 2)
|
||||
lx = pcx + Emu(int(r * 0.62 * math.cos(mid))) - Inches(0.5)
|
||||
ly = pcy + Emu(int(r * 0.62 * math.sin(mid))) - Inches(0.15)
|
||||
add_text(s, lx, ly, Inches(1.0), Inches(0.3),
|
||||
f"{k} {v}", self.ds, size=10, bold=True,
|
||||
color=self.ds.text, align=PP_ALIGN.CENTER)
|
||||
angle += sweep
|
||||
|
||||
# ---- timeline
|
||||
def r_timeline(self, p, idx, total):
|
||||
s = self.new()
|
||||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||||
section_no=p.get("section_no"),
|
||||
confidential=p.get("confidential"))
|
||||
items = p.get("content", [])
|
||||
n = len(items)
|
||||
if not n:
|
||||
return
|
||||
x0 = self.margin
|
||||
# 时间线主线位于 body 区域中部
|
||||
body_top = self.ds.layout["body_top"]
|
||||
body_bottom = self.ds.layout["body_bottom"]
|
||||
y_line = Inches(body_top + (body_bottom - body_top) * 0.28)
|
||||
line_w = self.content_w
|
||||
# 渐变主线(深→浅)
|
||||
add_rect(s, x0, y_line, line_w, Pt(3), self.ds, fill=self.ds.line)
|
||||
cw = Emu(int(line_w / n))
|
||||
for i, item in enumerate(items):
|
||||
x = x0 + i * cw
|
||||
cx = x + Inches(0.2)
|
||||
# 双圈节点:外圈描边 + 内圈实心
|
||||
add_shape(s, MSO_SHAPE.OVAL, cx - Inches(0.12), y_line - Inches(0.12),
|
||||
Inches(0.24), Inches(0.24), self.ds,
|
||||
fill=self.ds.bg, line=self.ds.primary)
|
||||
add_shape(s, MSO_SHAPE.OVAL, cx - Inches(0.06), y_line - Inches(0.06),
|
||||
Inches(0.12), Inches(0.12), self.ds,
|
||||
fill=self.ds.primary)
|
||||
# 序号(节点下方小徽章)
|
||||
add_text(s, cx - Inches(0.3), y_line - Inches(0.5), Inches(0.6),
|
||||
Inches(0.25), f"{i+1:02d}", self.ds,
|
||||
size=self.tp('timeline_desc', 9), bold=True,
|
||||
color=self.ds.text_dim, align=PP_ALIGN.CENTER)
|
||||
# 阶段
|
||||
parts = item.split('\n')
|
||||
stage = parts[0] if parts else ""
|
||||
desc = '\n'.join(parts[1:]) if len(parts) > 1 else ""
|
||||
add_text(s, x, y_line + Inches(0.25), cw - Inches(0.1),
|
||||
Inches(0.4), stage, self.ds,
|
||||
size=self.tp('timeline_title', 13), bold=True,
|
||||
color=self.ds.primary, align=PP_ALIGN.LEFT)
|
||||
if desc:
|
||||
add_text(s, x, y_line + Inches(0.65), cw - Inches(0.15),
|
||||
Inches(1.5), desc, self.ds,
|
||||
size=self.tp('timeline_desc', 10),
|
||||
color=self.ds.text_dim, line_spacing=1.3)
|
||||
|
||||
# ---- flowchart(流程/泳道图,参考日企报告会资料)
|
||||
def r_flowchart(self, p, idx, total):
|
||||
s = self.new()
|
||||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||||
section_no=p.get("section_no"),
|
||||
confidential=p.get("confidential"))
|
||||
rows = p.get("rows", []) # 泳道标题列表
|
||||
steps = p.get("content", []) # 步骤列表 ["阶段A", "阶段B", ...]
|
||||
x0, y0 = self.margin, Inches(self.ds.layout["body_top"])
|
||||
cw = self.content_w
|
||||
# 可用高度(到 body_bottom)
|
||||
avail_h = self.ds.layout["body_bottom"] - self.ds.layout["body_top"]
|
||||
if rows:
|
||||
# 泳道模式:rows 为行标题,content 为每行步骤列表
|
||||
rh = Emu(int((Inches(avail_h) - Inches(0.3) * (len(rows) - 1)) / len(rows)))
|
||||
for ri, row_title in enumerate(rows):
|
||||
ry = y0 + ri * (rh + Inches(0.3))
|
||||
# 行标题
|
||||
add_rect(s, x0, ry, Inches(1.5), rh, self.ds,
|
||||
fill=self.ds.primary, radius=True)
|
||||
add_text(s, x0, ry, Inches(1.5), rh, row_title,
|
||||
self.ds, size=self.tp('body_small', 12), bold=True,
|
||||
color=RGBColor(0xFF, 0xFF, 0xFF),
|
||||
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||||
# 该行步骤
|
||||
row_steps = steps[ri] if ri < len(steps) else []
|
||||
if isinstance(row_steps, str):
|
||||
row_steps = [row_steps]
|
||||
lane_w = Emu(int((cw - Inches(1.5) - Inches(0.2)
|
||||
- Inches(0.3) * max(len(row_steps) - 1, 0))
|
||||
/ max(len(row_steps), 1)))
|
||||
for si, step in enumerate(row_steps):
|
||||
sx = x0 + Inches(1.5) + Inches(0.1) + si * (lane_w + Inches(0.3))
|
||||
step_parts = str(step).split('\n')
|
||||
st_title = step_parts[0]
|
||||
st_desc = '\n'.join(step_parts[1:])
|
||||
card = add_rect(s, sx, ry, lane_w, rh, self.ds,
|
||||
fill=self.ds.card_fill, radius=True,
|
||||
shadow=True)
|
||||
title_h = Emu(int(rh * 0.35))
|
||||
# 序号徽章(左上角,小尺寸)
|
||||
badge = add_rect(s, sx + Inches(0.06), ry + Inches(0.08),
|
||||
Inches(0.24), Inches(0.22), self.ds,
|
||||
fill=self.ds.primary, radius=True)
|
||||
bt = badge.text_frame
|
||||
bt.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
bp = bt.paragraphs[0]
|
||||
bp.alignment = PP_ALIGN.CENTER
|
||||
br = bp.add_run()
|
||||
br.text = str(si + 1)
|
||||
set_run_font(br, 9, True, RGBColor(0xFF, 0xFF, 0xFF),
|
||||
self.ds.font_cn)
|
||||
# 标题区(徽章右侧开始)
|
||||
add_text(s, sx + Inches(0.38), ry + Inches(0.08),
|
||||
lane_w - Inches(0.5), title_h - Inches(0.1),
|
||||
st_title, self.ds,
|
||||
size=self.tp('body_small', 12), bold=True,
|
||||
color=self.ds.primary,
|
||||
align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.1)
|
||||
if st_desc:
|
||||
# 描述区:卡片下部 65%(自动换行,垂直居中)
|
||||
desc_h = Emu(int(rh - title_h))
|
||||
add_text(s, sx + Inches(0.08),
|
||||
ry + title_h + Inches(0.04),
|
||||
lane_w - Inches(0.16),
|
||||
desc_h - Inches(0.1),
|
||||
st_desc, self.ds,
|
||||
size=self.tp('timeline_desc', 10),
|
||||
color=self.ds.text_dim,
|
||||
align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.2)
|
||||
if si < len(row_steps) - 1:
|
||||
add_shape(s, MSO_SHAPE.RIGHT_ARROW,
|
||||
sx + lane_w + Inches(0.04),
|
||||
ry + Emu(int(rh / 2)) - Inches(0.1),
|
||||
Inches(0.22), Inches(0.2),
|
||||
self.ds, fill=self.ds.secondary)
|
||||
else:
|
||||
# 横向流程模式:steps 为 ["阶段A|描述", ...]
|
||||
n = len(steps)
|
||||
if not n:
|
||||
return
|
||||
# 步骤多时自动分行(每行最多4个)
|
||||
per_row = 4 if n > 4 else n
|
||||
n_rows = (n + per_row - 1) // per_row
|
||||
bh = Inches(min(1.3, (avail_h - 0.35 * (n_rows - 1)) / n_rows))
|
||||
row_gap = Inches(0.35)
|
||||
total_h = n_rows * bh + (n_rows - 1) * row_gap
|
||||
by0 = y0 + Emu(int((Inches(avail_h) - total_h) / 2))
|
||||
bw = Emu(int((cw - Inches(0.35) * (per_row - 1)) / per_row))
|
||||
for i, step in enumerate(steps):
|
||||
r_i, c_i = divmod(i, per_row)
|
||||
bx = x0 + c_i * (bw + Inches(0.35))
|
||||
by = by0 + r_i * (bh + row_gap)
|
||||
parts = str(step).split('\n')
|
||||
st_title = parts[0]
|
||||
st_desc = '\n'.join(parts[1:])
|
||||
card = add_rect(s, bx, by, bw, bh, self.ds,
|
||||
fill=self.ds.card_fill, radius=True,
|
||||
shadow=True)
|
||||
# 标题区 40%
|
||||
title_h = Emu(int(bh * 0.4))
|
||||
# 序号徽章(左上角,小尺寸)
|
||||
badge = add_rect(s, bx + Inches(0.06), by + Inches(0.08),
|
||||
Inches(0.24), Inches(0.22), self.ds,
|
||||
fill=self.ds.primary, radius=True)
|
||||
bt = badge.text_frame
|
||||
bt.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
bp = bt.paragraphs[0]
|
||||
bp.alignment = PP_ALIGN.CENTER
|
||||
br = bp.add_run()
|
||||
br.text = str(i + 1)
|
||||
set_run_font(br, 9, True, RGBColor(0xFF, 0xFF, 0xFF),
|
||||
self.ds.font_cn)
|
||||
# 标题(徽章右侧开始,避免重叠)——窄卡片自动缩小字号
|
||||
title_sz = 12
|
||||
desc_sz = 10
|
||||
if bw < Inches(1.6):
|
||||
title_sz = 9
|
||||
desc_sz = 8
|
||||
elif bw < Inches(2.2):
|
||||
title_sz = 10
|
||||
desc_sz = 9
|
||||
add_text(s, bx + Inches(0.38), by + Inches(0.08),
|
||||
bw - Inches(0.48), title_h - Inches(0.12),
|
||||
st_title, self.ds, size=title_sz, bold=True,
|
||||
color=self.ds.primary, align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.05)
|
||||
if st_desc:
|
||||
add_text(s, bx + Inches(0.08), by + title_h + Inches(0.04),
|
||||
bw - Inches(0.16), bh - title_h - Inches(0.1),
|
||||
st_desc, self.ds, size=desc_sz,
|
||||
color=self.ds.text_dim,
|
||||
align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.15)
|
||||
if c_i < per_row - 1 and i < n - 1:
|
||||
add_shape(s, MSO_SHAPE.RIGHT_ARROW,
|
||||
bx + bw + Inches(0.04),
|
||||
by + Emu(int(bh / 2)) - Inches(0.1),
|
||||
Inches(0.27), Inches(0.2),
|
||||
self.ds, fill=self.ds.secondary)
|
||||
|
||||
# ---- quote
|
||||
def r_quote(self, p, idx, total):
|
||||
s = self.new()
|
||||
pw = self.ds.page_w
|
||||
# 左侧竖条 + 引文区(动态宽度,适配10.83in窄页面)
|
||||
bar_x = self.margin
|
||||
text_x = Emu(int(self.margin + Inches(0.6)))
|
||||
text_w = Emu(int(pw - text_x - self.margin))
|
||||
add_rect(s, bar_x, Inches(2.3), Inches(0.09), Inches(2.2),
|
||||
self.ds, fill=self.ds.primary)
|
||||
title = p.get("title", "")
|
||||
# 引文字号按长度自适应
|
||||
if len(title) > 60:
|
||||
title_sz = 18
|
||||
elif len(title) > 40:
|
||||
title_sz = 20
|
||||
else:
|
||||
title_sz = 24
|
||||
add_text(s, text_x, Inches(2.6), text_w, Inches(1.6),
|
||||
title, self.ds, size=title_sz, bold=True,
|
||||
line_spacing=1.35)
|
||||
if p.get("content"):
|
||||
add_text(s, text_x, Inches(4.3), text_w, Inches(1.0),
|
||||
"\n".join(p["content"]), self.ds,
|
||||
size=self.tp('body', 15), color=self.ds.text_dim)
|
||||
if p.get("notes"):
|
||||
add_text(s, text_x, Inches(5.4), text_w, Inches(0.4),
|
||||
p["notes"], self.ds,
|
||||
size=self.tp('timeline_title', 13), bold=True,
|
||||
color=self.ds.accent)
|
||||
|
||||
# ---- end
|
||||
def r_end(self, p, idx, total):
|
||||
s = self.new()
|
||||
pw = self.ds.page_w
|
||||
title_w = Emu(int(pw - 2 * self.margin))
|
||||
title = p.get("title", "谢谢")
|
||||
# 标题字号按长度自适应(长句缩小,避免溢出)
|
||||
title_len = len(title)
|
||||
if title_len > 28:
|
||||
title_sz = 22
|
||||
elif title_len > 18:
|
||||
title_sz = 26
|
||||
else:
|
||||
title_sz = 34
|
||||
# 标题区高度也自适应(最长支持2-3行)
|
||||
title_h = Inches(1.6)
|
||||
add_text(s, self.margin, Inches(2.7), title_w, title_h,
|
||||
title, self.ds, size=title_sz, bold=True,
|
||||
align=PP_ALIGN.CENTER, line_spacing=1.3)
|
||||
if p.get("content"):
|
||||
add_text(s, self.margin, Inches(4.5),
|
||||
Emu(int(title_w - Inches(0.5))), Inches(1.2),
|
||||
"\n".join(p["content"]), self.ds, size=16,
|
||||
color=self.ds.text_dim, align=PP_ALIGN.CENTER)
|
||||
add_rect(s, self.margin, Inches(5.7), Inches(2.8), Pt(2.5),
|
||||
self.ds, fill=self.ds.primary)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 内容保真验证
|
||||
def verify_fidelity(plan_path, pptx_path, report_only=False):
|
||||
"""验证输出PPT中用户提供的数字/术语是否原样保留"""
|
||||
with open(plan_path, "r", encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
prs = Presentation(pptx_path)
|
||||
# 收集源数据(数字+关键术语)
|
||||
source_numbers = set()
|
||||
source_text = []
|
||||
def collect(obj, skip_keys=None):
|
||||
skip_keys = skip_keys or {"notes", "image"}
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if k in skip_keys:
|
||||
continue # notes 是演讲者备注;image 是本地配图路径,均不属于内容保真范围
|
||||
collect(v)
|
||||
elif isinstance(obj, list):
|
||||
for v in obj:
|
||||
collect(v)
|
||||
elif isinstance(obj, str):
|
||||
source_text.append(obj)
|
||||
for m in re.findall(r'-?\d+[\d,]*\.?\d*\s*[%%]?', obj):
|
||||
m = m.strip()
|
||||
# 排除目录编号/序号(如 "01."、"1." 紧跟标点或行首)
|
||||
if re.fullmatch(r'\d{1,2}\.?\s*', m):
|
||||
continue
|
||||
source_numbers.add(m)
|
||||
collect(plan.get("slides", []))
|
||||
# 注意:plan 顶层 title 是元数据(封面等用页面级 title),不纳入术语保真检查
|
||||
|
||||
# 收集输出文本
|
||||
out_text = []
|
||||
for slide in prs.slides:
|
||||
for shp in slide.shapes:
|
||||
if shp.has_text_frame:
|
||||
out_text.append(shp.text_frame.text)
|
||||
out_all = "\n".join(out_text)
|
||||
|
||||
# 数字保真检查
|
||||
missing = []
|
||||
for num in source_numbers:
|
||||
if num.replace(' ', '') not in out_all.replace(' ', ''):
|
||||
missing.append(num)
|
||||
# 术语检查(非纯数字的内容片段,取较长的)
|
||||
term_missing = []
|
||||
# 已知的合法结构变换:渲染器会拆分目录编号、去掉"-"子项前缀
|
||||
def normalize(s):
|
||||
s = re.sub(r'^\d{1,2}\.\s*', '', s) # 去目录编号 "01. "
|
||||
s = re.sub(r'^-\s*', '', s) # 去列表前缀 "- "
|
||||
s = s.replace('**', '')
|
||||
return s.strip()
|
||||
|
||||
for t in source_text:
|
||||
t = t.strip()
|
||||
if len(t) < 6 or not re.search(r'[\u4e00-\u9fff\u3040-\u30ff]', t):
|
||||
continue
|
||||
if '\n' in t or '**' in t:
|
||||
continue
|
||||
norm = normalize(t)
|
||||
if not norm:
|
||||
continue
|
||||
# 输出中必须能找到该术语的规范化形式(整体或核心子串)
|
||||
out_norm = normalize(out_all)
|
||||
if norm in out_all or norm in out_norm:
|
||||
continue
|
||||
# 宽松匹配:取前8个字符作为指纹
|
||||
fingerprint = norm[:8]
|
||||
if fingerprint and fingerprint in out_norm:
|
||||
continue
|
||||
term_missing.append(t)
|
||||
|
||||
# notes 写入检查:plan 中有 notes 的页面,输出备注区必须非空
|
||||
missing_notes = []
|
||||
plan_slides = plan.get("slides", [])
|
||||
out_slides = list(prs.slides)
|
||||
for idx, sp in enumerate(plan_slides):
|
||||
if not sp.get("notes"):
|
||||
continue
|
||||
if idx < len(out_slides):
|
||||
slide = out_slides[idx]
|
||||
try:
|
||||
if not (slide.has_notes_slide and
|
||||
slide.notes_slide.notes_text_frame.text.strip()):
|
||||
missing_notes.append(idx + 1)
|
||||
except Exception:
|
||||
missing_notes.append(idx + 1)
|
||||
if report_only:
|
||||
return {"missing_numbers": missing[:10],
|
||||
"missing_terms": term_missing[:10],
|
||||
"missing_notes": missing_notes,
|
||||
"total_slides": len(prs.slides)}
|
||||
return (len(missing) == 0 and len(term_missing) == 0
|
||||
and len(missing_notes) == 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 主流程
|
||||
def get_design(design_name="aura-dark"):
|
||||
"""从注册表获取设计系统实例;未知名称回退到 aura-dark"""
|
||||
cls = DESIGN_SYSTEMS.get(design_name, DesignSystem)
|
||||
return cls()
|
||||
|
||||
|
||||
def render(plan_path, output_path, design_name="aura-dark",
|
||||
template_path=None):
|
||||
with open(plan_path, "r", encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
# plan.json 中的 design 字段优先(未指定时用 CLI 参数)
|
||||
if plan.get("design"):
|
||||
design_name = plan["design"]
|
||||
ds = get_design(design_name)
|
||||
# 模板模式:用指定PPTX作为母版模板(继承背景/页脚/Logo)
|
||||
using_template = template_path is not None
|
||||
if using_template:
|
||||
prs = Presentation(template_path)
|
||||
# 清空模板自带的旧幻灯片(只保留母版/版式)
|
||||
# 通过 drop_rel 移除旧 slide 关系 + 移除 sldIdLst 引用
|
||||
from pptx.oxml.ns import qn as _qn
|
||||
sldIdLst = prs.slides._sldIdLst
|
||||
for sldId in list(sldIdLst):
|
||||
rId = sldId.get(_qn('r:id'))
|
||||
sldIdLst.remove(sldId)
|
||||
if rId:
|
||||
try:
|
||||
prs.part.drop_rel(rId)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
prs = Presentation()
|
||||
prs.slide_width = ds.page_w
|
||||
prs.slide_height = ds.page_h
|
||||
renderer = SlideRenderer(prs, ds, template=using_template)
|
||||
slides = plan.get("slides", [])
|
||||
total = len(slides)
|
||||
company = plan.get("company", "")
|
||||
date_text = plan.get("date", "")
|
||||
notes_template = plan.get("notes_template", False)
|
||||
global_confidential = plan.get("confidential", "")
|
||||
for i, sp in enumerate(slides, 1):
|
||||
# 顶层机密标记注入页面(页面级字段优先)
|
||||
sp = dict(sp)
|
||||
if "confidential" not in sp and global_confidential:
|
||||
sp["confidential"] = global_confidential
|
||||
renderer.render(sp, i, total, company=company,
|
||||
date_text=date_text, notes_template=notes_template)
|
||||
prs.save(output_path)
|
||||
return total
|
||||
|
||||
|
||||
def improve(input_path, output_path, design_name="aura-dark"):
|
||||
"""美化已有PPT:统一背景、标题样式、正文排版(内容零改动)"""
|
||||
ds = get_design(design_name)
|
||||
prs = Presentation(input_path)
|
||||
for slide in prs.slides:
|
||||
# 统一背景
|
||||
bg = slide.background
|
||||
bg.fill.solid()
|
||||
bg.fill.fore_color.rgb = ds.bg
|
||||
for shp in slide.shapes:
|
||||
if shp.has_text_frame and shp.text_frame.text.strip():
|
||||
tf = shp.text_frame
|
||||
text = tf.text
|
||||
font_size = None
|
||||
for p in tf.paragraphs:
|
||||
for r in p.runs:
|
||||
if r.font.size:
|
||||
font_size = r.font.size.pt
|
||||
break
|
||||
if font_size:
|
||||
break
|
||||
size = font_size or 16
|
||||
# 重设字体
|
||||
for p in tf.paragraphs:
|
||||
p.line_spacing = 1.3
|
||||
for r in p.runs:
|
||||
r.font.name = ds.font_cn
|
||||
rPr = r._r.get_or_add_rPr()
|
||||
for tag in ('a:ea', 'a:cs'):
|
||||
el = rPr.find(qn(tag))
|
||||
if el is None:
|
||||
el = rPr.makeelement(qn(tag), {})
|
||||
rPr.append(el)
|
||||
el.set('typeface', ds.font_cn)
|
||||
if r.font.size is None:
|
||||
r.font.size = Pt(size)
|
||||
if r.font.color is None or \
|
||||
r.font.color.rgb == RGBColor(0, 0, 0):
|
||||
r.font.color.rgb = ds.text
|
||||
prs.save(output_path)
|
||||
return len(prs.slides)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="aura-ppt 渲染引擎")
|
||||
ap.add_argument("--plan", help="plan JSON 路径")
|
||||
ap.add_argument("--output", help="输出 PPTX 路径")
|
||||
ap.add_argument("--improve", help="美化模式:输入 PPTX 路径")
|
||||
ap.add_argument("--design", default="aura-dark", help="设计系统名称")
|
||||
ap.add_argument("--template", help="模板PPTX路径(继承其母版背景/页脚/Logo)")
|
||||
ap.add_argument("--verify", help="验证模式:输出 PPTX 路径")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.verify:
|
||||
if not args.plan:
|
||||
print("[aura-ppt] --verify 需要 --plan 配合")
|
||||
sys.exit(1)
|
||||
result = verify_fidelity(args.plan, args.verify, report_only=True)
|
||||
print(f"[aura-ppt] 验证完成: {result['total_slides']} 页")
|
||||
if result["missing_numbers"]:
|
||||
print(f"[aura-ppt] ⚠ 数字保真失败: {result['missing_numbers']}")
|
||||
sys.exit(2)
|
||||
if result["missing_terms"]:
|
||||
print(f"[aura-ppt] ⚠ 术语保真失败: {result['missing_terms']}")
|
||||
sys.exit(3)
|
||||
if result.get("missing_notes"):
|
||||
print(f"[aura-ppt] ⚠ 备注缺失: {result['missing_notes']}")
|
||||
sys.exit(4)
|
||||
print("[aura-ppt] ✅ 内容保真验证通过(数字+术语+备注全部完整)")
|
||||
sys.exit(0)
|
||||
|
||||
if args.improve:
|
||||
if not args.output:
|
||||
print("[aura-ppt] 美化模式需要 --output")
|
||||
sys.exit(1)
|
||||
n = improve(args.improve, args.output, args.design)
|
||||
print(f"[aura-ppt] 美化完成: {n} 页 → {args.output}")
|
||||
sys.exit(0)
|
||||
|
||||
if not args.plan or not args.output:
|
||||
ap.print_help()
|
||||
sys.exit(1)
|
||||
n = render(args.plan, args.output, args.design, args.template)
|
||||
mode = "模板生成" if args.template else "生成"
|
||||
print(f"[aura-ppt] {mode}完成: {n} 页 → {args.output}")
|
||||
if os.path.exists(args.plan):
|
||||
ok = verify_fidelity(args.plan, args.output)
|
||||
if not ok:
|
||||
print("[aura-ppt] ⚠ 注意: 检测到内容保真偏差,请运行 --verify 查看详情")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
# aura-ppt 技能包使用说明
|
||||
|
||||
> 本地AI演示文稿引擎 | 综合 Gamma 设计感 / WPS 中文理解 / 7牛 内容保真 / Beautiful.ai 无损导出
|
||||
|
||||
## 安装位置
|
||||
|
||||
```
|
||||
C:\Users\xunhe\.config\opencode\skills\aura-ppt\
|
||||
├── SKILL.md ← 技能指令(触发条件/工作流/内容保真规则)
|
||||
├── references/
|
||||
│ ├── chinese-typography.md ← 中文排版规范
|
||||
│ ├── content-fidelity.md ← 内容保真规则(数据零改写)
|
||||
│ └── design-system.md ← 设计系统 + 全局视觉规范
|
||||
└── scripts/
|
||||
└── engine.py ← 渲染引擎(1600+行,全部能力实现)
|
||||
```
|
||||
|
||||
备份副本:`D:\AIDEPT\讯和能力知识库构建\aura-ppt\`(目录)+ `aura-ppt技能包.zip`
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 创建 plan.json(见下方结构)
|
||||
# 2. 生成 PPT
|
||||
python scripts/engine.py --plan plan.json --output out.pptx
|
||||
|
||||
# 3. 验证(必须执行)
|
||||
python scripts/engine.py --verify out.pptx --plan plan.json
|
||||
```
|
||||
|
||||
## 三种设计系统
|
||||
|
||||
| 系统名 | 风格 | 适用 | 比例 |
|
||||
|---|---|---|---|
|
||||
| `business-report` | 火焰蓝商务(白底+火焰蓝+红) | 中文管理层汇报 | 16:9 |
|
||||
| `report-jp` | 日式商务(法人蓝+浅天蓝卡片+Meiryo UI) | 日本客户/总部 | 10.83×7.5 |
|
||||
| `aura-dark` | 深色高级(深蓝黑+电光青) | 科技感/发布会 | 16:9 |
|
||||
|
||||
plan.json 的 `design` 字段选择。
|
||||
|
||||
## 使用模板母版
|
||||
|
||||
```bash
|
||||
python scripts/engine.py --plan plan.json --output out.pptx \
|
||||
--template "公司模板.pptx" # 继承母版背景/页脚/Logo
|
||||
```
|
||||
|
||||
## plan.json 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "标题",
|
||||
"design": "business-report",
|
||||
"notes_template": true,
|
||||
"slides": [
|
||||
{"type": "cover", "title": "主标题", "stats": [["70%", "说明"]]},
|
||||
{"type": "toc", "content": ["01. 章节", "02. 章节"]},
|
||||
{"type": "content", "content": ["**要点标题** | 描述"]},
|
||||
{"type": "table", "data": [[...]], "charts": [{"type":"bar","data":{}}]},
|
||||
{"type": "two_table", "tables": [{"title":"表1","data":[[...]]}, {"title":"表2","data":[[...]]}]},
|
||||
{"type": "chart", "chart": {"type": "bar", "data": {}}},
|
||||
{"type": "flowchart", "rows": ["泳道1"], "content": [["步骤A", "步骤B"]]},
|
||||
{"type": "timeline", "content": ["Phase 0\n描述"]},
|
||||
{"type": "quote", "title": "引言"},
|
||||
{"type": "end", "title": "谢谢"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 全部能力清单
|
||||
|
||||
- **封面大数字墙**(stats):右侧数字卡片(70%/6个/30-50%)
|
||||
- **要点卡片**:content 中 `**标题** | 描述` 自动渲染卡片网格
|
||||
- **双表并排**(two_table):左右表格,表头/字号自适应
|
||||
- **表格+图表混合**:table 支持 chart/charts 字段
|
||||
- **渐变柱体**:柱状图垂直渐变+网格线
|
||||
- **卡片投影**:所有卡片柔和阴影
|
||||
- **泳道流程图**:3层泳道 / 横向流程(窄卡自动缩字号)
|
||||
- **时间线**:双圈节点
|
||||
- **内容保真验证**:数字+术语+备注三合一
|
||||
- **演讲者备注**:自动生成(主题/数据/要点/过渡)
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q1: 数据会被AI改写吗?** 不会。渲染引擎是纯代码,plan.json 中的数据逐字符保留,验证器强制校验。
|
||||
|
||||
**Q2: 能处理日文吗?** 能。report-jp 用 Meiryo UI,日文标题/表格自适应。
|
||||
|
||||
**Q3: 输出能编辑吗?** 能。python-pptx 输出标准 PPTX,全矢量可编辑。
|
||||
|
||||
**Q4: 需要网络/API吗?** 不需要。纯本地 Python(python-pptx)。
|
||||
|
||||
**Q5: 如何加新设计系统?** 在 engine.py 的 DESIGN_SYSTEMS 注册表扩展(继承 DesignSystem 覆写令牌)。
|
||||
@@ -0,0 +1,8 @@
|
||||
flask==3.1.3
|
||||
python-pptx==1.0.2
|
||||
werkzeug>=3.1
|
||||
PyMuPDF==1.27.2.3
|
||||
mammoth==1.12.0
|
||||
markdownify==1.2.2
|
||||
beautifulsoup4==4.15.0
|
||||
openpyxl==3.1.5
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Agent 核心包。"""
|
||||
|
||||
from . import memory, orchestrator # noqa: F401
|
||||
@@ -0,0 +1,182 @@
|
||||
"""行动层:生成任务编排(状态机 + aura-ppt 引擎调用 + 结果落库)。
|
||||
|
||||
2026-08-24 改版:删除占位 SVG 渲染,接通 aura-ppt plan.json 真渲染链路。
|
||||
流程:build_plan → 写盘(plan/sources) → render → verify → 落库。
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from config import PROJECTS_PATH, COMPANY_TEMPLATE, COMPANY_TEMPLATE_MODE
|
||||
from src.engine_bridge import EngineError, render_plan, verify_output
|
||||
from src.agent.planning import PlanningError
|
||||
|
||||
|
||||
def _slug(text: str, max_len: int = 40) -> str:
|
||||
"""把标题转为安全的目录名片段。"""
|
||||
text = re.sub(r"[^\w\u4e00-\u9fff\u3040-\u30ff\u31f0-\u31ff]+", "_", text).strip("_")
|
||||
return (text[:max_len] or "ppt").encode("utf-8", "ignore").decode("utf-8", "ignore")
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
|
||||
def _pick_template(language: str) -> Path | None:
|
||||
"""按策略决定是否使用公司母版。"""
|
||||
if COMPANY_TEMPLATE_MODE == "off" or not COMPANY_TEMPLATE.exists():
|
||||
return None
|
||||
if COMPANY_TEMPLATE_MODE == "ja" and language != "ja":
|
||||
return None
|
||||
return COMPANY_TEMPLATE
|
||||
|
||||
|
||||
class GenerationTask:
|
||||
"""单个生成任务:持有状态与进度,供多线程更新。"""
|
||||
|
||||
def __init__(self, task_id: str, cfg, warnings: list | None = None):
|
||||
self.task_id = task_id
|
||||
self.cfg = cfg
|
||||
self.status = "pending"
|
||||
self.progress = 0
|
||||
self.message = "等待生成…"
|
||||
self.error = None
|
||||
self.warnings = list(warnings or [])
|
||||
self.record_id = None
|
||||
self.serial_lock = threading.Lock()
|
||||
|
||||
def update(self, status: str, progress: int, message: str):
|
||||
with self.serial_lock:
|
||||
self.status = status
|
||||
self.progress = progress
|
||||
self.message = message
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
with self.serial_lock:
|
||||
d = {
|
||||
"task_id": self.task_id,
|
||||
"status": self.status,
|
||||
"progress": self.progress,
|
||||
"message": self.message,
|
||||
"error": self.error,
|
||||
}
|
||||
if self.warnings:
|
||||
d["warnings"] = list(self.warnings)
|
||||
return d
|
||||
|
||||
|
||||
# 全局任务注册表
|
||||
_tasks: dict[str, GenerationTask] = {}
|
||||
_tasks_lock = threading.Lock()
|
||||
|
||||
|
||||
def register_task(task: GenerationTask) -> None:
|
||||
with _tasks_lock:
|
||||
_tasks[task.task_id] = task
|
||||
|
||||
|
||||
def get_task(task_id: str) -> GenerationTask | None:
|
||||
with _tasks_lock:
|
||||
return _tasks.get(task_id)
|
||||
|
||||
|
||||
# 生成锁:并发上限(config.MAX_CONCURRENT_GENERATION)
|
||||
_generate_semaphore = threading.Lock()
|
||||
|
||||
|
||||
def acquire_slot() -> bool:
|
||||
"""占用一个生成槽位;占满时返回 False(对应错误码 2003)。"""
|
||||
return _generate_semaphore.acquire(blocking=False)
|
||||
|
||||
|
||||
def guarded_run(task: "GenerationTask") -> None:
|
||||
"""带槽位释放的生成入口(供后台线程使用)。"""
|
||||
try:
|
||||
run_generation(task)
|
||||
finally:
|
||||
_release_slot()
|
||||
|
||||
|
||||
def _render_and_verify(project_dir: Path, plan_path: Path, output_pptx: Path,
|
||||
language: str, task: GenerationTask, progress: int) -> None:
|
||||
"""渲染 + 保真验证(失败自动重试一次)。失败抛 EngineError。"""
|
||||
template = _pick_template(language)
|
||||
render_plan(plan_path, output_pptx, template=template)
|
||||
task.update("rendering", progress, "正在验证输出…")
|
||||
ok, detail = verify_output(output_pptx, plan_path)
|
||||
if not ok:
|
||||
ok, detail = verify_output(output_pptx, plan_path)
|
||||
if not ok:
|
||||
raise EngineError("内容保真验证未通过:" + detail)
|
||||
|
||||
|
||||
def run_generation(task: GenerationTask) -> None:
|
||||
"""生成主流程(后台线程执行)。
|
||||
|
||||
产品决策(2026-08-26):取消规则降级——每轮尝试均为 LLM 总结规划,
|
||||
共两轮;两轮均失败则明确报失败,绝不产出照搬版。
|
||||
"""
|
||||
cfg = task.cfg
|
||||
start = time.time()
|
||||
try:
|
||||
plan = result = project_dir = output_pptx = None
|
||||
out_name = f"{_slug(cfg.title)}_{_now()}"
|
||||
for attempt in (1, 2):
|
||||
# ① 规划:LLM 总结 + 大纲(长文档两级通读)
|
||||
task.update("planning", 15 if attempt == 1 else 45,
|
||||
"AI 正在总结与规划结构…")
|
||||
from src.agent import planning as planning_mod
|
||||
result = planning_mod.build_plan(cfg)
|
||||
plan = result.plan
|
||||
task.warnings.extend(result.warnings)
|
||||
|
||||
# ② 项目目录与源文件
|
||||
project_dir = PROJECTS_PATH / f"{out_name}" if attempt == 1 \
|
||||
else PROJECTS_PATH / f"{out_name}_r2"
|
||||
(project_dir / "sources").mkdir(parents=True, exist_ok=True)
|
||||
(project_dir / "exports").mkdir(parents=True, exist_ok=True)
|
||||
(project_dir / "sources" / "source.md").write_text(cfg.content, encoding="utf-8")
|
||||
|
||||
# ②.5 网络配图(image_strategy=web 时启用;失败降级为无图版式)
|
||||
if getattr(cfg, "image_strategy", "off") == "web":
|
||||
from src.agent import imaging
|
||||
task.update("planning", 30 if attempt == 1 else 55, "正在获取配图…")
|
||||
task.warnings.extend(imaging.attach_images(plan["slides"], project_dir))
|
||||
|
||||
# ③ 渲染(必要时继承公司母版)
|
||||
task.update("rendering", 40 if attempt == 1 else 65, "正在排版渲染…")
|
||||
plan_path = project_dir / "plan.json"
|
||||
plan_path.write_text(
|
||||
json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8",
|
||||
)
|
||||
suffix = "" if attempt == 1 else "_r2"
|
||||
output_pptx = project_dir / "exports" / f"{_slug(cfg.title)}_{_now()}{suffix}.pptx"
|
||||
_render_and_verify(project_dir, plan_path, output_pptx,
|
||||
cfg.language, task, 85)
|
||||
break # 本轮成功
|
||||
|
||||
# ④ 落库
|
||||
from src.agent.memory import save_generated_record
|
||||
duration = int(time.time() - start)
|
||||
record_id = save_generated_record(
|
||||
user_id=cfg.user_id, cfg=cfg, project_dir=project_dir,
|
||||
export_path=output_pptx, page_count=len(plan["slides"]),
|
||||
duration=duration,
|
||||
)
|
||||
task.record_id = record_id
|
||||
task.update("done", 100, "生成完成")
|
||||
except EngineError as exc:
|
||||
detail = exc.stderr or str(exc)
|
||||
task.error = detail
|
||||
msg = f"生成失败:{detail[:80]}" if detail else "生成失败,请重试"
|
||||
task.update("failed", 100, msg)
|
||||
except PlanningError as exc:
|
||||
task.error = str(exc)
|
||||
task.update("failed", 100, str(exc)[:100])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
task.error = str(exc)
|
||||
task.update("failed", 100, "生成失败,请重试")
|
||||
@@ -0,0 +1,376 @@
|
||||
"""对话修改:解析用户修改指令 → 变更 plan.json → 重渲染 → 新版本落库。
|
||||
|
||||
设计文档 4.6 对话修改流程。说明:aura-ppt 全量重渲为秒级,等效满足
|
||||
"只改受影响内容"的体验目标(局部 pptx 编辑成本高、收益低)。
|
||||
|
||||
v1 支持意图(中/日双语):
|
||||
1. change_color 换配色:换成绿色 / カラーを緑に変更
|
||||
2. change_title 改标题:把标题改成XX / タイトルを「XX」に変更
|
||||
3. edit_page 改页内容:第2页把A改成B / 第2页删除"xx" / 第2页添加"yy"
|
||||
4. unknown 未识别 → 返回帮助文案(不重渲染)
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from config import PROJECTS_PATH, COMPANY_TEMPLATE, COMPANY_TEMPLATE_MODE
|
||||
from src.engine_bridge import EngineError, render_plan, verify_output
|
||||
from src.agent import llm, memory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 意图解析
|
||||
|
||||
_COLOR_PATTERNS = [
|
||||
(r"蓝|ブルー", "blue"), (r"绿|緑|グリーン", "green"),
|
||||
(r"紫|パープル", "purple"), (r"橙|オレンジ", "orange"),
|
||||
(r"灰|グレー", "gray"),
|
||||
]
|
||||
_COLOR_CHANGE = re.compile(r"换|改成|改为|变为|换成|配色|カラー|変更")
|
||||
|
||||
_TITLE_KEY = re.compile(r"标题|タイトル")
|
||||
_QUOTE = r"[“\"'「『『]([^”\"'」』』]+)[”\"'」』』]"
|
||||
_PAGE = re.compile(r"第\s*(\d+)\s*[页ページ]")
|
||||
_REPLACE = re.compile(
|
||||
r"(?:把|将)?\s*" + _QUOTE + r"\s*(?:改成|改为|换成|に変更|を)\s*" + _QUOTE
|
||||
)
|
||||
_DELETE = re.compile(r"删除|去掉|移除|削除|取り除く")
|
||||
_ADD = re.compile(r"加一条|添加|加上|追加|加个")
|
||||
|
||||
|
||||
def _extract_color(text: str) -> str | None:
|
||||
for pat, key in _COLOR_PATTERNS:
|
||||
if re.search(pat, text):
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def _extract_quoted(text: str) -> list:
|
||||
return [m for m in re.findall(_QUOTE, text) if m.strip()]
|
||||
|
||||
|
||||
def _pick_template(language: str) -> Path | None:
|
||||
if COMPANY_TEMPLATE_MODE == "off" or not COMPANY_TEMPLATE.exists():
|
||||
return None
|
||||
if COMPANY_TEMPLATE_MODE == "ja" and language != "ja":
|
||||
return None
|
||||
return COMPANY_TEMPLATE
|
||||
|
||||
|
||||
_INTENT_SYSTEM = (
|
||||
"你是 PPT 对话修改的意图解析器。只输出一个合法 JSON 对象,不要解释。"
|
||||
)
|
||||
|
||||
|
||||
def parse_intent_llm(message: str, plan: dict) -> dict | None:
|
||||
"""用大模型解析任意自然语言修改指令。失败/不合法返回 None(走规则兜底)。"""
|
||||
digest = []
|
||||
|
||||
def _strip(s):
|
||||
return re.sub(r"^-\s*", "", str(s))[:40]
|
||||
|
||||
for i, s in enumerate(plan.get("slides", []), 1):
|
||||
bullets = [_strip(b) for b in (s.get("content") or [])]
|
||||
digest.append(
|
||||
f"第{i}页 [{s.get('type')}] {s.get('title', '')}"
|
||||
+ ("|要点: " + ";".join(bullets) if bullets else "")
|
||||
)
|
||||
prompt = f"""解析用户对 PPT 的修改指令,输出 JSON。
|
||||
|
||||
页面清单:
|
||||
{chr(10).join(digest)}
|
||||
|
||||
支持的意图类型:
|
||||
- change_color:换配色,字段 color ∈ blue/green/purple/orange/gray
|
||||
- change_title:改整份PPT标题,字段 title
|
||||
- edit_page:改某页,字段 page_no(页码整数)+ op:
|
||||
- replace:把页内文字A换成B,字段 old / new
|
||||
- delete:删除要点,字段 text(要点包含的文字)或 index(第几条)
|
||||
- add:新增要点,字段 text
|
||||
- set:整页要点替换,字段 text
|
||||
- swap_page:交换两页顺序,字段 page_a / page_b(暂不支持就输出 unknown)
|
||||
- unknown:与修改 PPT 无关的指令
|
||||
|
||||
用户指令:{message[:500]}
|
||||
|
||||
输出格式:{{"type": "...", ...对应字段}}。页码用整数。"""
|
||||
data = llm.chat_json(prompt, system=_INTENT_SYSTEM)
|
||||
if not data or not isinstance(data, dict):
|
||||
return None
|
||||
itype = data.get("type")
|
||||
if itype == "change_color":
|
||||
color = str(data.get("color", "")).strip()
|
||||
return {"type": "change_color", "color": color} if color in \
|
||||
("blue", "green", "purple", "orange", "gray") else {"type": "unknown"}
|
||||
if itype == "change_title":
|
||||
title = str(data.get("title", "")).strip()
|
||||
return {"type": "change_title", "title": title[:80]} if title else {"type": "unknown"}
|
||||
if itype == "edit_page":
|
||||
try:
|
||||
page_no = int(data.get("page_no"))
|
||||
except (TypeError, ValueError):
|
||||
return {"type": "unknown"}
|
||||
# 规范化:指向封面页的整词替换视为改标题(保证 ppt_records.title 同步)
|
||||
if page_no <= 1 and data.get("op") == "replace":
|
||||
old, new = str(data.get("old", "")).strip(), str(data.get("new", "")).strip()
|
||||
cover_title = str((plan.get("slides") or [{}])[0].get("title", "")).strip()
|
||||
if new and (not old or old == cover_title):
|
||||
return {"type": "change_title", "title": new[:80]}
|
||||
op = data.get("op")
|
||||
if op == "replace":
|
||||
old, new = str(data.get("old", "")).strip(), str(data.get("new", "")).strip()
|
||||
if old and new:
|
||||
return {"type": "edit_page", "page_no": page_no, "op": "replace",
|
||||
"old": old, "new": new}
|
||||
elif op == "delete":
|
||||
text = str(data.get("text", "")).strip()
|
||||
try:
|
||||
index = int(data.get("index"))
|
||||
return {"type": "edit_page", "page_no": page_no, "op": "delete", "index": index}
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if text:
|
||||
return {"type": "edit_page", "page_no": page_no, "op": "delete", "text": text}
|
||||
elif op == "add":
|
||||
text = str(data.get("text", "")).strip()
|
||||
if text:
|
||||
return {"type": "edit_page", "page_no": page_no, "op": "add", "text": text}
|
||||
elif op == "set":
|
||||
text = str(data.get("text", "")).strip()
|
||||
if text:
|
||||
return {"type": "edit_page", "page_no": page_no, "op": "set", "text": text}
|
||||
return {"type": "unknown"}
|
||||
return {"type": "unknown"}
|
||||
|
||||
|
||||
def parse_intent(message: str, record: dict) -> dict:
|
||||
"""解析修改意图。返回 {type, ...params}。"""
|
||||
text = message.strip()
|
||||
|
||||
# ① 换配色
|
||||
color = _extract_color(text)
|
||||
if color and _COLOR_CHANGE.search(text):
|
||||
return {"type": "change_color", "color": color}
|
||||
|
||||
# ② 改标题
|
||||
if _TITLE_KEY.search(text) and re.search(r"改成|改为|换成|に変更|を", text):
|
||||
quotes = _extract_quoted(text)
|
||||
if quotes:
|
||||
return {"type": "change_title", "title": quotes[-1].strip()}
|
||||
m = re.search(r"(?:改成|改为|换成|に変更)(.+)$", text)
|
||||
if m:
|
||||
tail = re.sub(r"^[的は\s]+|[。..]+$", "", m.group(1)).strip()
|
||||
if tail:
|
||||
return {"type": "change_title", "title": tail}
|
||||
|
||||
# ③ 改页内容
|
||||
pm = _PAGE.search(text)
|
||||
if pm:
|
||||
page_no = int(pm.group(1))
|
||||
rep = _REPLACE.search(text)
|
||||
if rep:
|
||||
return {"type": "edit_page", "page_no": page_no, "op": "replace",
|
||||
"old": rep.group(1).strip(), "new": rep.group(2).strip()}
|
||||
if _DELETE.search(text):
|
||||
quotes = _extract_quoted(text)
|
||||
nth = re.search(r"第\s*(\d+)\s*[条つ]", text)
|
||||
if quotes:
|
||||
return {"type": "edit_page", "page_no": page_no, "op": "delete",
|
||||
"text": quotes[0].strip()}
|
||||
if nth:
|
||||
return {"type": "edit_page", "page_no": page_no, "op": "delete",
|
||||
"index": int(nth.group(1))}
|
||||
if _ADD.search(text):
|
||||
quotes = _extract_quoted(text)
|
||||
if quotes:
|
||||
return {"type": "edit_page", "page_no": page_no, "op": "add",
|
||||
"text": quotes[-1].strip()}
|
||||
# 无引号的整页替换:第N页 改成/换成 "..."(引号必带,避免误伤)
|
||||
if re.search(r"改成|改为|换成|に変更", text):
|
||||
quotes = _extract_quoted(text)
|
||||
if quotes:
|
||||
return {"type": "edit_page", "page_no": page_no, "op": "set",
|
||||
"text": quotes[-1].strip()}
|
||||
|
||||
return {"type": "unknown"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 应用变更
|
||||
|
||||
def _apply_to_plan(plan: dict, intent: dict, lang: str) -> tuple[str, dict]:
|
||||
"""把意图应用到 plan,返回 (变更说明, 记录字段更新)。抛 ValueError 表示无法执行。"""
|
||||
slides = plan["slides"]
|
||||
field_updates: dict = {}
|
||||
|
||||
if intent["type"] == "change_color":
|
||||
from src.agent.planning import COLOR_TO_DESIGN
|
||||
if lang == "ja":
|
||||
raise ValueError("design") # 日文版固定日式规格,不支持换色
|
||||
key = intent["color"]
|
||||
if key not in COLOR_TO_DESIGN:
|
||||
raise ValueError(key)
|
||||
plan["design"] = COLOR_TO_DESIGN[key]
|
||||
field_updates["color_scheme"] = key
|
||||
names = {"blue": "商务蓝", "green": "清新绿", "purple": "科技紫",
|
||||
"orange": "活力橙", "gray": "深灰"}
|
||||
return names.get(key, key), field_updates
|
||||
|
||||
if intent["type"] == "change_title":
|
||||
title = intent["title"][:80]
|
||||
plan["title"] = title
|
||||
for s in slides:
|
||||
if s["type"] == "cover":
|
||||
s["title"] = title
|
||||
field_updates["title"] = title
|
||||
return title, field_updates
|
||||
|
||||
if intent["type"] == "edit_page":
|
||||
page_no = intent["page_no"]
|
||||
if page_no < 1 or page_no > len(slides):
|
||||
raise ValueError(f"page {page_no}")
|
||||
slide = slides[page_no - 1]
|
||||
if slide["type"] not in ("content", "two_column"):
|
||||
raise ValueError(f"page {page_no} type {slide['type']}")
|
||||
bullets: list = slide.get("content") or []
|
||||
strip = lambda s: re.sub(r"^-\s*", "", s) # noqa: E731
|
||||
|
||||
op = intent["op"]
|
||||
if op == "replace":
|
||||
hit = False
|
||||
for i, b in enumerate(bullets):
|
||||
if intent["old"] in strip(b):
|
||||
bullets[i] = "- " + strip(b).replace(intent["old"], intent["new"])
|
||||
hit = True
|
||||
if not hit:
|
||||
raise ValueError("not found: " + intent["old"])
|
||||
slide["content"] = bullets
|
||||
return f"{intent['old']} → {intent['new']}", field_updates
|
||||
|
||||
if op == "delete":
|
||||
if "index" in intent:
|
||||
idx = intent["index"] - 1
|
||||
if idx < 0 or idx >= len(bullets):
|
||||
raise ValueError(f"index {intent['index']}")
|
||||
removed = strip(bullets.pop(idx))
|
||||
else:
|
||||
target = next((i for i, b in enumerate(bullets)
|
||||
if intent["text"] in strip(b)), None)
|
||||
if target is None:
|
||||
raise ValueError("not found: " + intent["text"])
|
||||
removed = strip(bullets.pop(target))
|
||||
slide["content"] = bullets
|
||||
return "删除:" + removed, field_updates
|
||||
|
||||
if op == "add":
|
||||
bullets.append("- " + intent["text"])
|
||||
slide["content"] = bullets
|
||||
return "新增:" + intent["text"], field_updates
|
||||
|
||||
if op == "set":
|
||||
slide["content"] = ["- " + intent["text"]]
|
||||
return "整页替换", field_updates
|
||||
|
||||
raise ValueError("unknown intent")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 主入口
|
||||
|
||||
_REPLY = {
|
||||
"zh": {
|
||||
"ok": "已完成修改并生成新版本(第 {v} 版):{detail}",
|
||||
"unknown": "暂不支持该指令。可试试:换成绿色 / 把标题改成XX / 第2页把A改成B / 第3页删除\"xx\" / 第2页添加\"yy\"",
|
||||
"fail": "修改未执行:{reason}",
|
||||
},
|
||||
"ja": {
|
||||
"ok": "修正完了、新バージョン(第{v}版)を生成しました:{detail}",
|
||||
"unknown": "その指示には対応していません。例:カラーを緑に変更 / タイトルを「XX」に変更 / 第2ページのAをBに変更",
|
||||
"fail": "修正を実行できませんでした:{reason}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def handle_chat(record_id: int, user_id: int, message: str) -> dict:
|
||||
"""处理一条修改指令。返回 {ok, reply, version_no, record}。"""
|
||||
record = memory.get_record_with_owner(record_id, user_id)
|
||||
if not record:
|
||||
return {"ok": False, "reply": "记录不存在", "code": 4001}
|
||||
lang = record.get("language") or "zh"
|
||||
T = _REPLY[lang]
|
||||
|
||||
memory.add_chat(record_id, "user", message)
|
||||
# 意图解析:LLM 优先(支持任意自然语言),失败回退规则
|
||||
versions = memory.get_versions(record_id)
|
||||
cur = versions[0] if versions else None
|
||||
plan = None
|
||||
if cur and cur.get("svg_dir"):
|
||||
_plan_path = PROJECTS_PATH / cur["svg_dir"] / "plan.json"
|
||||
if _plan_path.exists():
|
||||
try:
|
||||
plan = json.loads(_plan_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
plan = None
|
||||
intent = (parse_intent_llm(message, plan) if plan else None) \
|
||||
or parse_intent(message, record)
|
||||
|
||||
if intent["type"] == "unknown":
|
||||
reply = T["unknown"]
|
||||
memory.add_chat(record_id, "assistant", reply)
|
||||
return {"ok": True, "reply": reply, "changed": False}
|
||||
|
||||
# 定位项目目录与 plan(意图解析阶段已加载)
|
||||
if not cur or not cur.get("svg_dir") or plan is None:
|
||||
reply = T["fail"].format(reason="找不到原始工程文件")
|
||||
memory.add_chat(record_id, "assistant", reply)
|
||||
return {"ok": False, "reply": reply, "code": 4002}
|
||||
project_dir = PROJECTS_PATH / cur["svg_dir"]
|
||||
plan_path = project_dir / "plan.json"
|
||||
if not plan_path.exists():
|
||||
reply = T["fail"].format(reason="plan.json 已丢失")
|
||||
memory.add_chat(record_id, "assistant", reply)
|
||||
return {"ok": False, "reply": reply, "code": 4002}
|
||||
|
||||
try:
|
||||
detail, field_updates = _apply_to_plan(plan, intent, lang)
|
||||
except ValueError as exc:
|
||||
reason = "日文版固定使用日式报告规格,暂不支持换色" if str(exc) == "design" \
|
||||
else str(exc)
|
||||
reply = T["fail"].format(reason=reason)
|
||||
memory.add_chat(record_id, "assistant", reply)
|
||||
return {"ok": False, "reply": reply, "changed": False}
|
||||
|
||||
# 重渲染 + 验证(先把变更后的 plan 写回磁盘)
|
||||
try:
|
||||
plan_path.write_text(
|
||||
json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8",
|
||||
)
|
||||
out_name = f"{record_id}_v{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pptx"
|
||||
output_pptx = project_dir / "exports" / out_name
|
||||
render_plan(plan_path, output_pptx,
|
||||
template=_pick_template(record.get("language") or "zh"))
|
||||
ok, detail_v = verify_output(output_pptx, plan_path)
|
||||
if not ok:
|
||||
ok, detail_v = verify_output(output_pptx, plan_path)
|
||||
if not ok:
|
||||
raise EngineError("保真验证未通过:" + detail_v)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
reply = T["fail"].format(reason=str(exc)[:120])
|
||||
memory.add_chat(record_id, "assistant", reply)
|
||||
return {"ok": False, "reply": reply, "changed": False}
|
||||
|
||||
# 版本落库(两版本策略)
|
||||
rel_file = str(output_pptx.relative_to(project_dir))
|
||||
version_id = memory.save_version(record_id, rel_file, project_dir.name, message)
|
||||
if field_updates:
|
||||
memory.update_record_fields(record_id, field_updates)
|
||||
|
||||
new_ver = memory.get_versions(record_id)[0]
|
||||
reply = T["ok"].format(v=new_ver["version_no"], detail=detail)
|
||||
memory.add_chat(record_id, "assistant", reply)
|
||||
updated = memory.get_record_with_owner(record_id, user_id)
|
||||
return {
|
||||
"ok": True, "reply": reply, "changed": True,
|
||||
"version_no": new_ver["version_no"],
|
||||
"file_size": updated.get("file_size"),
|
||||
"record": updated,
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""网络配图:为 plan 内容页获取配图并就地附加 image 路径。
|
||||
|
||||
分工(引擎约束:只经 CLI 子进程调用 ppt-master 脚本):
|
||||
IMAGE_SEARCH_MODE=web → source_to_md 同级的 image_search.py
|
||||
(openverse / wikimedia 零配置,pexels/pixabay 配 Key 可用)
|
||||
IMAGE_SEARCH_MODE=test → 本地样例图目录(e2e 确定性通道)
|
||||
IMAGE_SEARCH_MODE=off → 不配图
|
||||
|
||||
设计原则:单页失败只记告警,绝不阻断生成(优雅降级为无图版式)。
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from config import (PPT_MASTER_PATH, IMAGE_SEARCH_MODE, IMAGE_SEARCH_TEST_DIR,
|
||||
IMAGE_SEARCH_TIMEOUT, MAX_IMAGE_SLIDES)
|
||||
|
||||
SCRIPTS_DIR = PPT_MASTER_PATH / "scripts"
|
||||
|
||||
|
||||
def attach_images(slides: list, project_dir) -> list:
|
||||
"""就地为内容页附加 image 字段(最多 MAX_IMAGE_SLIDES 页)。返回告警列表。"""
|
||||
warnings = []
|
||||
if IMAGE_SEARCH_MODE == "off" or not slides:
|
||||
return warnings
|
||||
targets = [sl for sl in slides if sl.get("type", "content") == "content"][:MAX_IMAGE_SLIDES]
|
||||
images_dir = Path(project_dir) / "images"
|
||||
ok = 0
|
||||
for i, slide in enumerate(targets):
|
||||
query = _query_from(slide)
|
||||
dest = images_dir / f"slide_{chr(ord('a') + i % 26)}.jpg"
|
||||
try:
|
||||
if IMAGE_SEARCH_MODE == "test":
|
||||
path = _from_test_dir(i, dest)
|
||||
else:
|
||||
path = _search_web(query, dest) if query else None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
warnings.append(f"配图失败({query or '无关键词'}):{exc}")
|
||||
path = None
|
||||
if path:
|
||||
slide["image"] = str(path)
|
||||
ok += 1
|
||||
if targets and ok == 0:
|
||||
warnings.append("网络配图不可用,已按无图版式生成")
|
||||
elif targets and ok < len(targets):
|
||||
warnings.append(f"部分页面配图失败,成功 {ok}/{len(targets)} 页")
|
||||
return warnings
|
||||
|
||||
|
||||
def _query_from(slide: dict) -> str:
|
||||
"""取页面标题为搜图关键词(2-5 词效果最好),去标点限长。"""
|
||||
title = str(slide.get("title", ""))
|
||||
title = re.sub(r"[^\w\u4e00-\u9fff\u3040-\u30ff ]+", " ", title).strip()
|
||||
return " ".join(title.split()[:5])[:40]
|
||||
|
||||
|
||||
def _from_test_dir(i: int, dest: Path) -> Path:
|
||||
srcs = sorted(Path(IMAGE_SEARCH_TEST_DIR).glob("*.jpg")) if IMAGE_SEARCH_TEST_DIR else []
|
||||
if not srcs:
|
||||
raise RuntimeError("测试图目录为空或未配置 IMAGE_SEARCH_TEST_DIR")
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(srcs[i % len(srcs)], dest)
|
||||
return dest
|
||||
|
||||
|
||||
def _search_web(query: str, dest: Path):
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(SCRIPTS_DIR / "image_search.py"), query,
|
||||
"--filename", dest.name, "--orientation", "landscape",
|
||||
"--min-width", "800", "--min-height", "500",
|
||||
"-o", str(dest.parent)],
|
||||
cwd=str(SCRIPTS_DIR), capture_output=True, timeout=IMAGE_SEARCH_TIMEOUT,
|
||||
env={**os.environ, "PYTHONIOENCODING": "utf-8"},
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError("搜图超时")
|
||||
if proc.returncode != 0 or not dest.exists():
|
||||
tail = (proc.stderr or proc.stdout or b"").decode("utf-8", "ignore")[-120:].strip()
|
||||
raise RuntimeError(tail.splitlines()[-1] if tail else "无可下载结果")
|
||||
return dest
|
||||
@@ -0,0 +1,171 @@
|
||||
"""大模型客户端:统一 chat() 入口,支持 opencode serve / OpenAI 兼容两种后端。
|
||||
|
||||
设计原则:
|
||||
- 零第三方依赖(urllib 直连)
|
||||
- 任何失败返回 None,调用方自行降级到规则模式——LLM 不可用不阻塞系统
|
||||
- opencode 模式自动拉起 serve(本机无 Key 的默认方案)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
from config import (
|
||||
LLM_BACKEND, LLM_TIMEOUT,
|
||||
OPENCODE_SERVE, OPENCODE_SERVE_PORT, OPENCODE_MODEL,
|
||||
OPENAI_API_BASE, OPENAI_API_KEY, OPENAI_MODEL,
|
||||
)
|
||||
|
||||
_serve_proc = None
|
||||
_health_cache = {"ok": False, "ts": 0.0}
|
||||
_HEALTH_TTL = 30.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 健康与拉起
|
||||
|
||||
def _health_ok(timeout: float = 1.5) -> bool:
|
||||
"""探测 opencode serve 是否存活(带 TTL 缓存)。"""
|
||||
now = time.time()
|
||||
if now - _health_cache["ts"] < _HEALTH_TTL:
|
||||
return _health_cache["ok"]
|
||||
ok = False
|
||||
try:
|
||||
with urllib.request.urlopen(OPENCODE_SERVE + "/global/health", timeout=timeout) as r:
|
||||
ok = (r.status == 200)
|
||||
except Exception: # noqa: BLE001
|
||||
ok = False
|
||||
_health_cache["ok"] = ok
|
||||
_health_cache["ts"] = now
|
||||
return ok
|
||||
|
||||
|
||||
def _start_serve(wait: float = 20.0) -> bool:
|
||||
"""拉起 opencode serve(分离进程),轮询等待就绪。"""
|
||||
global _serve_proc
|
||||
exe = shutil.which("opencode")
|
||||
if not exe:
|
||||
return False
|
||||
try:
|
||||
flags = 0
|
||||
if os.name == "nt":
|
||||
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) | getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
_serve_proc = subprocess.Popen(
|
||||
[exe, "serve", "--port", str(OPENCODE_SERVE_PORT)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
creationflags=flags,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
deadline = time.time() + wait
|
||||
while time.time() < deadline:
|
||||
_health_cache["ts"] = 0.0
|
||||
if _health_ok():
|
||||
return True
|
||||
time.sleep(1.0)
|
||||
return False
|
||||
|
||||
|
||||
def ensure_backend() -> bool:
|
||||
"""确保 LLM 后端可用。不可用返回 False(调用方降级)。"""
|
||||
if LLM_BACKEND == "off":
|
||||
return False
|
||||
if LLM_BACKEND == "openai":
|
||||
return bool(OPENAI_API_BASE and OPENAI_API_KEY)
|
||||
# opencode 模式
|
||||
if _health_ok():
|
||||
return True
|
||||
return _start_serve()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 调用
|
||||
|
||||
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def _strip_noise(text: str) -> str:
|
||||
text = _ANSI.sub("", text or "")
|
||||
text = re.sub(r"^\[[^\]]*\]\s*(plugin[^\n]*\n?)", "", text, flags=re.I)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _call_opencode(prompt: str, system: str | None) -> str | None:
|
||||
def _post(path, payload, timeout):
|
||||
req = urllib.request.Request(
|
||||
OPENCODE_SERVE + path, data=json.dumps(payload).encode("utf-8"),
|
||||
method="POST", headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read() or b"{}")
|
||||
|
||||
try:
|
||||
sess = _post("/session", {"title": "ppt-agent"}, 30)
|
||||
sid = sess["id"]
|
||||
provider, _, model = OPENCODE_MODEL.partition("/")
|
||||
body = {
|
||||
"model": {"providerID": provider or "opencode", "modelID": model or "x-preview-f-free"},
|
||||
"parts": [{"type": "text", "text": prompt}],
|
||||
}
|
||||
if system:
|
||||
body["system"] = system
|
||||
result = _post(f"/session/{sid}/message", body, LLM_TIMEOUT)
|
||||
parts = result.get("parts") or []
|
||||
text = "\n".join(p.get("text", "") for p in parts if p.get("type") == "text")
|
||||
text = _strip_noise(text)
|
||||
return text or None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _call_openai(prompt: str, system: str | None, json_mode: bool) -> str | None:
|
||||
body = {
|
||||
"model": OPENAI_MODEL,
|
||||
"messages": ([{"role": "system", "content": system}] if system else [])
|
||||
+ [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.4,
|
||||
}
|
||||
if json_mode:
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
req = urllib.request.Request(
|
||||
OPENAI_API_BASE.rstrip("/") + "/chat/completions",
|
||||
data=json.dumps(body).encode("utf-8"), method="POST",
|
||||
headers={"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + OPENAI_API_KEY},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as r:
|
||||
data = json.loads(r.read())
|
||||
return (data["choices"][0]["message"]["content"] or "").strip() or None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def chat(prompt: str, system: str | None = None, json_mode: bool = False) -> str | None:
|
||||
"""调用大模型。成功返回文本;任何失败返回 None(调用方降级)。"""
|
||||
if not ensure_backend():
|
||||
return None
|
||||
if LLM_BACKEND == "openai":
|
||||
return _call_openai(prompt, system, json_mode)
|
||||
if json_mode:
|
||||
# opencode 模式无 response_format,靠提示词约束 + 调用方解析校验
|
||||
prompt = prompt + "\n\n【输出要求】只输出一个合法 JSON 对象,不要任何解释文字或代码块标记。"
|
||||
return _call_opencode(prompt, system)
|
||||
|
||||
|
||||
def chat_json(prompt: str, system: str | None = None):
|
||||
"""调用大模型并解析 JSON。失败返回 None。"""
|
||||
text = chat(prompt, system=system, json_mode=True)
|
||||
if not text:
|
||||
return None
|
||||
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip(), flags=re.S)
|
||||
start, end = text.find("{"), text.rfind("}")
|
||||
if start < 0 or end <= start:
|
||||
return None
|
||||
try:
|
||||
return json.loads(text[start:end + 1])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
@@ -0,0 +1,158 @@
|
||||
"""记忆层:SQLite 读写(用户/偏好/生成记录/版本)。"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from src.db import execute, query_all, query_one
|
||||
|
||||
|
||||
# ===== 用户 =====
|
||||
|
||||
def create_user(username: str, password_hash: str) -> int:
|
||||
return execute(
|
||||
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||
(username, password_hash),
|
||||
)
|
||||
|
||||
|
||||
def get_user_by_name(username: str):
|
||||
return query_one("SELECT * FROM users WHERE username = ?", (username,))
|
||||
|
||||
|
||||
def get_user_by_id(user_id: int):
|
||||
return query_one("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||
|
||||
|
||||
def update_username(user_id: int, username: str) -> None:
|
||||
execute("UPDATE users SET username = ? WHERE id = ?", (username, user_id))
|
||||
|
||||
|
||||
def update_password(user_id: int, password_hash: str) -> None:
|
||||
execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id))
|
||||
|
||||
|
||||
# ===== 偏好 =====
|
||||
|
||||
def get_preferences(user_id: int) -> dict | None:
|
||||
return query_one("SELECT * FROM preferences WHERE user_id = ?", (user_id,))
|
||||
|
||||
|
||||
def create_preferences(user_id: int) -> None:
|
||||
execute(
|
||||
"INSERT INTO preferences (user_id) VALUES (?)", (user_id,),
|
||||
)
|
||||
|
||||
|
||||
def save_preferences(user_id: int, prefs: dict) -> None:
|
||||
"""保存生成偏好(只更新提供的字段)。"""
|
||||
allowed = {
|
||||
"default_scene", "default_color", "default_language", "default_canvas",
|
||||
"default_image_strategy", "default_page_min", "default_page_max",
|
||||
}
|
||||
fields = {k: v for k, v in prefs.items() if k in allowed}
|
||||
if not fields:
|
||||
return
|
||||
sets = ", ".join(f"{k} = ?" for k in fields)
|
||||
vals = list(fields.values()) + [user_id]
|
||||
execute(f"UPDATE preferences SET {sets} WHERE user_id = ?", tuple(vals))
|
||||
|
||||
|
||||
# ===== 生成记录 =====
|
||||
|
||||
def save_generated_record(user_id: int, cfg, project_dir: Path, export_path: Path,
|
||||
page_count: int, duration: int) -> int:
|
||||
"""保存生成记录,并写入首个版本(version_no=1)。返回 record_id。"""
|
||||
file_size = export_path.stat().st_size if export_path.exists() else None
|
||||
record_id = execute(
|
||||
"""INSERT INTO ppt_records
|
||||
(user_id, title, content, scene, language, canvas, image_strategy, color_scheme,
|
||||
page_min, page_max, status, file_size, page_count, generate_duration)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'done', ?, ?, ?)""",
|
||||
(
|
||||
user_id, cfg.title, cfg.content, cfg.scene, cfg.language, cfg.canvas,
|
||||
cfg.image_strategy, cfg.color_scheme, cfg.page_min, cfg.page_max,
|
||||
file_size, page_count, duration,
|
||||
),
|
||||
)
|
||||
rel_file = str(Path(export_path).relative_to(project_dir))
|
||||
rel_svg = project_dir.name
|
||||
execute(
|
||||
"""INSERT INTO versions (record_id, version_no, file_path, svg_dir, change_note)
|
||||
VALUES (?, 1, ?, ?, '首次生成')""",
|
||||
(record_id, rel_file, rel_svg),
|
||||
)
|
||||
return record_id
|
||||
|
||||
|
||||
def save_version(record_id: int, file_path: str, svg_dir: str, change_note: str = None) -> int:
|
||||
"""两版本策略:删除旧version_no=2,原version_no=1降为2,新记录为1。"""
|
||||
# 清除旧第二版
|
||||
execute("DELETE FROM versions WHERE record_id = ? AND version_no = 2", (record_id,))
|
||||
# 原第一版 → 第二版
|
||||
execute("UPDATE versions SET version_no = 2 WHERE record_id = ? AND version_no = 1", (record_id,))
|
||||
# 新第一版
|
||||
return execute(
|
||||
"INSERT INTO versions (record_id, version_no, file_path, svg_dir, change_note) VALUES (?, 1, ?, ?, ?)",
|
||||
(record_id, file_path, svg_dir, change_note),
|
||||
)
|
||||
|
||||
|
||||
def get_record(record_id: int):
|
||||
return query_one("SELECT * FROM ppt_records WHERE id = ?", (record_id,))
|
||||
|
||||
|
||||
def get_record_with_owner(record_id: int, user_id: int):
|
||||
return query_one(
|
||||
"SELECT * FROM ppt_records WHERE id = ? AND user_id = ?", (record_id, user_id),
|
||||
)
|
||||
|
||||
|
||||
def list_records(user_id: int, keyword: str = "", scene: str = "", language: str = "") -> list:
|
||||
"""历史列表(搜索/筛选/时间倒序,未分页)。"""
|
||||
sql = "SELECT * FROM ppt_records WHERE user_id = ?"
|
||||
params: list = [user_id]
|
||||
if keyword:
|
||||
sql += " AND title LIKE ?"
|
||||
params.append(f"%{keyword}%")
|
||||
if scene:
|
||||
sql += " AND scene = ?"
|
||||
params.append(scene)
|
||||
if language:
|
||||
sql += " AND language = ?"
|
||||
params.append(language)
|
||||
sql += " ORDER BY created_at DESC"
|
||||
return query_all(sql, tuple(params))
|
||||
|
||||
|
||||
def get_versions(record_id: int) -> list:
|
||||
"""版本回查(两版:1=当前,2=上一版)。"""
|
||||
return query_all("SELECT * FROM versions WHERE record_id = ? ORDER BY version_no", (record_id,))
|
||||
|
||||
|
||||
def update_record_fields(record_id: int, fields: dict) -> None:
|
||||
"""更新生成记录的可变字段(标题/配色等,对话修改时同步)。"""
|
||||
allowed = {"title", "color_scheme"}
|
||||
sets = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not sets:
|
||||
return
|
||||
assign = ", ".join(f"{k} = ?" for k in sets)
|
||||
execute(f"UPDATE ppt_records SET {assign} WHERE id = ?", tuple(sets.values()) + (record_id,))
|
||||
|
||||
|
||||
def delete_record(record_id: int, user_id: int) -> bool:
|
||||
cur = execute("DELETE FROM ppt_records WHERE id = ? AND user_id = ?", (record_id, user_id))
|
||||
return cur > 0
|
||||
|
||||
|
||||
# ===== 对话 =====
|
||||
|
||||
def add_chat(record_id: int, role: str, content: str) -> int:
|
||||
return execute(
|
||||
"INSERT INTO chat_messages (record_id, role, content) VALUES (?, ?, ?)",
|
||||
(record_id, role, content),
|
||||
)
|
||||
|
||||
|
||||
def list_chat(record_id: int) -> list:
|
||||
return query_all(
|
||||
"SELECT * FROM chat_messages WHERE record_id = ? ORDER BY created_at, id", (record_id,),
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""总调度:串联感知→规划→行动→记忆,对外提供生成/状态/结果入口。"""
|
||||
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
from src.agent.action import (
|
||||
GenerationTask, register_task, get_task, acquire_slot, guarded_run,
|
||||
)
|
||||
from src.agent.perception import resolve_config, validate_config
|
||||
from src.agent import memory
|
||||
|
||||
|
||||
def start_generation(payload: dict, user_id: int) -> dict:
|
||||
"""触发一次生成:校验 → 占槽位 → 建任务 → 后台线程执行。"""
|
||||
prefs = memory.get_preferences(user_id)
|
||||
cfg = resolve_config(payload, user_id, prefs)
|
||||
validate_config(cfg)
|
||||
if cfg.errors:
|
||||
return {"ok": False, "errors": cfg.errors}
|
||||
|
||||
if not acquire_slot():
|
||||
return {"ok": False, "errors": [
|
||||
{"code": 2003, "message": "引擎繁忙,请稍后重试"},
|
||||
]}
|
||||
|
||||
task = GenerationTask(task_id=f"gen_{uuid.uuid4().hex[:12]}", cfg=cfg)
|
||||
register_task(task)
|
||||
task.update("analyzing", 5, "正在分析内容…")
|
||||
th = threading.Thread(target=guarded_run, args=(task,), daemon=True)
|
||||
th.start()
|
||||
return {"ok": True, "task_id": task.task_id, "status": task.status}
|
||||
|
||||
|
||||
def get_generation_status(task_id: str) -> dict:
|
||||
task = get_task(task_id)
|
||||
return task.to_dict() if task else None
|
||||
|
||||
|
||||
def get_generation_result(task_id: str) -> dict:
|
||||
"""生成完成后返回结果(含 record_id、文件信息)。"""
|
||||
task = get_task(task_id)
|
||||
if not task:
|
||||
return {"ok": False, "message": "任务不存在"}
|
||||
if task.status != "done":
|
||||
return {"ok": False, "task": task.to_dict()}
|
||||
record = memory.get_record(task.record_id)
|
||||
return {"ok": True, "record": record}
|
||||
|
||||
|
||||
def prefill_from_preferences(user_id: int) -> dict:
|
||||
"""登录后把偏好带回前端初始化配置。"""
|
||||
prefs = memory.get_preferences(user_id)
|
||||
if not prefs:
|
||||
return {
|
||||
"default_scene": "training", "default_color": "blue", "default_language": "zh",
|
||||
"default_canvas": "ppt169", "default_image_strategy": "web",
|
||||
"default_page_min": 10, "default_page_max": 15,
|
||||
}
|
||||
return prefs
|
||||
@@ -0,0 +1,73 @@
|
||||
"""感知层:输入解析 + 配置合并 + 校验。"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from config import (
|
||||
ALLOWED_SCENES, ALLOWED_COLORS, ALLOWED_LANGS, ALLOWED_CANVAS,
|
||||
ALLOWED_IMAGE_STRATEGIES,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerateConfig:
|
||||
"""一次生成任务的完整配置(请求参数 + 用户偏好合并结果)。"""
|
||||
title: str = ""
|
||||
content: str = ""
|
||||
scene: str = "training"
|
||||
language: str = "zh"
|
||||
canvas: str = "ppt169"
|
||||
image_strategy: str = "web"
|
||||
color_scheme: str = "blue"
|
||||
page_min: int = 10
|
||||
page_max: int = 15
|
||||
user_id: int = 0
|
||||
errors: list = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_input(title: str, content: str) -> dict:
|
||||
"""清洗输入,返回结构化内容对象。"""
|
||||
title = (title or "").strip()
|
||||
content = (content or "").strip()
|
||||
return {"title": title, "content": content, "char_count": len(content)}
|
||||
|
||||
|
||||
def resolve_config(payload: dict, user_id: int, preferences: dict | None = None) -> GenerateConfig:
|
||||
"""合并请求参数 + 用户偏好,返回完整配置。"""
|
||||
prefs = preferences or {}
|
||||
|
||||
scene = payload.get("scene") or prefs.get("default_scene") or "training"
|
||||
language = payload.get("language") or prefs.get("default_language") or "zh"
|
||||
canvas = payload.get("canvas") or prefs.get("default_canvas") or "ppt169"
|
||||
image_strategy = payload.get("image_strategy") or prefs.get("default_image_strategy") or "web"
|
||||
color_scheme = payload.get("color_scheme") or prefs.get("default_color") or "blue"
|
||||
page_min = payload.get("page_min") or prefs.get("default_page_min") or 10
|
||||
page_max = payload.get("page_max") or prefs.get("default_page_max") or 15
|
||||
|
||||
cfg = GenerateConfig(
|
||||
title=(payload.get("title") or "").strip(),
|
||||
content=(payload.get("content") or "").strip(),
|
||||
scene=scene, language=language, canvas=canvas,
|
||||
image_strategy=image_strategy, color_scheme=color_scheme,
|
||||
page_min=int(page_min), page_max=int(page_max), user_id=user_id,
|
||||
)
|
||||
return cfg
|
||||
|
||||
|
||||
def validate_config(cfg: GenerateConfig) -> None:
|
||||
"""校验生成配置,把错误信息写入 cfg.errors(对应错误码 2001/2002)。"""
|
||||
errors = []
|
||||
if cfg.content and len(cfg.content) < 20:
|
||||
errors.append({"code": 2001, "message": "内容至少需要 20 个字"})
|
||||
if cfg.page_min > cfg.page_max:
|
||||
errors.append({"code": 2002, "message": "最少页数不能大于最多页数"})
|
||||
if cfg.scene not in ALLOWED_SCENES:
|
||||
errors.append({"code": 2002, "message": "不支持的生成场景"})
|
||||
if cfg.color_scheme not in ALLOWED_COLORS:
|
||||
errors.append({"code": 2002, "message": "不支持的配色方案"})
|
||||
if cfg.language not in ALLOWED_LANGS:
|
||||
errors.append({"code": 2002, "message": "不支持的生成语言"})
|
||||
if cfg.canvas not in ALLOWED_CANVAS:
|
||||
errors.append({"code": 2002, "message": "不支持的画布格式"})
|
||||
if cfg.image_strategy not in ALLOWED_IMAGE_STRATEGIES:
|
||||
errors.append({"code": 2002, "message": "不支持的配图策略"})
|
||||
cfg.errors = errors
|
||||
@@ -0,0 +1,295 @@
|
||||
"""规划层:把用户配置映射为 aura-ppt 引擎可执行的 plan.json。
|
||||
|
||||
2026-08-26 改版(产品决策:取消规则降级):
|
||||
- 纯 LLM 总结模式:要么产出总结版大纲,要么抛 PlanningError 明确失败
|
||||
- 长文档两级规划:分段通读提炼 → 汇总产出大纲,全文覆盖不静默截断
|
||||
对应系统详细设计第5章。
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
|
||||
from config import PLAN_CHUNK_CHARS, PLAN_MAX_CHUNKS
|
||||
from src.agent import llm
|
||||
|
||||
# 配色 → 设计系统名(engine.py DESIGN_SYSTEMS 注册表)
|
||||
COLOR_TO_DESIGN = {
|
||||
"blue": "corp-blue",
|
||||
"green": "corp-green",
|
||||
"purple": "corp-purple",
|
||||
"orange": "corp-orange",
|
||||
"gray": "corp-gray",
|
||||
}
|
||||
|
||||
# 日语生成固定走日式报告规格(法人蓝 #0B3D91,与本部长报告会母版对齐)
|
||||
JA_DESIGN = "report-jp"
|
||||
|
||||
# LLM 大纲允许的页面类型
|
||||
_LLM_TYPES = {"cover", "toc", "section", "content", "two_column", "table", "end"}
|
||||
|
||||
_OUTLINE_SYSTEM = (
|
||||
"你是资深 PPT 结构规划师。只输出一个合法 JSON 对象,不要解释、不要代码块标记。"
|
||||
)
|
||||
|
||||
_NUM_TOKEN = re.compile(r"\d+(?:[.,,]\d+)?%?")
|
||||
|
||||
|
||||
class PlanningError(Exception):
|
||||
"""LLM 规划最终失败(已重试),向用户明确报错——不做静默降级。"""
|
||||
|
||||
|
||||
def _pick_design(cfg) -> str:
|
||||
return JA_DESIGN if cfg.language == "ja" else COLOR_TO_DESIGN.get(cfg.color_scheme, "corp-blue")
|
||||
|
||||
|
||||
def _labels(lang: str) -> dict:
|
||||
return {
|
||||
"zh": {"toc": "目录", "end_title": "谢谢聆听",
|
||||
"end_body": ["以上为本次汇报的全部内容", "欢迎提问与指导"],
|
||||
"cover_sub": "自动生成演示文稿"},
|
||||
"ja": {"toc": "目次", "end_title": "ご清聴ありがとうございました",
|
||||
"end_body": ["以上でご報告を終わります", "ご質問・ご指導をお願いいたします"],
|
||||
"cover_sub": "自動生成プレゼンテーション"},
|
||||
}[lang]
|
||||
|
||||
|
||||
def _sanitize_fidelity(slides: list, source: str) -> None:
|
||||
"""数字保真消毒:剔除 LLM 编造的、源内容中不存在的数字要点/标题数字。"""
|
||||
src_nums = set(_NUM_TOKEN.findall(source or ""))
|
||||
for s in slides:
|
||||
if s["type"] in ("cover", "toc", "end"):
|
||||
continue
|
||||
kept = []
|
||||
for b in s.get("content", []):
|
||||
nums = _NUM_TOKEN.findall(b)
|
||||
if all(n in src_nums for n in nums):
|
||||
kept.append(b)
|
||||
s["content"] = kept or s.get("content", [])
|
||||
title = s.get("title", "")
|
||||
if title and any(n not in src_nums for n in _NUM_TOKEN.findall(title)):
|
||||
s["title"] = _NUM_TOKEN.sub("", title).replace(" ", " ").strip(" ::、-—")
|
||||
|
||||
|
||||
def _validate_slides(slides: list, cfg, lo: int, hi: int) -> list | None:
|
||||
"""严格校验/清洗 LLM 产出,不合格返回 None。页数超上限直接裁剪(硬约束)。"""
|
||||
cleaned = []
|
||||
for s in slides:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
stype = str(s.get("type", "content")).strip()
|
||||
if stype not in _LLM_TYPES:
|
||||
stype = "content"
|
||||
title = str(s.get("title", "")).strip()[:60]
|
||||
content = [str(x).strip()[:120] for x in (s.get("content") or []) if str(x).strip()]
|
||||
notes = str(s.get("notes", "")).strip()[:500] or None
|
||||
if not title and not content:
|
||||
continue
|
||||
item = {"type": stype, "title": title, "content": content[:10]}
|
||||
if notes:
|
||||
item["notes"] = notes
|
||||
cleaned.append(item)
|
||||
if len(cleaned) < 4:
|
||||
return None
|
||||
labels = _labels(cfg.language)
|
||||
if cleaned[0].get("type") != "cover":
|
||||
cleaned.insert(0, {"type": "cover", "title": cfg.title or "Untitled",
|
||||
"content": [], "subtitle": labels["cover_sub"]})
|
||||
if cleaned[-1].get("type") != "end":
|
||||
cleaned.append({"type": "end", "title": labels["end_title"],
|
||||
"content": labels["end_body"]})
|
||||
# 页数超上限:优先丢弃中段 section,再丢尾部 content(硬性保证 ≤ hi)
|
||||
while len(cleaned) > hi:
|
||||
mid = [i for i, s in enumerate(cleaned) if s["type"] == "section"]
|
||||
if mid:
|
||||
cleaned.pop(mid[-1])
|
||||
continue
|
||||
contents = [i for i, s in enumerate(cleaned)
|
||||
if s["type"] == "content" and 0 < i < len(cleaned) - 1]
|
||||
if not contents:
|
||||
break
|
||||
cleaned.pop(contents[-1])
|
||||
_sanitize_fidelity(cleaned, cfg.content)
|
||||
# 消毒后可能出现空要点页,剔除
|
||||
cleaned = [s for s in cleaned if s["type"] in ("cover", "toc", "section", "end")
|
||||
or s.get("content")]
|
||||
# 碎片化检测:内容页要点平均长度过短(电报式短语)视为不合格,触发外层重写
|
||||
bullets = [b for s in cleaned if s["type"] in ("content", "two_column", "table")
|
||||
for b in s.get("content", [])]
|
||||
if len(bullets) >= 6 and sum(len(b) for b in bullets) / len(bullets) < 10:
|
||||
return None
|
||||
return cleaned
|
||||
|
||||
|
||||
def _ask_json(prompt: str, attempts: int = 2) -> dict:
|
||||
"""带重试的 LLM JSON 调用;最终失败抛 PlanningError(不静默返回)。"""
|
||||
last_err = ""
|
||||
for i in range(attempts):
|
||||
try:
|
||||
data = llm.chat_json(prompt, system=_OUTLINE_SYSTEM)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
last_err = "输出不是合法 JSON"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_err = str(exc)[:120]
|
||||
raise PlanningError(f"AI 规划服务未响应({last_err}),请稍后重试")
|
||||
|
||||
|
||||
def _chunk_text(content: str) -> list:
|
||||
"""按行边界切块(≈PLAN_CHUNK_CHARS);超 PLAN_MAX_CHUNKS 时两两合并保证全文覆盖。"""
|
||||
text = (content or "").strip()
|
||||
if len(text) <= PLAN_CHUNK_CHARS:
|
||||
return [text] if text else []
|
||||
chunks, buf, size = [], [], 0
|
||||
for ln in text.splitlines():
|
||||
if size + len(ln) > PLAN_CHUNK_CHARS and buf:
|
||||
chunks.append("\n".join(buf))
|
||||
buf, size = [], 0
|
||||
buf.append(ln)
|
||||
size += len(ln) + 1
|
||||
if buf:
|
||||
chunks.append("\n".join(buf))
|
||||
while len(chunks) > PLAN_MAX_CHUNKS and len(chunks) > 1:
|
||||
chunks = ["\n".join(chunks[i:i + 2]) for i in range(0, len(chunks), 2)]
|
||||
return chunks
|
||||
|
||||
|
||||
def _map_chunk(chunk: str, i: int, n: int) -> list:
|
||||
"""阶段一(Map):让 LLM 通读一段原文,提炼小节摘要与关键数据。"""
|
||||
prompt = f"""以下是一份长文档的第 {i}/{n} 段。请提炼本段信息要点,供后续制作 PPT 大纲使用。
|
||||
|
||||
要求:
|
||||
1. 只输出合法 JSON 对象,不要解释、不要代码块标记
|
||||
2. sections 为本段的小节列表(1~5 个),每个含:
|
||||
- title:小节主题(不超过 20 字)
|
||||
- summary_points:3~6 条**完整的陈述句**(每条 15~40 字,含主语与结果/结论信息;
|
||||
禁止电报式短语,如"完成部署""效率提升"这类无完整语义的词组)
|
||||
- key_data:逐字摘录本段重要数字/百分比/金额/日期(禁止改写、禁止编造)
|
||||
|
||||
输出格式:{{"sections": [{{"title": "...", "summary_points": ["..."], "key_data": ["..."]}}]}}
|
||||
|
||||
【文档第 {i}/{n} 段】
|
||||
{chunk}"""
|
||||
data = _ask_json(prompt)
|
||||
sections = data.get("sections")
|
||||
return sections if isinstance(sections, list) else []
|
||||
|
||||
|
||||
def _outline_prompt(cfg, material: str, is_digest: bool) -> str:
|
||||
lang = cfg.language
|
||||
lo = max(cfg.page_min, 4)
|
||||
hi = max(cfg.page_max, lo + 1)
|
||||
lang_name = "中文" if lang == "zh" else "日本語(すべて日本語で書くこと)"
|
||||
src_head = ("下面是长文档的**分段摘要汇编**(已覆盖全文,请务必综合各段主题,"
|
||||
"不得只围绕开头内容规划):" if is_digest else "【用户内容】")
|
||||
return f"""请把下面的内容规划成一份 PPT 的页面大纲。
|
||||
|
||||
要求:
|
||||
1. 页面总数 {lo}~{hi} 页(含封面、目录、结尾)
|
||||
2. 输出语言:{lang_name}
|
||||
3. 页面类型只能用:cover(封面) / toc(目录) / section(章节页) / content(内容页) / two_column(双栏) / end(结尾)
|
||||
4. 第一页 type=cover,最后一页 type=end;章节多于3个时可用 section 分隔
|
||||
5. content 页的 content 数组为该页要点。每条要点必须是**完整的陈述句**:
|
||||
有主语、有谓语,长度 15~45 字,表达一个完整的事实或结论;禁止短语式罗列;支持 **加粗**
|
||||
6. 每页写 notes:2~4 句口语化演讲备注(TTS 朗读,禁止元数据标记)
|
||||
7. 标题要有信息量,不要用"要点1"这类占位词
|
||||
8. 内容保真(最高优先级):材料中出现的数字、百分比、金额、日期必须逐字保留;
|
||||
严禁编造材料里不存在的数字或百分比——"明显提升"不得写成"提升65%"这类具体数值
|
||||
|
||||
要点风格示例(务必遵守):
|
||||
【正确】"本期完成基础服务部署,累计导入历史文档120份"
|
||||
【正确】"混合检索方案试点后,检索准确率较基线明显提升"
|
||||
【错误】"基础服务部署"、"文档导入"、"效率提升"
|
||||
|
||||
输出格式:{{"slides": [{{"type": "...", "title": "...", "content": ["..."], "notes": "..."}}, ...]}}
|
||||
cover 页 content 放 [今天日期];toc 页 content 放各章标题;end 页 content 放结束语。
|
||||
|
||||
【用户标题】{cfg.title or '(自拟)'}
|
||||
{src_head}
|
||||
{material}"""
|
||||
|
||||
|
||||
def _llm_outline(cfg) -> list:
|
||||
"""两级规划:短文档直出大纲;长文档先分段通读(Map)再汇总出大纲(Reduce)。
|
||||
最终失败抛 PlanningError——绝不降级为照搬模式。"""
|
||||
lang = cfg.language
|
||||
lo = max(cfg.page_min, 4)
|
||||
hi = max(cfg.page_max, lo + 1)
|
||||
|
||||
chunks = _chunk_text(cfg.content)
|
||||
if not chunks:
|
||||
raise PlanningError("内容为空,无法生成")
|
||||
|
||||
if len(chunks) == 1:
|
||||
material = chunks[0]
|
||||
is_digest = False
|
||||
else:
|
||||
digests = []
|
||||
for i, chunk in enumerate(chunks, 1):
|
||||
digests.extend(_map_chunk(chunk, i, len(chunks)))
|
||||
if not digests:
|
||||
raise PlanningError("AI 通读文档失败,请稍后重试")
|
||||
material = json.dumps(digests, ensure_ascii=False)
|
||||
is_digest = True
|
||||
|
||||
# 汇总出大纲(校验不过自动重试一次,仍失败则明确报错)
|
||||
for _ in range(2):
|
||||
data = llm.chat_json(_outline_prompt(cfg, material, is_digest),
|
||||
system=_OUTLINE_SYSTEM)
|
||||
if isinstance(data, dict) and isinstance(data.get("slides"), list):
|
||||
slides = _validate_slides(data["slides"], cfg, lo, hi)
|
||||
if slides:
|
||||
return slides
|
||||
raise PlanningError("AI 连续两次未产出合格大纲,请稍后重试或调整内容")
|
||||
|
||||
|
||||
class PlanBuildResult:
|
||||
"""plan 构建结果:含 plan 字典与告警(如画布回退)。mode 恒为 llm。"""
|
||||
|
||||
def __init__(self, plan: dict, warnings: list | None = None, mode: str = "llm"):
|
||||
self.plan = plan
|
||||
self.warnings = warnings or []
|
||||
self.mode = mode
|
||||
|
||||
|
||||
def build_plan(cfg) -> PlanBuildResult:
|
||||
"""构建 plan.json(纯 LLM 总结模式;失败抛 PlanningError,不做规则降级)。"""
|
||||
warnings: list = []
|
||||
design = _pick_design(cfg)
|
||||
today = datetime.date.today().strftime("%Y-%m-%d")
|
||||
labels = _labels(cfg.language)
|
||||
|
||||
# aura 暂未提供 4:3 画布(待验证项),自动回退 16:9 并记录告警
|
||||
if cfg.canvas != "ppt169":
|
||||
warnings.append(
|
||||
f"canvas '{cfg.canvas}' 尚未支持,已按 ppt169(16:9)渲染"
|
||||
if cfg.language == "zh" else
|
||||
f"canvas '{cfg.canvas}' は未対応のため、16:9 で出力しました"
|
||||
)
|
||||
|
||||
llm_slides = _llm_outline(cfg)
|
||||
cover = llm_slides[0]
|
||||
cover.setdefault("subtitle", labels["cover_sub"])
|
||||
if cfg.title:
|
||||
cover["title"] = cfg.title # 用户标题优先,不允许 LLM 改写
|
||||
if not cover.get("content"):
|
||||
cover["content"] = [today]
|
||||
end = llm_slides[-1]
|
||||
if end["type"] == "end" and not end.get("title"):
|
||||
end["title"] = labels["end_title"]
|
||||
if end["type"] == "end" and not end.get("content"):
|
||||
end["content"] = labels["end_body"]
|
||||
|
||||
plan = {
|
||||
"title": cfg.title or (cover.get("title") or "Untitled"),
|
||||
"design": design,
|
||||
"date": today,
|
||||
"notes_template": True, # 缺备注的页仍由引擎兜底
|
||||
"slides": llm_slides,
|
||||
}
|
||||
return PlanBuildResult(plan=plan, warnings=warnings, mode="llm")
|
||||
|
||||
|
||||
def match_template(cfg):
|
||||
"""兼容旧接口名:返回构建结果(orchestrator/action 使用)。"""
|
||||
return build_plan(cfg)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""上传解析:调用 ppt-master source_to_md 脚本,把文档/网页转为 Markdown 文本。
|
||||
|
||||
解析分工(引擎约束:只经 CLI 子进程调用,不 import 引擎内部模块):
|
||||
.pdf → source_to_md/pdf_to_md.py (PyMuPDF)
|
||||
.docx/.doc → source_to_md/doc_to_md.py (mammoth / pandoc 兜底)
|
||||
.html/.htm → source_to_md/doc_to_md.py (markdownify + bs4)
|
||||
.xlsx/.xlsm → source_to_md/excel_to_md.py (openpyxl)
|
||||
.pptx → source_to_md/ppt_to_md.py (python-pptx)
|
||||
.md/.txt → 直接读取(无需脚本)
|
||||
URL → source_to_md/web_to_md.py (requests + bs4)
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from config import PPT_MASTER_PATH
|
||||
|
||||
SCRIPTS_DIR = PPT_MASTER_PATH / "scripts"
|
||||
PARSE_TIMEOUT = 300 # 单文件解析超时(秒)
|
||||
MAX_TEXT_CHARS = 30000 # 回传前端的最大字符数(规划层还会再截断)
|
||||
|
||||
# 需要脚本的扩展名 → source_to_md 相对路径
|
||||
EXT_SCRIPTS = {
|
||||
".pdf": "source_to_md/pdf_to_md.py",
|
||||
".docx": "source_to_md/doc_to_md.py",
|
||||
".doc": "source_to_md/doc_to_md.py",
|
||||
".html": "source_to_md/doc_to_md.py",
|
||||
".htm": "source_to_md/doc_to_md.py",
|
||||
".xlsx": "source_to_md/excel_to_md.py",
|
||||
".xlsm": "source_to_md/excel_to_md.py",
|
||||
".pptx": "source_to_md/ppt_to_md.py",
|
||||
}
|
||||
DIRECT_READ_EXTS = (".md", ".txt")
|
||||
|
||||
_IMG_LINE_RE = re.compile(r"!\[[^\]]*\]\([^)]*\)") # Markdown 图片引用
|
||||
_ASSET_LINK_RE = re.compile(r"\[([^\]]*)\]\((?:\./)?[^)]*_files/[^)]*\)")
|
||||
_HTML_TAG_RE = re.compile(r"<(script|style)[^>]*>.*?</\1>", re.S | re.I)
|
||||
_MULTI_BLANK_RE = re.compile(r"\n{3,}")
|
||||
_MD_ESCAPE_RE = re.compile(r"\\([\\`*_{}\[\]()#+\-.!~>|])") # Markdown 转义还原(\_ 等)
|
||||
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.S) # 网页抓取残留注释
|
||||
_CONV_BANNER_RE = re.compile( # 转换器自动加的元信息横幅
|
||||
r"^#{1,6}\s+[A-Za-z ]{0,24}Source\s*:.*$", re.M)
|
||||
_CONV_NAME_RE = re.compile( # 以内部存储名命名的首行标题
|
||||
r"^#{1,6}\s+\S*?\d+_[0-9a-f]{32}\S*$", re.M)
|
||||
_CONV_INLINE_RE = re.compile( # 行内引用内部存储名(如 - Source: `10_hex…`)
|
||||
r"^.*\d+_[0-9a-f]{32}.*$", re.M)
|
||||
|
||||
|
||||
class ParseError(Exception):
|
||||
"""解析失败,code 对应错误码体系 30xx。"""
|
||||
|
||||
def __init__(self, code: int, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def parse_file(path: Path) -> str:
|
||||
"""把本地文件解析为 Markdown 文本;失败抛 ParseError(3001/3003/3004)。"""
|
||||
path = Path(path)
|
||||
ext = path.suffix.lower()
|
||||
if not path.exists() or path.stat().st_size == 0:
|
||||
raise ParseError(3003, "文件损坏,无法读取")
|
||||
|
||||
if ext in DIRECT_READ_EXTS:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
elif ext in EXT_SCRIPTS:
|
||||
out_md = path.with_suffix(".md")
|
||||
_run_script(EXT_SCRIPTS[ext], [str(path)], out_md)
|
||||
text = out_md.read_text(encoding="utf-8", errors="replace") if out_md.exists() else ""
|
||||
else:
|
||||
raise ParseError(3001, "不支持的文件类型")
|
||||
|
||||
return _finalize(text)
|
||||
|
||||
|
||||
def parse_url(url: str) -> str:
|
||||
"""抓取网页正文并转 Markdown;失败抛 ParseError(3001/3003/3004)。"""
|
||||
url = (url or "").strip()
|
||||
if not re.match(r"^https?://\S+$", url, re.I):
|
||||
raise ParseError(3001, "链接格式不正确,请输入 http(s):// 开头的网页链接")
|
||||
with tempfile.TemporaryDirectory(prefix="pptagent_parse_") as tmp:
|
||||
out = Path(tmp) / "web.md"
|
||||
_run_script("source_to_md/web_to_md.py", [url], out)
|
||||
text = out.read_text(encoding="utf-8", errors="replace") if out.exists() else ""
|
||||
return _finalize(text)
|
||||
|
||||
|
||||
def _run_script(rel_script: str, in_args: list, out_md: Path) -> None:
|
||||
"""执行 source_to_md 脚本(<script> <in> -o <out>),失败抛 ParseError(3003)。"""
|
||||
script = SCRIPTS_DIR / rel_script
|
||||
cmd = [sys.executable, str(script), *in_args, "-o", str(out_md)]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, cwd=str(SCRIPTS_DIR), capture_output=True, timeout=PARSE_TIMEOUT,
|
||||
env={**__import__("os").environ, "PYTHONIOENCODING": "utf-8"},
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ParseError(3003, "解析超时,请换较小的文件重试")
|
||||
if proc.returncode != 0 or not Path(out_md).exists():
|
||||
tail = (proc.stderr or proc.stdout or b"").decode("utf-8", "ignore")[-200:].strip()
|
||||
raise ParseError(3003, f"文件解析失败{(':' + tail) if tail else ''}")
|
||||
|
||||
|
||||
def _finalize(text: str) -> str:
|
||||
"""清洗解析产物:去注释/横幅/图片与资源引用、还原转义、压空行、截断超长;全空则报 3004。"""
|
||||
text = _HTML_COMMENT_RE.sub("", text or "")
|
||||
text = _HTML_TAG_RE.sub("", text)
|
||||
text = _CONV_BANNER_RE.sub("", text)
|
||||
text = _CONV_NAME_RE.sub("", text)
|
||||
text = _CONV_INLINE_RE.sub("", text)
|
||||
text = _IMG_LINE_RE.sub("", text)
|
||||
text = _ASSET_LINK_RE.sub(r"\1", text)
|
||||
text = _MD_ESCAPE_RE.sub(r"\1", text)
|
||||
text = _MULTI_BLANK_RE.sub("\n\n", text).strip()
|
||||
if not text:
|
||||
raise ParseError(3004, "文件内容为空")
|
||||
return text[:MAX_TEXT_CHARS]
|
||||
@@ -0,0 +1,4 @@
|
||||
"""数据库包。"""
|
||||
|
||||
from .conn import get_connection, execute, query_all, query_one # noqa: F401
|
||||
from .schema import init_db # noqa: F401
|
||||
@@ -0,0 +1,47 @@
|
||||
"""SQLite 连接管理(参数化查询安全访问)。"""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from config import DB_PATH
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
"""获取数据库连接,自动应用 WAL / 外键 / 超时配置。"""
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA busy_timeout = 5000")
|
||||
return conn
|
||||
|
||||
|
||||
def execute(sql: str, params: tuple = ()) -> int:
|
||||
"""执行写操作,返回 lastrowid。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(sql, params)
|
||||
conn.commit()
|
||||
return cur.lastrowid if cur.lastrowid else cur.rowcount
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def query_all(sql: str, params: tuple = ()) -> list:
|
||||
"""执行查询,返回 dict 列表。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
return [dict(row) for row in conn.execute(sql, params).fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def query_one(sql: str, params: tuple = ()):
|
||||
"""执行查询,返回单行 dict 或 None。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(sql, params).fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,7 @@
|
||||
"""数据库初始化入口:python -m src.db.init"""
|
||||
|
||||
from .schema import init_db
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
print("[OK] 数据库初始化完成")
|
||||
@@ -0,0 +1,80 @@
|
||||
"""数据库表结构 DDL(Sprint 1:users / preferences / ppt_records / versions / chat_messages)。"""
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
default_scene TEXT NOT NULL DEFAULT 'training',
|
||||
default_color TEXT NOT NULL DEFAULT 'blue',
|
||||
default_language TEXT NOT NULL DEFAULT 'zh',
|
||||
default_canvas TEXT NOT NULL DEFAULT 'ppt169',
|
||||
default_image_strategy TEXT NOT NULL DEFAULT 'web',
|
||||
default_page_min INTEGER NOT NULL DEFAULT 10,
|
||||
default_page_max INTEGER NOT NULL DEFAULT 15,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_preferences_user ON preferences(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ppt_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
scene TEXT NOT NULL,
|
||||
language TEXT NOT NULL DEFAULT 'zh',
|
||||
canvas TEXT NOT NULL DEFAULT 'ppt169',
|
||||
image_strategy TEXT NOT NULL DEFAULT 'web',
|
||||
color_scheme TEXT NOT NULL,
|
||||
page_min INTEGER NOT NULL,
|
||||
page_max INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'processing',
|
||||
file_size INTEGER,
|
||||
page_count INTEGER,
|
||||
generate_duration INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_records_user_time ON ppt_records(user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_records_user_scene ON ppt_records(user_id, scene);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
record_id INTEGER NOT NULL,
|
||||
version_no INTEGER NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
svg_dir TEXT,
|
||||
change_note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
FOREIGN KEY (record_id) REFERENCES ppt_records(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_versions_record ON versions(record_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
record_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
FOREIGN KEY (record_id) REFERENCES ppt_records(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_record ON chat_messages(record_id, created_at);
|
||||
"""
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""执行全部 DDL,幂等可重复调用。"""
|
||||
from .conn import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""引擎桥接包。"""
|
||||
|
||||
from .bridge import ( # noqa: F401
|
||||
EngineError, render_plan, verify_output, export_preview_images,
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""引擎桥接层:封装对 aura-ppt 渲染引擎的 CLI 调用。
|
||||
|
||||
所有调用统一走 subprocess,不直接 import 引擎内部模块。
|
||||
ppt-master 的文档解析/搜图脚本封装将在 Sprint 3(上传解析)补充。
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from config import AURA_ENGINE_PATH, AURA_ENGINE_SCRIPT
|
||||
|
||||
|
||||
class EngineError(Exception):
|
||||
"""引擎调用失败(含退出码与 stderr)。"""
|
||||
|
||||
def __init__(self, message: str, cmd=None, returncode=None, stderr=None):
|
||||
super().__init__(message)
|
||||
self.cmd = cmd
|
||||
self.returncode = returncode
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
def _run_aura(args: list, timeout: int = 600) -> str:
|
||||
"""执行 aura engine.py 子命令,成功返回 stdout,失败抛 EngineError。"""
|
||||
cmd = [sys.executable, str(AURA_ENGINE_SCRIPT)] + [str(a) for a in args]
|
||||
# 强制 UTF-8 子进程输出,规避 Windows GBK 控制台对 ✅/⚠ 等字符的编码崩溃
|
||||
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True,
|
||||
cwd=str(AURA_ENGINE_PATH), env=env,
|
||||
timeout=timeout, encoding="utf-8", errors="replace",
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise EngineError("生成超时,引擎无响应", cmd=cmd)
|
||||
except Exception as exc:
|
||||
raise EngineError(f"引擎启动失败: {exc}", cmd=cmd)
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise EngineError(
|
||||
"aura 引擎执行失败",
|
||||
cmd=cmd, returncode=proc.returncode, stderr=proc.stderr or proc.stdout,
|
||||
)
|
||||
return proc.stdout or ""
|
||||
|
||||
|
||||
def render_plan(plan_path: Path, output_pptx: Path, template: Path | None = None) -> Path:
|
||||
"""渲染 plan.json → PPTX。template 提供时继承公司母版。"""
|
||||
args = ["--plan", str(plan_path), "--output", str(output_pptx)]
|
||||
if template:
|
||||
args += ["--template", str(template)]
|
||||
out = _run_aura(args)
|
||||
if not Path(output_pptx).exists():
|
||||
raise EngineError(
|
||||
"渲染完成但未找到输出文件",
|
||||
cmd=args, stderr=out,
|
||||
)
|
||||
return Path(output_pptx)
|
||||
|
||||
|
||||
def verify_output(output_pptx: Path, plan_path: Path) -> tuple[bool, str]:
|
||||
"""内容保真验证。返回 (是否通过, 引擎输出摘要)。"""
|
||||
out = _run_aura(["--verify", str(output_pptx), "--plan", str(plan_path)], timeout=300)
|
||||
passed = ("内容保真验证通过" in out) and ("保真失败" not in out)
|
||||
tail = "\n".join(out.splitlines()[-6:])
|
||||
return passed, tail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 预览导出
|
||||
|
||||
_PS_SCRIPT = Path(__file__).resolve().parent / "preview_export.ps1"
|
||||
|
||||
|
||||
def _normalize_preview_names(out_dir: Path) -> None:
|
||||
"""COM 导出文件名随 Office 语言不同(Slide1.PNG / 幻灯片1.PNG …),统一归一为 Slide<N>.PNG。"""
|
||||
for p in out_dir.iterdir():
|
||||
if p.suffix.lower() != ".png":
|
||||
continue
|
||||
m = re.search(r"(\d+)", p.stem)
|
||||
if not m:
|
||||
continue
|
||||
target = out_dir / f"Slide{int(m.group(1))}.PNG"
|
||||
if p.name != target.name:
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
p.replace(target)
|
||||
|
||||
|
||||
def export_preview_images(pptx_path: Path, out_dir: Path) -> list:
|
||||
"""把 PPTX 每页导出为 PNG(PowerPoint COM,WPS 兜底)。返回图片路径列表。
|
||||
|
||||
带缓存:out_dir 下已有 Slide1.PNG 时直接复用。
|
||||
"""
|
||||
out_dir = Path(out_dir)
|
||||
first = out_dir / "Slide1.PNG"
|
||||
if not first.exists():
|
||||
cmd = [
|
||||
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||
"-File", str(_PS_SCRIPT), str(pptx_path), str(out_dir),
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=120,
|
||||
encoding="utf-8", errors="replace",
|
||||
env={**os.environ, "PYTHONIOENCODING": "utf-8"},
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise EngineError("预览导出超时", cmd=cmd)
|
||||
_normalize_preview_names(out_dir)
|
||||
if proc.returncode != 0 or not first.exists():
|
||||
raise EngineError(
|
||||
"预览导出失败", cmd=cmd,
|
||||
returncode=proc.returncode, stderr=proc.stderr or proc.stdout,
|
||||
)
|
||||
return sorted(out_dir.glob("Slide*.PNG"),
|
||||
key=lambda p: int(re.search(r"\d+", p.stem).group()))
|
||||
@@ -0,0 +1,26 @@
|
||||
# PPTX -> PNG slide images via PowerPoint COM (WPS fallback).
|
||||
# Usage: powershell -NoProfile -ExecutionPolicy Bypass -File preview_export.ps1 <pptx> <outdir>
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$Pptx,
|
||||
[Parameter(Mandatory=$true)][string]$OutDir
|
||||
)
|
||||
$ErrorActionPreference = "Stop"
|
||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||
|
||||
$app = $null
|
||||
$pres = $null
|
||||
try {
|
||||
try {
|
||||
$app = New-Object -ComObject PowerPoint.Application
|
||||
} catch {
|
||||
$app = New-Object -ComObject KWPP.Application
|
||||
}
|
||||
$full = (Resolve-Path $Pptx).Path
|
||||
$pres = $app.Presentations.Open($full, $true, $false, $false) # ReadOnly, no window
|
||||
$pres.Export((Resolve-Path $OutDir).Path, "PNG", 1280, 720)
|
||||
Write-Output "EXPORT_OK"
|
||||
}
|
||||
finally {
|
||||
if ($pres -ne $null) { $pres.Close() }
|
||||
if ($app -ne $null) { $app.Quit() }
|
||||
}
|
||||
@@ -0,0 +1,980 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=1280">
|
||||
<title>PPT自动生成 Agent - 布局草图</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, "Microsoft YaHei", sans-serif;
|
||||
background: #f0f2f5;
|
||||
color: #333;
|
||||
padding: 40px;
|
||||
}
|
||||
.page-label {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.page-label::before {
|
||||
content: "📐 ";
|
||||
font-size: 14px;
|
||||
}
|
||||
.page {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,.08);
|
||||
margin-bottom: 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.page-title {
|
||||
padding: 20px 32px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
.page-title span {
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
/* === 登录页(重新设计)=== */
|
||||
.login-top {
|
||||
height: 40%;
|
||||
min-height: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.login-title {
|
||||
font-family: "Microsoft YaHei", sans-serif;
|
||||
font-size: 40px;
|
||||
color: #1565C0;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.login-subtitle {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-top: 16px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.login-mid {
|
||||
height: 40%;
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.login-card {
|
||||
width: 380px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,.08);
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.login-card-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.login-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.login-field label {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
.login-field input {
|
||||
height: 44px;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 6px;
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
outline: none;
|
||||
font-family: "Microsoft YaHei", sans-serif;
|
||||
background: #fff;
|
||||
}
|
||||
.login-field input:focus {
|
||||
border-color: #1565C0;
|
||||
box-shadow: 0 0 0 2px rgba(21,101,192,.1);
|
||||
}
|
||||
.login-field input::placeholder {
|
||||
color: #bbb;
|
||||
}
|
||||
.login-btn {
|
||||
height: 44px;
|
||||
width: 100%;
|
||||
background: #1565C0;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: "Microsoft YaHei", sans-serif;
|
||||
letter-spacing: 2px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.login-btn:hover {
|
||||
background: #1254a5;
|
||||
}
|
||||
.login-register {
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
.login-register:hover {
|
||||
color: #1565C0;
|
||||
}
|
||||
.login-bottom {
|
||||
height: 20%;
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* 背景装饰 */
|
||||
.bg-circle-lg {
|
||||
position: absolute;
|
||||
top: -100px;
|
||||
right: -100px;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
border-radius: 50%;
|
||||
background: #E3F2FD;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.bg-circle-sm {
|
||||
position: absolute;
|
||||
bottom: -50px;
|
||||
left: -50px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
background: #E3F2FD;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.bg-dot {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: #E3F2FD;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.login-top, .login-mid, .login-bottom {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ph { background: #e0e0e0; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 13px; color: #999; }
|
||||
.ph-input { height: 44px; width: 100%; }
|
||||
|
||||
/* === 工作台(三栏布局)=== */
|
||||
.ws-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: #F5F6FA;
|
||||
}
|
||||
/* ========== 左侧导航 ========== */
|
||||
.ws-nav {
|
||||
width: 220px;
|
||||
background: #FAFBFC;
|
||||
border-right: 1px solid #E0E0E0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width .25s ease;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.ws-nav.collapsed { width: 60px; }
|
||||
|
||||
/* 折叠按钮 - 顶格 */
|
||||
.ws-nav-top-toggle {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ws-nav-toggle-btn {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
transition: color .2s;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.ws-nav-toggle-btn:hover { color: #333; }
|
||||
.ws-nav.collapsed .ws-nav-top-toggle { justify-content: center; padding: 0; }
|
||||
|
||||
/* 系统名称 */
|
||||
.ws-nav-brand {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ws-nav-brand .brand-icon { font-size: 22px; flex-shrink: 0; width: 28px; text-align: center; }
|
||||
.ws-nav-brand .brand-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1565C0;
|
||||
margin-left: 12px;
|
||||
transition: opacity .2s;
|
||||
}
|
||||
.ws-nav.collapsed .brand-text { opacity: 0; pointer-events: none; }
|
||||
.ws-nav.collapsed .ws-nav-brand { justify-content: center; padding: 0; }
|
||||
|
||||
/* 分隔线 */
|
||||
.ws-nav-divider {
|
||||
height: 1px;
|
||||
background: #E0E0E0;
|
||||
margin: 0 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ws-nav.collapsed .ws-nav-divider { margin: 0 12px; }
|
||||
|
||||
/* 导航项 */
|
||||
.ws-nav-items {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.ws-nav-item {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
border-left: 3px solid transparent;
|
||||
white-space: nowrap;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.ws-nav-item:hover { background: #f0f2f5; }
|
||||
.ws-nav-item.active {
|
||||
background: #E3F2FD;
|
||||
border-left-color: #1565C0;
|
||||
font-weight: 600;
|
||||
color: #1565C0;
|
||||
}
|
||||
.ws-nav-item .ni-icon { font-size: 18px; width: 28px; flex-shrink: 0; text-align: center; }
|
||||
.ws-nav-item .ni-label { margin-left: 12px; transition: opacity .2s; }
|
||||
.ws-nav.collapsed .ws-nav-item { justify-content: center; padding: 0; }
|
||||
.ws-nav.collapsed .ni-label { opacity: 0; pointer-events: none; }
|
||||
.ws-nav.collapsed .ws-nav-item.active { border-left-color: transparent; }
|
||||
|
||||
/* ========== 中间内容区 ========== */
|
||||
.ws-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
padding: 32px;
|
||||
}
|
||||
.ws-content-inner {
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.ws-content-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.ws-content-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
.ws-form { display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
/* 表单卡片 */
|
||||
.form-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 28px 32px;
|
||||
box-shadow: 0 1px 6px rgba(0,0,0,.06);
|
||||
}
|
||||
.form-card .f-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* PPT标题行 */
|
||||
.title-row { display: flex; gap: 10px; }
|
||||
.title-row input {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 6px;
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
outline: none;
|
||||
font-family: "Microsoft YaHei", sans-serif;
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
}
|
||||
.title-row input:focus {
|
||||
border-color: #1565C0;
|
||||
box-shadow: 0 0 0 2px rgba(21,101,192,.1);
|
||||
}
|
||||
.smart-btn {
|
||||
height: 44px;
|
||||
padding: 0 18px;
|
||||
background: #E3F2FD;
|
||||
color: #1565C0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
font-family: "Microsoft YaHei", sans-serif;
|
||||
transition: all .3s;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.smart-btn:hover { background: #bbdefb; }
|
||||
.smart-btn:disabled { background: #e0e0e0; color: #999; cursor: not-allowed; }
|
||||
|
||||
/* 拖拽上传 */
|
||||
.drop-zone {
|
||||
border: 2px dashed #E0E0E0;
|
||||
border-radius: 8px;
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color .2s, background .2s;
|
||||
}
|
||||
.drop-zone:hover { border-color: #1565C0; background: #fafcff; }
|
||||
.drop-zone.dragover { border-color: #1565C0; background: #E3F2FD; }
|
||||
.drop-zone .dz-icon { font-size: 28px; margin-bottom: 6px; }
|
||||
.drop-zone .dz-text { font-size: 14px; color: #999; }
|
||||
.drop-zone .dz-hint { font-size: 12px; color: #bbb; margin-top: 4px; }
|
||||
.drop-zone .file-info { font-size: 13px; color: #1565C0; margin-top: 8px; display: none; }
|
||||
.drop-zone .file-info.show { display: block; }
|
||||
.drop-zone .pbar { width: 60%; height: 4px; background: #E0E0E0; border-radius: 2px; margin: 8px auto 0; overflow: hidden; }
|
||||
.drop-zone .pfill { height: 100%; width: 0; background: #1565C0; border-radius: 2px; transition: width 1.5s ease; }
|
||||
.drop-zone .pfill.done { width: 100%; }
|
||||
|
||||
.paste-divider {
|
||||
display: flex; align-items: center; gap: 16px; margin: 12px 0;
|
||||
font-size: 12px; color: #999;
|
||||
}
|
||||
.paste-divider::before, .paste-divider::after {
|
||||
content: ''; flex: 1; height: 1px; background: #E0E0E0;
|
||||
}
|
||||
|
||||
/* 文本框 */
|
||||
.ta-wrap { position: relative; }
|
||||
.ta-wrap textarea {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: "Microsoft YaHei", sans-serif;
|
||||
line-height: 1.6;
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
}
|
||||
.ta-wrap textarea:focus {
|
||||
border-color: #1565C0;
|
||||
box-shadow: 0 0 0 2px rgba(21,101,192,.1);
|
||||
}
|
||||
.ta-wrap textarea::placeholder { color: #bbb; }
|
||||
.resize-handle {
|
||||
position: absolute; bottom: 4px; right: 6px;
|
||||
font-size: 16px; color: #ccc; user-select: none; pointer-events: none;
|
||||
}
|
||||
|
||||
/* ========== 右侧配置面板 ========== */
|
||||
.ws-panel {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
align-self: flex-start;
|
||||
overflow-y: auto;
|
||||
padding: 32px 24px 32px 0;
|
||||
}
|
||||
.panel-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 6px rgba(0,0,0,.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
.panel-item { }
|
||||
.panel-item .p-label {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 场景竖排 */
|
||||
.scene-vertical { display: flex; flex-direction: column; gap: 8px; }
|
||||
.scene-btn-v {
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #E0E0E0;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
font-family: "Microsoft YaHei", sans-serif;
|
||||
transition: all .2s;
|
||||
}
|
||||
.scene-btn-v:hover { border-color: #bbb; }
|
||||
.scene-btn-v.active {
|
||||
background: #E3F2FD;
|
||||
border-color: #1565C0;
|
||||
color: #1565C0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 色块 */
|
||||
.color-group { display: flex; gap: 14px; }
|
||||
.c-swatch {
|
||||
width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
|
||||
border: 2px solid #E0E0E0; transition: all .2s; flex-shrink: 0; position: relative;
|
||||
}
|
||||
.c-swatch:hover { transform: scale(1.15); }
|
||||
.c-swatch.active { border: 3px solid #1565C0; transform: scale(1.15); }
|
||||
.c-swatch .ck {
|
||||
display: none; position: absolute; top: 50%; left: 50%;
|
||||
transform: translate(-50%,-50%); color: #fff; font-size: 13px;
|
||||
font-weight: 700; text-shadow: 0 1px 2px rgba(0,0,0,.3);
|
||||
}
|
||||
.c-swatch.active .ck { display: block; }
|
||||
|
||||
/* 页数 */
|
||||
.page-select { width: 100%; height: 40px; border: 1px solid #E0E0E0; border-radius: 6px; padding: 0 10px; font-size: 14px; color: #333; outline: none; font-family: "Microsoft YaHei", sans-serif; background: #fff; cursor: pointer; transition: border-color .2s; }
|
||||
.page-select:focus { border-color: #1565C0; }
|
||||
.page-custom-input { width: 100%; height: 40px; border: 1px solid #E0E0E0; border-radius: 6px; padding: 0 10px; font-size: 14px; color: #333; outline: none; font-family: "Microsoft YaHei", sans-serif; display: none; margin-top: 8px; }
|
||||
.page-custom-input.show { display: block; }
|
||||
|
||||
/* 生成按钮 */
|
||||
.gen-btn {
|
||||
width: 100%; height: 48px;
|
||||
background: linear-gradient(135deg, #1565C0, #1976D2);
|
||||
color: #fff; border: none; border-radius: 8px;
|
||||
font-size: 16px; font-weight: 700; cursor: pointer;
|
||||
font-family: "Microsoft YaHei", sans-serif;
|
||||
transition: all .3s;
|
||||
}
|
||||
.gen-btn:hover { background: linear-gradient(135deg, #1254a5, #1565C0); }
|
||||
.gen-btn:disabled { background: #bbb; cursor: not-allowed; }
|
||||
|
||||
/* 下载卡片 */
|
||||
.dl-card {
|
||||
margin-top: 12px;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: none;
|
||||
}
|
||||
.dl-card.show { display: block; }
|
||||
.dl-card .dl-title { font-size: 14px; font-weight: 600; color: #333; display: flex; align-items: center; gap: 6px; }
|
||||
.dl-card .dl-size { font-size: 12px; color: #999; margin-top: 4px; }
|
||||
.dl-card .dl-actions { display: flex; gap: 10px; margin-top: 12px; }
|
||||
.dl-card .dl-btn {
|
||||
flex: 1; height: 36px; border-radius: 6px; border: none;
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
font-family: "Microsoft YaHei", sans-serif; transition: all .2s;
|
||||
}
|
||||
.dl-card .dl-btn-primary { background: #1565C0; color: #fff; }
|
||||
.dl-card .dl-btn-primary:hover { background: #1254a5; }
|
||||
.dl-card .dl-btn-outline { background: #fff; color: #1565C0; border: 1px solid #1565C0; }
|
||||
.dl-card .dl-btn-outline:hover { background: #f5f9ff; }
|
||||
|
||||
.spinner { display: inline-block; animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* === 结果页 === */
|
||||
.result-wrap {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
min-height: 600px;
|
||||
}
|
||||
.result-left {
|
||||
background: #f7f8fa;
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.result-right {
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
border-left: 1px solid #eee;
|
||||
}
|
||||
.ph-slide-preview { height: 360px; width: 100%; border-radius: 4px; }
|
||||
.ph-slide-thumbs { display: flex; gap: 8px; }
|
||||
.ph-thumb { width: 72px; height: 48px; border-radius: 4px; }
|
||||
|
||||
/* === 历史页 === */
|
||||
.history-wrap { padding: 24px 32px 32px; }
|
||||
.history-group { margin-bottom: 28px; }
|
||||
.history-group-title { font-size: 13px; color: #999; margin-bottom: 12px; font-weight: 500; }
|
||||
.history-item { display: flex; gap: 16px; align-items: center; padding: 12px 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.history-item:last-child { border-bottom: none; }
|
||||
.ph-history-thumb { width: 100px; height: 60px; border-radius: 4px; flex-shrink: 0; }
|
||||
.ph-history-text { flex: 1; height: 44px; }
|
||||
|
||||
/* === header === */
|
||||
.header-placeholder { height: 52px; background: #e8ecf1; display: flex; align-items: center; padding: 0 32px; }
|
||||
.header-placeholder .logo { width: 36px; height: 36px; border-radius: 6px; }
|
||||
.header-placeholder .nav { width: 120px; height: 28px; margin-left: auto; }
|
||||
|
||||
/* === AI 对话浮层(独立画在右侧)=== */
|
||||
.chat-layer {
|
||||
margin-top: 24px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,.08);
|
||||
overflow: hidden;
|
||||
width: 380px;
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
}
|
||||
.chat-header { padding: 16px 20px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }
|
||||
.chat-header .ph { width: 80px; height: 18px; }
|
||||
.chat-messages { padding: 20px; display: flex; flex-direction: column; gap: 16px; min-height: 240px; }
|
||||
.chat-bubble { width: 70%; height: 40px; border-radius: 8px; }
|
||||
.chat-bubble.user { margin-left: auto; }
|
||||
.chat-input-area { padding: 16px 20px; border-top: 1px solid #eee; display: flex; gap: 8px; align-items: center; }
|
||||
.chat-input-area .ph { flex: 1; height: 40px; }
|
||||
.chat-input-area .ph-btn { width: 72px; height: 40px; flex: none; }
|
||||
|
||||
.note {
|
||||
text-align: center;
|
||||
color: #bbb;
|
||||
font-size: 14px;
|
||||
padding: 16px 0 32px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page-label">页面 01 — 登录(重新设计)</div>
|
||||
<div class="page" style="overflow:hidden;position:relative">
|
||||
<!-- 背景装饰 -->
|
||||
<div class="bg-circle-lg"></div>
|
||||
<div class="bg-circle-sm"></div>
|
||||
<div class="bg-dot" style="top:15%;left:12%;width:18px;height:18px;opacity:.3"></div>
|
||||
<div class="bg-dot" style="top:55%;left:85%;width:12px;height:12px;opacity:.25;border-radius:3px"></div>
|
||||
<div class="bg-dot" style="top:75%;left:20%;width:14px;height:14px;opacity:.2"></div>
|
||||
<div class="bg-dot" style="top:30%;left:88%;width:10px;height:10px;opacity:.3;border-radius:3px"></div>
|
||||
|
||||
<!-- 顶部 40%:标题区 -->
|
||||
<div class="login-top">
|
||||
<div class="login-title">演示文稿智能生成</div>
|
||||
<div class="login-subtitle">社内汇报 · 社内培训 — AI 驱动,分钟级生成</div>
|
||||
</div>
|
||||
|
||||
<!-- 中部 40%:登录表单 -->
|
||||
<div class="login-mid">
|
||||
<div class="login-card">
|
||||
<div class="login-card-title">欢迎登录</div>
|
||||
<div class="login-field">
|
||||
<label>用户名</label>
|
||||
<input type="text" placeholder="请输入用户名或邮箱" disabled>
|
||||
</div>
|
||||
<div class="login-field">
|
||||
<label>密码</label>
|
||||
<input type="password" placeholder="请输入密码" disabled>
|
||||
</div>
|
||||
<button class="login-btn">登 录</button>
|
||||
<div class="login-register">还没有账号?立即注册</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部 20%:版权 -->
|
||||
<div class="login-bottom">
|
||||
© 2026 讯和創新科技
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-label">页面 02 — 工作台(三栏布局)</div>
|
||||
<div class="page" style="padding:0;overflow:hidden">
|
||||
<div class="ws-layout">
|
||||
|
||||
<!-- ===== 左侧导航栏 ===== -->
|
||||
<div class="ws-nav" id="wsNav">
|
||||
<div class="ws-nav-top-toggle">
|
||||
<span class="ws-nav-toggle-btn" id="navToggle">
|
||||
<span id="toggleIcon">◀</span>
|
||||
<span id="toggleLabel">收起</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ws-nav-brand">
|
||||
<span class="brand-icon">📊</span>
|
||||
<span class="brand-text">演示文稿智能生成</span>
|
||||
</div>
|
||||
<div class="ws-nav-divider"></div>
|
||||
<div class="ws-nav-items">
|
||||
<div class="ws-nav-item active">
|
||||
<span class="ni-icon">📄</span>
|
||||
<span class="ni-label">PPT 生成</span>
|
||||
</div>
|
||||
<div class="ws-nav-item">
|
||||
<span class="ni-icon">📋</span>
|
||||
<span class="ni-label">历史记录</span>
|
||||
</div>
|
||||
<div class="ws-nav-item">
|
||||
<span class="ni-icon">⚙️</span>
|
||||
<span class="ni-label">个人设置</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 中间内容区(可滚动) ===== -->
|
||||
<div class="ws-content" id="wsContent">
|
||||
<div class="ws-content-inner">
|
||||
|
||||
<div class="ws-content-header">
|
||||
<h2>PPT 生成</h2>
|
||||
</div>
|
||||
|
||||
<div class="ws-form">
|
||||
|
||||
<!-- PPT 标题 -->
|
||||
<div class="form-card">
|
||||
<div class="f-label">PPT 标题</div>
|
||||
<div class="title-row">
|
||||
<input type="text" id="pptTitle" placeholder="输入 PPT 标题,或使用智能生成">
|
||||
<button class="smart-btn" id="smartBtn">
|
||||
<span id="smartText">✨ 智能生成</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PPT 内容 -->
|
||||
<div class="form-card">
|
||||
<div class="f-label">PPT 内容</div>
|
||||
<div class="drop-zone" id="dropZone">
|
||||
<div class="dz-icon">📄</div>
|
||||
<div class="dz-text">将文档拖拽到此处,或点击上传</div>
|
||||
<div class="dz-hint">支持 .txt .md .docx .xlsx 格式</div>
|
||||
<div class="file-info" id="fileInfo">
|
||||
<span id="fileName"></span>
|
||||
<div class="pbar"><div class="pfill" id="pfill"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paste-divider">或手动粘贴</div>
|
||||
<div class="ta-wrap">
|
||||
<textarea id="pptContent" placeholder="在此粘贴您的 PPT 文字内容…"></textarea>
|
||||
<div class="resize-handle">┘</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 右侧配置面板(固定不动) ===== -->
|
||||
<div class="ws-panel">
|
||||
<div class="panel-card">
|
||||
|
||||
<!-- 生成场景 -->
|
||||
<div class="panel-item">
|
||||
<div class="p-label">生成场景</div>
|
||||
<div class="scene-vertical">
|
||||
<button class="scene-btn-v" data-scene="report">社内汇报</button>
|
||||
<button class="scene-btn-v active" data-scene="training">社内培训</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配色方案 -->
|
||||
<div class="panel-item">
|
||||
<div class="p-label">配色方案</div>
|
||||
<div class="color-group">
|
||||
<div class="c-swatch active" data-color="#1565C0" style="background:#1565C0"><span class="ck">✓</span></div>
|
||||
<div class="c-swatch" data-color="#34A853" style="background:#34A853"><span class="ck">✓</span></div>
|
||||
<div class="c-swatch" data-color="#7C3AED" style="background:#7C3AED"><span class="ck">✓</span></div>
|
||||
<div class="c-swatch" data-color="#F59E0B" style="background:#F59E0B"><span class="ck">✓</span></div>
|
||||
<div class="c-swatch" data-color="#374151" style="background:#374151"><span class="ck">✓</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 页数范围 -->
|
||||
<div class="panel-item">
|
||||
<div class="p-label">页数范围</div>
|
||||
<select class="page-select" id="pageSelect">
|
||||
<option value="5-10">5-10 页</option>
|
||||
<option value="10-15" selected>10-15 页</option>
|
||||
<option value="15-20">15-20 页</option>
|
||||
<option value="custom">自定义</option>
|
||||
</select>
|
||||
<input type="number" class="page-custom-input" id="pageCustom" min="5" max="50" placeholder="输入页数">
|
||||
</div>
|
||||
|
||||
<!-- 生成按钮 -->
|
||||
<button class="gen-btn" id="genBtn">🚀 一键生成</button>
|
||||
|
||||
<!-- 下载卡片 -->
|
||||
<div class="dl-card" id="dlCard">
|
||||
<div class="dl-title">📥 AI Agent入门_20260630.pptx</div>
|
||||
<div class="dl-size">2.4 MB · 12 页</div>
|
||||
<div class="dl-actions">
|
||||
<button class="dl-btn dl-btn-primary">下载</button>
|
||||
<button class="dl-btn dl-btn-outline">预览</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-label">页面 03 — 生成结果 + 下载</div>
|
||||
<div class="page">
|
||||
<div class="page-title">生成结果 <span>PPT 生成完成后自动跳转至此页</span></div>
|
||||
<div class="header-placeholder">
|
||||
<div class="ph logo"></div>
|
||||
<div style="margin-left:12px;font-size:14px;color:#888">PPT 自动生成 Agent</div>
|
||||
<div class="ph nav"><span>我的历史 ▾</span></div>
|
||||
</div>
|
||||
<div class="result-wrap">
|
||||
<div class="result-left">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div style="font-size:14px;font-weight:500;color:#666">预览 — AI Agent入门:从对话到自主行动</div>
|
||||
<div style="font-size:12px;color:#bbb">第 3 / 17 页</div>
|
||||
</div>
|
||||
<div class="ph ph-slide-preview"><span>PPT 页面预览区域(缩放到适应比例)</span></div>
|
||||
<div class="ph-slide-thumbs">
|
||||
<div class="ph-thumb" style="background:#d0d4d9"></div>
|
||||
<div class="ph-thumb"></div>
|
||||
<div class="ph-thumb"></div>
|
||||
<div class="ph-thumb"></div>
|
||||
<div class="ph-thumb"></div>
|
||||
<div class="ph-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-right">
|
||||
<div class="ph ph-btn-primary" style="background:#2b6bcb;height:48px"><span style="color:#fff;font-weight:600">⬇ 下载 PPTX</span></div>
|
||||
<div class="ph ph-btn-primary" style="background:#e8ecf1;height:44px"><span>不满意?AI 对话修改</span></div>
|
||||
<div style="margin-top:auto">
|
||||
<div style="font-size:13px;color:#999;margin-bottom:8px">版本记录</div>
|
||||
<div class="ph" style="height:60px;width:100%"><span>v1 · 刚刚生成 · 17页</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-label">页面 04 — AI 对话修改(浮层)</div>
|
||||
<div class="page" style="padding:24px 32px 32px">
|
||||
<div style="display:flex;gap:32px">
|
||||
<div style="flex:1;background:#f7f8fa;border-radius:8px;min-height:360px;display:flex;align-items:center;justify-content:center">
|
||||
<div class="ph" style="height:240px;width:90%"><span>PPT 预览区域(半透明遮罩)</span></div>
|
||||
</div>
|
||||
<div class="chat-layer" style="margin-top:0;flex:none">
|
||||
<div class="chat-header">
|
||||
<div style="font-size:14px;font-weight:500;color:#444">AI 修改助手</div>
|
||||
<div style="font-size:18px;color:#bbb;cursor:pointer">✕</div>
|
||||
</div>
|
||||
<div class="chat-messages">
|
||||
<div class="ph chat-bubble"><span>AI:你好,想怎么修改?</span></div>
|
||||
<div class="ph chat-bubble user" style="background:#d0d8e8"><span>用户:把第3页的标题改成蓝色</span></div>
|
||||
<div class="ph chat-bubble"><span>AI:已经改好了,你看看效果</span></div>
|
||||
</div>
|
||||
<div class="chat-input-area">
|
||||
<div class="ph"><span>输入修改意见...</span></div>
|
||||
<div class="ph ph-btn" style="background:#2b6bcb"><span style="color:#fff">发送</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-label">页面 05 — 历史记录</div>
|
||||
<div class="page">
|
||||
<div class="page-title">历史记录 <span>按时间分组,支持回溯任意版本</span></div>
|
||||
<div class="header-placeholder">
|
||||
<div class="ph logo"></div>
|
||||
<div style="margin-left:12px;font-size:14px;color:#888">PPT 自动生成 Agent</div>
|
||||
<div class="ph nav"><span>我的历史 ▾</span></div>
|
||||
</div>
|
||||
<div class="history-wrap">
|
||||
<div class="history-group">
|
||||
<div class="history-group-title">今天</div>
|
||||
<div class="history-item">
|
||||
<div class="ph ph-history-thumb"></div>
|
||||
<div class="ph ph-history-text"><span>AI Agent入门:从对话到自主行动 — v2(已修改)</span></div>
|
||||
<div style="font-size:12px;color:#bbb">10:32</div>
|
||||
</div>
|
||||
<div class="history-item">
|
||||
<div class="ph ph-history-thumb"></div>
|
||||
<div class="ph ph-history-text"><span>AI Agent入门:从对话到自主行动 — v1(原始)</span></div>
|
||||
<div style="font-size:12px;color:#bbb">09:15</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-group">
|
||||
<div class="history-group-title">昨天</div>
|
||||
<div class="history-item">
|
||||
<div class="ph ph-history-thumb"></div>
|
||||
<div class="ph ph-history-text"><span>7月项目周报</span></div>
|
||||
<div style="font-size:12px;color:#bbb">14:20</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-group">
|
||||
<div class="history-group-title">本周</div>
|
||||
<div class="history-item">
|
||||
<div class="ph ph-history-thumb"></div>
|
||||
<div class="ph ph-history-text"><span>AI基础知识培训_v3</span></div>
|
||||
<div style="font-size:12px;color:#bbb">6月28日</div>
|
||||
</div>
|
||||
<div class="history-item">
|
||||
<div class="ph ph-history-thumb"></div>
|
||||
<div class="ph ph-history-text"><span>提示词工程培训</span></div>
|
||||
<div style="font-size:12px;color:#bbb">6月27日</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note">↑ 以上为布局草图 · 灰色方块表示待填充内容的区域 · 下一步填充实际 UI</div>
|
||||
|
||||
<script>
|
||||
// === 导航折叠 ===
|
||||
let navCollapsed = false;
|
||||
document.getElementById('navToggle').addEventListener('click', function() {
|
||||
navCollapsed = !navCollapsed;
|
||||
document.getElementById('wsNav').classList.toggle('collapsed', navCollapsed);
|
||||
document.getElementById('toggleIcon').textContent = navCollapsed ? '▶' : '◀';
|
||||
document.getElementById('toggleLabel').textContent = navCollapsed ? '' : '收起';
|
||||
});
|
||||
|
||||
// === 导航项切换 ===
|
||||
document.querySelectorAll('.ws-nav-item').forEach(function(el) {
|
||||
el.addEventListener('click', function() {
|
||||
document.querySelectorAll('.ws-nav-item').forEach(function(i) { i.classList.remove('active'); });
|
||||
this.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// === 场景切换(右侧面板) ===
|
||||
document.querySelectorAll('.scene-btn-v').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.scene-btn-v').forEach(function(b) { b.classList.remove('active'); });
|
||||
this.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// === 配色选中 ===
|
||||
document.querySelectorAll('.c-swatch').forEach(function(s) {
|
||||
s.addEventListener('click', function() {
|
||||
document.querySelectorAll('.c-swatch').forEach(function(c) { c.classList.remove('active'); });
|
||||
this.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// === 页数下拉 ===
|
||||
document.getElementById('pageSelect').addEventListener('change', function() {
|
||||
var c = document.getElementById('pageCustom');
|
||||
c.classList.toggle('show', this.value === 'custom');
|
||||
});
|
||||
|
||||
// === 智能生成标题 ===
|
||||
var smartBtn = document.getElementById('smartBtn');
|
||||
var smartText = document.getElementById('smartText');
|
||||
var titleInput = document.getElementById('pptTitle');
|
||||
smartBtn.addEventListener('click', function() {
|
||||
if (smartBtn.disabled) return;
|
||||
smartBtn.disabled = true;
|
||||
smartText.innerHTML = '<span class="spinner">⏳</span> 正在生成…';
|
||||
smartBtn.style.background = '#e0e0e0';
|
||||
smartBtn.style.color = '#999';
|
||||
setTimeout(function() {
|
||||
titleInput.value = 'AI Agent入门:从对话到自主行动';
|
||||
smartText.textContent = '✨ 智能生成';
|
||||
smartBtn.style.background = '#E3F2FD';
|
||||
smartBtn.style.color = '#1565C0';
|
||||
smartBtn.disabled = false;
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
// === 拖拽上传 ===
|
||||
var dropZone = document.getElementById('dropZone');
|
||||
dropZone.addEventListener('click', function() {
|
||||
var fi = document.getElementById('fileInfo');
|
||||
var fn = document.getElementById('fileName');
|
||||
var pf = document.getElementById('pfill');
|
||||
fi.classList.add('show');
|
||||
fn.textContent = 'AI基础知识说明_v7.md (12.4 KB)';
|
||||
pf.classList.remove('done');
|
||||
pf.style.width = '0';
|
||||
setTimeout(function() { pf.style.width = '100%'; }, 200);
|
||||
setTimeout(function() { pf.classList.add('done'); }, 1700);
|
||||
});
|
||||
['dragenter','dragover'].forEach(function(e) {
|
||||
dropZone.addEventListener(e, function(ev) { ev.preventDefault(); this.classList.add('dragover'); });
|
||||
});
|
||||
['dragleave','drop'].forEach(function(e) {
|
||||
dropZone.addEventListener(e, function(ev) { ev.preventDefault(); this.classList.remove('dragover'); });
|
||||
});
|
||||
|
||||
// === 一键生成 + 下载卡片 ===
|
||||
var genBtn = document.getElementById('genBtn');
|
||||
var dlCard = document.getElementById('dlCard');
|
||||
genBtn.addEventListener('click', function() {
|
||||
if (genBtn.disabled) return;
|
||||
genBtn.disabled = true;
|
||||
genBtn.textContent = '⏳ 正在生成…';
|
||||
genBtn.style.background = '#bbb';
|
||||
dlCard.classList.remove('show');
|
||||
setTimeout(function() {
|
||||
genBtn.textContent = '🚀 一键生成';
|
||||
genBtn.style.background = 'linear-gradient(135deg, #1565C0, #1976D2)';
|
||||
genBtn.disabled = false;
|
||||
dlCard.classList.add('show');
|
||||
}, 2500);
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
"""Flask Web 服务入口:路由 + 鉴权 + 统一错误处理。"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2])) # 加入项目根,保证 import config 可用
|
||||
|
||||
from flask import Flask, jsonify, request, session, send_file, make_response
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
from config import (SECRET_KEY, HOST, PORT, DEBUG, PROJECTS_PATH,
|
||||
UPLOAD_DIR, MAX_UPLOAD_MB, ALLOWED_EXTENSIONS,
|
||||
ALLOWED_SCENES, ALLOWED_COLORS, ALLOWED_LANGS,
|
||||
ALLOWED_CANVAS, ALLOWED_IMAGE_STRATEGIES)
|
||||
from src.agent import memory, orchestrator, chat, llm
|
||||
from src.agent.upload import parse_file, parse_url, ParseError
|
||||
from src.db import init_db
|
||||
|
||||
app = Flask(__name__, static_folder="static", static_url_path="/static")
|
||||
app.secret_key = SECRET_KEY
|
||||
app.config["MAX_CONTENT_LENGTH"] = 20 * 1024 * 1024 # 20MB
|
||||
|
||||
UPLOAD_RETENTION_HOURS = 24 # 上传文件保留时长,超期在上传时顺带清理
|
||||
|
||||
|
||||
# ===== 鉴权中间件 =====
|
||||
|
||||
def _current_user():
|
||||
uid = session.get("user_id")
|
||||
if not uid:
|
||||
return None
|
||||
return memory.get_user_by_id(uid)
|
||||
|
||||
|
||||
def _require_login():
|
||||
user = _current_user()
|
||||
if not user:
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
# ===== Epic 1 · 认证 =====
|
||||
|
||||
@app.route("/api/register", methods=["POST"])
|
||||
def api_register():
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = (data.get("username") or "").strip()
|
||||
password = data.get("password") or ""
|
||||
confirm = data.get("confirm_password") or ""
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({"code": 5002, "message": "用户名 3-20 位,密码至少 6 位", "detail": None}), 400
|
||||
if len(username) < 3 or len(username) > 20:
|
||||
return jsonify({"code": 5002, "message": "用户名 3-20 位,密码至少 6 位", "detail": None}), 400
|
||||
if len(password) < 6:
|
||||
return jsonify({"code": 5002, "message": "用户名 3-20 位,密码至少 6 位", "detail": None}), 400
|
||||
if password != confirm:
|
||||
return jsonify({"code": 1003, "message": "两次输入的密码不一致", "detail": None}), 400
|
||||
if memory.get_user_by_name(username):
|
||||
return jsonify({"code": 1001, "message": "用户名已被使用", "detail": None}), 409
|
||||
|
||||
uid = memory.create_user(username, generate_password_hash(password))
|
||||
memory.create_preferences(uid)
|
||||
session["user_id"] = uid
|
||||
return jsonify({"code": 0, "data": {"username": username}})
|
||||
|
||||
|
||||
@app.route("/api/login", methods=["POST"])
|
||||
def api_login():
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = (data.get("username") or "").strip()
|
||||
password = data.get("password") or ""
|
||||
|
||||
user = memory.get_user_by_name(username)
|
||||
if not user or not check_password_hash(user["password_hash"], password):
|
||||
return jsonify({"code": 1002, "message": "用户名或密码错误", "detail": None}), 401
|
||||
|
||||
session["user_id"] = user["id"]
|
||||
return jsonify({"code": 0, "data": {"username": user["username"]}})
|
||||
|
||||
|
||||
@app.route("/api/logout", methods=["POST"])
|
||||
def api_logout():
|
||||
session.clear()
|
||||
return jsonify({"code": 0, "data": None})
|
||||
|
||||
|
||||
@app.route("/api/me", methods=["GET"])
|
||||
def api_me():
|
||||
user = _current_user()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
prefs = orchestrator.prefill_from_preferences(user["id"])
|
||||
return jsonify({"code": 0, "data": {"username": user["username"], "preferences": prefs}})
|
||||
|
||||
|
||||
# ===== Epic 2 · PPT 生成 =====
|
||||
|
||||
@app.route("/api/title/suggest", methods=["POST"])
|
||||
def api_title_suggest():
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
data = request.get_json(silent=True) or {}
|
||||
content = (data.get("content") or "").strip()
|
||||
lang = data.get("language") or "zh"
|
||||
if len(content) < 20:
|
||||
return jsonify({"code": 2001, "message": "内容至少需要 20 个字", "detail": None}), 400
|
||||
from src.agent import llm
|
||||
if lang == "ja":
|
||||
prompt = (f"以下の内容に基づき、報告資料のタイトルを3つ提案してください。"
|
||||
f"各タイトルは20文字以内。JSON形式 {{\"titles\": [\"...\"]}} で出力。\n\n{content[:2000]}")
|
||||
else:
|
||||
prompt = (f"根据以下 PPT 内容拟 3 个标题(每个不超过 20 字,有信息量、不空洞)。"
|
||||
f"输出 JSON {{\"titles\": [\"...\"]}}。\n\n{content[:2000]}")
|
||||
data_llm = llm.chat_json(prompt)
|
||||
titles = [str(t).strip()[:30] for t in (data_llm or {}).get("titles", []) if str(t).strip()]
|
||||
if not titles:
|
||||
# 规则兜底:取首个 Markdown 标题或首行截断
|
||||
first = next((ln.lstrip("# ").strip() for ln in content.splitlines()
|
||||
if ln.strip() and not ln.startswith("#")), content[:20])
|
||||
titles = [first[:20] or "未命名演示文稿"]
|
||||
return jsonify({"code": 0, "data": {"titles": titles}})
|
||||
|
||||
|
||||
@app.route("/api/generate", methods=["POST"])
|
||||
def api_generate():
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
result = orchestrator.start_generation(payload, user["id"])
|
||||
if not result["ok"]:
|
||||
first = result["errors"][0]
|
||||
return jsonify({"code": first["code"], "message": first["message"], "detail": None}), 400
|
||||
return jsonify({"code": 0, "data": {
|
||||
"task_id": result["task_id"],
|
||||
"status": result["status"],
|
||||
}})
|
||||
|
||||
|
||||
@app.route("/api/generate/<task_id>/status", methods=["GET"])
|
||||
def api_generate_status(task_id):
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
task = orchestrator.get_generation_status(task_id)
|
||||
if not task:
|
||||
return jsonify({"code": 2004, "message": "任务不存在", "detail": None}), 404
|
||||
return jsonify({"code": 0, "data": task})
|
||||
|
||||
|
||||
@app.route("/api/generate/<task_id>/result", methods=["GET"])
|
||||
def api_generate_result(task_id):
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
result = orchestrator.get_generation_result(task_id)
|
||||
if not result["ok"]:
|
||||
return jsonify({"code": 2004, "message": result.get("message", "生成失败"), "detail": None}), 400
|
||||
return jsonify({"code": 0, "data": {"record": result["record"]}})
|
||||
|
||||
|
||||
# ===== Epic 3 · 上传解析 =====
|
||||
|
||||
@app.errorhandler(413)
|
||||
def api_too_large(_e):
|
||||
return jsonify({"code": 3002, "message": "文件大小不能超过 20MB", "detail": None}), 413
|
||||
|
||||
|
||||
def _sweep_expired_uploads():
|
||||
"""顺带清理超过保留期的上传文件(尽力而为,不抛错)。"""
|
||||
try:
|
||||
cutoff = time.time() - UPLOAD_RETENTION_HOURS * 3600
|
||||
for p in Path(UPLOAD_DIR).glob("*"):
|
||||
if p.is_file() and p.stat().st_mtime < cutoff:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@app.route("/api/upload", methods=["POST"])
|
||||
def api_upload():
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
f = request.files.get("file")
|
||||
if f is None or not f.filename:
|
||||
return jsonify({"code": 3003, "message": "文件缺失或已损坏,请重新选择", "detail": None}), 400
|
||||
ext = Path(f.filename).suffix.lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
return jsonify({"code": 3001, "message": "不支持的文件类型", "detail": None}), 400
|
||||
data = f.read()
|
||||
if len(data) > MAX_UPLOAD_MB * 1024 * 1024:
|
||||
return jsonify({"code": 3002, "message": "文件大小不能超过 20MB", "detail": None}), 400
|
||||
if not data:
|
||||
return jsonify({"code": 3003, "message": "文件内容为空", "detail": None}), 400
|
||||
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
file_id = uuid.uuid4().hex
|
||||
(Path(UPLOAD_DIR) / f"{user['id']}_{file_id}{ext}").write_bytes(data)
|
||||
|
||||
# 展示名:仅取原文件名主体,限制长度,防路径拼接
|
||||
safe_name = re.sub(r"[\\/:*?\"<>|]", "_", Path(f.filename).name)[:80] or f"document{ext}"
|
||||
_sweep_expired_uploads()
|
||||
return jsonify({"code": 0, "data": {"file_id": file_id, "name": safe_name, "size": len(data)}})
|
||||
|
||||
|
||||
@app.route("/api/parse", methods=["POST"])
|
||||
def api_parse():
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
payload = request.get_json(silent=True) or {}
|
||||
url = (payload.get("url") or "").strip()
|
||||
try:
|
||||
if url:
|
||||
text = parse_url(url)
|
||||
else:
|
||||
file_id = payload.get("file_id") or ""
|
||||
if not re.fullmatch(r"[0-9a-f]{32}", str(file_id)):
|
||||
return jsonify({"code": 3001, "message": "非法请求", "detail": None}), 400
|
||||
matches = sorted(Path(UPLOAD_DIR).glob(f"{user['id']}_{file_id}.*"))
|
||||
if not matches:
|
||||
return jsonify({"code": 3003, "message": "文件不存在或已过期,请重新上传", "detail": None}), 404
|
||||
ext = matches[0].suffix.lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
return jsonify({"code": 3001, "message": "不支持的文件类型", "detail": None}), 400
|
||||
text = parse_file(matches[0])
|
||||
except ParseError as exc:
|
||||
return jsonify({"code": exc.code, "message": exc.message, "detail": None}), 400
|
||||
return jsonify({"code": 0, "data": {"text": text, "chars": len(text)}})
|
||||
|
||||
|
||||
# ===== Epic 4 · 对话修改 =====
|
||||
|
||||
@app.route("/api/chat", methods=["POST"])
|
||||
def api_chat():
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
data = request.get_json(silent=True) or {}
|
||||
record_id = data.get("record_id")
|
||||
message = (data.get("message") or "").strip()
|
||||
if not record_id or not message:
|
||||
return jsonify({"code": 4001, "message": "参数不完整", "detail": None}), 400
|
||||
result = chat.handle_chat(int(record_id), user["id"], message)
|
||||
if not result.get("ok") and result.get("code"):
|
||||
return jsonify({"code": result["code"], "message": result["reply"], "detail": None}), 404
|
||||
return jsonify({"code": 0, "data": result})
|
||||
|
||||
|
||||
@app.route("/api/chat/<int:record_id>/messages", methods=["GET"])
|
||||
def api_chat_messages(record_id):
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
if not memory.get_record_with_owner(record_id, user["id"]):
|
||||
return jsonify({"code": 4001, "message": "该记录不存在或已被删除", "detail": None}), 404
|
||||
msgs = memory.list_chat(record_id)
|
||||
return jsonify({"code": 0, "data": {"messages": msgs}})
|
||||
|
||||
|
||||
# ===== Epic 5 · 历史记录 =====
|
||||
|
||||
@app.route("/api/records", methods=["GET"])
|
||||
def api_records():
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
keyword = request.args.get("keyword", "")
|
||||
scene = request.args.get("scene", "")
|
||||
language = request.args.get("language", "")
|
||||
records = memory.list_records(user["id"], keyword=keyword, scene=scene, language=language)
|
||||
return jsonify({"code": 0, "data": {"records": records}})
|
||||
|
||||
|
||||
@app.route("/api/records/<int:record_id>", methods=["GET"])
|
||||
def api_record_detail(record_id):
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
record = memory.get_record_with_owner(record_id, user["id"])
|
||||
if not record:
|
||||
return jsonify({"code": 4001, "message": "该记录不存在或已被删除", "detail": None}), 404
|
||||
versions = memory.get_versions(record_id)
|
||||
return jsonify({"code": 0, "data": {"record": record, "versions": versions}})
|
||||
|
||||
|
||||
@app.route("/api/records/<int:record_id>", methods=["DELETE"])
|
||||
def api_record_delete(record_id):
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
if not memory.delete_record(record_id, user["id"]):
|
||||
return jsonify({"code": 4001, "message": "该记录不存在或已被删除", "detail": None}), 404
|
||||
return jsonify({"code": 0, "data": None})
|
||||
|
||||
|
||||
# ===== 下载 =====
|
||||
|
||||
@app.route("/api/files/<int:record_id>/download", methods=["GET"])
|
||||
def api_download(record_id):
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
record = memory.get_record_with_owner(record_id, user["id"])
|
||||
if not record:
|
||||
return jsonify({"code": 4001, "message": "该记录不存在或已被删除", "detail": None}), 404
|
||||
versions = memory.get_versions(record_id)
|
||||
if not versions:
|
||||
return jsonify({"code": 4002, "message": "文件已丢失,请重新生成", "detail": None}), 404
|
||||
# 当前版本(version_no=1;两版本策略下 2 为上一版)
|
||||
ver = versions[0]
|
||||
file_path = _resolve_file_path(ver["svg_dir"], ver["file_path"])
|
||||
if not file_path or not file_path.exists():
|
||||
return jsonify({"code": 4002, "message": "文件已丢失,请重新生成", "detail": None}), 404
|
||||
resp = make_response(send_file(str(file_path), as_attachment=True,
|
||||
download_name=f"{record['title']}_{ver['version_no']}.pptx"))
|
||||
return resp
|
||||
|
||||
|
||||
def _resolve_file_path(project_dir_name: str, rel_file: str):
|
||||
"""由项目目录名 + 相对路径反查文件绝对路径。"""
|
||||
if not project_dir_name:
|
||||
return None
|
||||
return PROJECTS_PATH / project_dir_name / rel_file
|
||||
|
||||
|
||||
# ===== 在线预览 =====
|
||||
|
||||
@app.route("/api/files/<int:record_id>/preview", methods=["GET"])
|
||||
def api_preview(record_id):
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
record = memory.get_record_with_owner(record_id, user["id"])
|
||||
if not record:
|
||||
return jsonify({"code": 4001, "message": "该记录不存在或已被删除", "detail": None}), 404
|
||||
versions = memory.get_versions(record_id)
|
||||
if not versions:
|
||||
return jsonify({"code": 4002, "message": "文件已丢失,请重新生成", "detail": None}), 404
|
||||
ver = versions[0]
|
||||
pptx_path = _resolve_file_path(ver["svg_dir"], ver["file_path"])
|
||||
if not pptx_path or not pptx_path.exists():
|
||||
return jsonify({"code": 4002, "message": "文件已丢失,请重新生成", "detail": None}), 404
|
||||
|
||||
from src.engine_bridge import export_preview_images, EngineError
|
||||
out_dir = PROJECTS_PATH / ver["svg_dir"] / "preview" / Path(ver["file_path"]).stem
|
||||
try:
|
||||
images = export_preview_images(pptx_path, out_dir)
|
||||
except EngineError as exc:
|
||||
return jsonify({"code": 6001, "message": "预览生成失败,请重试", "detail": None}), 500
|
||||
urls = [f"/api/preview-img/{record_id}/{img.name}" for img in images]
|
||||
return jsonify({"code": 0, "data": {
|
||||
"images": urls, "version_no": ver["version_no"], "title": record["title"],
|
||||
}})
|
||||
|
||||
|
||||
@app.route("/api/preview-img/<int:record_id>/<name>", methods=["GET"])
|
||||
def api_preview_img(record_id, name):
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
import re as _re
|
||||
if not _re.fullmatch(r"Slide\d+\.PNG", name):
|
||||
return jsonify({"code": 4001, "message": "非法请求", "detail": None}), 400
|
||||
record = memory.get_record_with_owner(record_id, user["id"])
|
||||
if not record:
|
||||
return jsonify({"code": 4001, "message": "该记录不存在或已被删除", "detail": None}), 404
|
||||
versions = memory.get_versions(record_id)
|
||||
if not versions:
|
||||
return jsonify({"code": 4002, "message": "文件已丢失", "detail": None}), 404
|
||||
ver = versions[0]
|
||||
img = PROJECTS_PATH / ver["svg_dir"] / "preview" / Path(ver["file_path"]).stem / name
|
||||
if not img.exists():
|
||||
return jsonify({"code": 4002, "message": "预览图不存在", "detail": None}), 404
|
||||
return send_file(str(img), mimetype="image/png")
|
||||
|
||||
|
||||
# ===== Epic 6 · 个人设置 =====
|
||||
|
||||
_PREF_ENUMS = {
|
||||
"default_scene": ALLOWED_SCENES,
|
||||
"default_color": ALLOWED_COLORS,
|
||||
"default_language": ALLOWED_LANGS,
|
||||
"default_canvas": ALLOWED_CANVAS,
|
||||
"default_image_strategy": ALLOWED_IMAGE_STRATEGIES,
|
||||
}
|
||||
|
||||
|
||||
@app.route("/api/settings", methods=["GET"])
|
||||
def api_settings():
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
prefs = memory.get_preferences(user["id"]) or {}
|
||||
clean = {k: prefs[k] for k in
|
||||
("default_scene", "default_color", "default_language", "default_canvas",
|
||||
"default_image_strategy", "default_page_min", "default_page_max") if k in prefs}
|
||||
return jsonify({"code": 0, "data": {"username": user["username"], "preferences": clean}})
|
||||
|
||||
|
||||
@app.route("/api/settings/username", methods=["PUT"])
|
||||
def api_settings_username():
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
username = (request.get_json(silent=True) or {}).get("username") or ""
|
||||
username = username.strip()
|
||||
if len(username) < 3 or len(username) > 20:
|
||||
return jsonify({"code": 5002, "message": "用户名 3-20 位,密码至少 6 位", "detail": None}), 400
|
||||
exist = memory.get_user_by_name(username)
|
||||
if exist and exist["id"] != user["id"]:
|
||||
return jsonify({"code": 1001, "message": "用户名已被使用", "detail": None}), 409
|
||||
memory.update_username(user["id"], username)
|
||||
return jsonify({"code": 0, "data": {"username": username}})
|
||||
|
||||
|
||||
@app.route("/api/settings/password", methods=["PUT"])
|
||||
def api_settings_password():
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
data = request.get_json(silent=True) or {}
|
||||
old_pwd = data.get("old_password") or ""
|
||||
new_pwd = data.get("new_password") or ""
|
||||
confirm = data.get("confirm_password") or ""
|
||||
if not check_password_hash(user["password_hash"], old_pwd):
|
||||
return jsonify({"code": 5001, "message": "原密码不正确", "detail": None}), 400
|
||||
if len(new_pwd) < 6:
|
||||
return jsonify({"code": 5002, "message": "用户名 3-20 位,密码至少 6 位", "detail": None}), 400
|
||||
if new_pwd != confirm:
|
||||
return jsonify({"code": 1003, "message": "两次输入的密码不一致", "detail": None}), 400
|
||||
memory.update_password(user["id"], generate_password_hash(new_pwd))
|
||||
return jsonify({"code": 0, "data": None})
|
||||
|
||||
|
||||
@app.route("/api/settings/preferences", methods=["PUT"])
|
||||
def api_settings_preferences():
|
||||
user = _require_login()
|
||||
if not user:
|
||||
return jsonify({"code": 1004, "message": "请先登录", "detail": None}), 401
|
||||
payload = request.get_json(silent=True) or {}
|
||||
prefs: dict = {}
|
||||
for key, allowed in _PREF_ENUMS.items():
|
||||
if key in payload:
|
||||
if payload[key] not in allowed:
|
||||
return jsonify({"code": 5002, "message": f"参数不合法:{key}", "detail": None}), 400
|
||||
prefs[key] = payload[key]
|
||||
for key in ("default_page_min", "default_page_max"):
|
||||
if key in payload:
|
||||
try:
|
||||
val = int(payload[key])
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"code": 5002, "message": "页数必须为数字", "detail": None}), 400
|
||||
if not 1 <= val <= 50:
|
||||
return jsonify({"code": 5002, "message": "页数范围需在 1-50 之间", "detail": None}), 400
|
||||
prefs[key] = val
|
||||
if "default_page_min" in prefs and "default_page_max" not in prefs:
|
||||
cur = memory.get_preferences(user["id"]) or {}
|
||||
prefs["default_page_max"] = cur.get("default_page_max", 15)
|
||||
if "default_page_max" in prefs and "default_page_min" not in prefs:
|
||||
cur = memory.get_preferences(user["id"]) or {}
|
||||
prefs["default_page_min"] = cur.get("default_page_min", 10)
|
||||
if prefs.get("default_page_min", 0) > prefs.get("default_page_max", 0):
|
||||
return jsonify({"code": 2002, "message": "最少页数不能大于最多页数", "detail": None}), 400
|
||||
memory.save_preferences(user["id"], prefs)
|
||||
updated = memory.get_preferences(user["id"]) or {}
|
||||
clean = {k: updated[k] for k in
|
||||
("default_scene", "default_color", "default_language", "default_canvas",
|
||||
"default_image_strategy", "default_page_min", "default_page_max") if k in updated}
|
||||
return jsonify({"code": 0, "data": {"preferences": clean}})
|
||||
|
||||
|
||||
# ===== 页面路由 =====
|
||||
|
||||
@app.route("/")
|
||||
def page_index():
|
||||
user = _current_user()
|
||||
return app.send_static_file("login.html") if not user else app.send_static_file("workspace.html")
|
||||
|
||||
|
||||
@app.route("/login")
|
||||
def page_login():
|
||||
return app.send_static_file("login.html")
|
||||
|
||||
|
||||
@app.route("/workspace")
|
||||
def page_workspace():
|
||||
return app.send_static_file("workspace.html")
|
||||
|
||||
|
||||
@app.route("/history")
|
||||
def page_history():
|
||||
return app.send_static_file("history.html")
|
||||
|
||||
|
||||
@app.route("/settings")
|
||||
def page_settings():
|
||||
return app.send_static_file("settings.html")
|
||||
|
||||
|
||||
# ===== 启动 =====
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
app.run(host=HOST, port=PORT, debug=DEBUG)
|
||||
@@ -0,0 +1,136 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>历史记录</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: "Microsoft YaHei", sans-serif; background: #EDEFF3; height: 100vh; overflow: hidden; display: flex; }
|
||||
.sidebar { width: 220px; height: 100vh; background: #FAFBFC; border-right: 1px solid #E4E7EB; display: flex; flex-direction: column; flex-shrink: 0; }
|
||||
.sidebar-brand { display: flex; align-items: center; gap: 8px; padding: 20px 16px; font-size: 15px; font-weight: 700; color: #1565C0; }
|
||||
.sidebar-nav { flex: 1; padding: 8px 0; }
|
||||
.nav-item { display: flex; align-items: center; height: 48px; padding: 0 16px; margin: 2px 8px; border-radius: 8px; cursor: pointer; font-size: 14px; color: #555; }
|
||||
.nav-item:hover { background: #EEF2F7; color: #333; }
|
||||
.nav-item.active { background: #E3F2FD; color: #1565C0; font-weight: 600; }
|
||||
.nav-icon { margin-right: 10px; }
|
||||
.sidebar-footer { padding: 16px; border-top: 1px solid #E8ECF0; }
|
||||
.btn-logout { width: 100%; height: 36px; border: 1px solid #E0E0E0; border-radius: 8px; background: #fff; color: #666; cursor: pointer; font-family: inherit; }
|
||||
.btn-logout:hover { border-color: #E53935; color: #E53935; }
|
||||
.main { flex: 1; overflow-y: auto; padding: 32px 48px; }
|
||||
.page-title { font-size: 22px; font-weight: 700; color: #1A1A1A; margin-bottom: 20px; }
|
||||
.search-row { display: flex; gap: 12px; margin-bottom: 20px; }
|
||||
.search-input { flex: 1; height: 40px; border: 1px solid #E0E0E0; border-radius: 8px; padding: 0 14px; font-size: 14px; font-family: inherit; outline: none; }
|
||||
.search-input:focus { border-color: #1565C0; }
|
||||
.filter-select { width: 130px; height: 40px; border: 1px solid #E0E0E0; border-radius: 8px; padding: 0 10px; font-size: 14px; font-family: inherit; outline: none; background: #fff; flex-shrink: 0; }
|
||||
.filter-select:focus { border-color: #1565C0; }
|
||||
.btn-back { height: 40px; padding: 0 18px; border: 1px solid #E0E0E0; border-radius: 8px; background: #fff; color: #666; cursor: pointer; font-family: inherit; }
|
||||
.btn-back:hover { border-color: #1565C0; color: #1565C0; }
|
||||
.list-empty { text-align: center; color: #999; padding: 80px 0; }
|
||||
.record-item { background: #fff; border-radius: 10px; padding: 18px 22px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); display: flex; align-items: center; justify-content: space-between; }
|
||||
.record-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 4px; }
|
||||
.record-meta { font-size: 12px; color: #999; }
|
||||
.record-actions { display: flex; gap: 8px; }
|
||||
.btn-dl { height: 32px; padding: 0 14px; border: none; border-radius: 6px; background: #1565C0; color: #fff; font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||
.btn-del { height: 32px; padding: 0 14px; border: 1px solid #E53935; border-radius: 6px; background: #fff; color: #E53935; font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||
.lang-row { display: flex; gap: 8px; margin-bottom: 10px; }
|
||||
.lang-btn { flex: 1; height: 32px; border: 1px solid #E0E0E0; border-radius: 8px; background: #fff; color: #666; font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||
.lang-btn.active { border-color: #1565C0; color: #1565C0; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">📊 <span data-i18n="app_name">演示文稿智能生成</span></div>
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-item" onclick="location.href='/workspace'"><span class="nav-icon">📄</span><span data-i18n="nav_generate">PPT 生成</span></div>
|
||||
<div class="nav-item active"><span class="nav-icon">🕐</span><span data-i18n="nav_history">历史记录</span></div>
|
||||
<div class="nav-item" onclick="location.href='/settings'"><span class="nav-icon">⚙️</span><span data-i18n="nav_settings">个人设置</span></div>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="lang-row">
|
||||
<button class="lang-btn" id="langZh" onclick="setLang('zh')">中文</button>
|
||||
<button class="lang-btn" id="langJa" onclick="setLang('ja')">日本語</button>
|
||||
</div>
|
||||
<button class="btn-logout" onclick="doLogout()" data-i18n="btn_logout">退出登录</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<div class="page-title" data-i18n="nav_history">历史记录</div>
|
||||
<div class="search-row">
|
||||
<input class="search-input" id="searchInput" data-i18n-ph="history_search_ph" placeholder="搜索标题…" oninput="loadRecords()">
|
||||
<select class="filter-select" id="langFilter" onchange="loadRecords()">
|
||||
<option value="" data-i18n="filter_all_lang">全部语言</option>
|
||||
<option value="zh" data-i18n="lang_zh">中文</option>
|
||||
<option value="ja" data-i18n="lang_ja">日本語</option>
|
||||
</select>
|
||||
<button class="btn-back" onclick="location.href='/workspace'" data-i18n="btn_new_doc">+ 新建</button>
|
||||
</div>
|
||||
<div id="recordList"></div>
|
||||
</main>
|
||||
<script>
|
||||
let I18N = {};
|
||||
|
||||
async function loadLang(lang) {
|
||||
try {
|
||||
const res = await fetch('/static/lang/' + lang + '.json');
|
||||
I18N = await res.json();
|
||||
} catch (e) { I18N = {}; }
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
el.textContent = I18N[el.dataset.i18n] || el.textContent;
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
|
||||
el.placeholder = I18N[el.dataset.i18nPh] || el.placeholder;
|
||||
});
|
||||
document.getElementById('langZh').classList.toggle('active', lang === 'zh');
|
||||
document.getElementById('langJa').classList.toggle('active', lang === 'ja');
|
||||
}
|
||||
|
||||
function setLang(lang) {
|
||||
loadLang(lang).then(loadRecords);
|
||||
localStorage.setItem('ui_lang', lang);
|
||||
}
|
||||
|
||||
async function loadRecords() {
|
||||
const kw = document.getElementById('searchInput').value.trim();
|
||||
const lang = document.getElementById('langFilter').value;
|
||||
try {
|
||||
const res = await fetch('/api/records?keyword=' + encodeURIComponent(kw) + '&language=' + encodeURIComponent(lang));
|
||||
const data = await res.json();
|
||||
if (data.code !== 0) { window.location.href = '/login'; return; }
|
||||
render(data.data.records);
|
||||
} catch (e) { window.location.href = '/login'; }
|
||||
}
|
||||
function render(records) {
|
||||
const list = document.getElementById('recordList');
|
||||
if (!records || records.length === 0) {
|
||||
list.innerHTML = '<div class="list-empty">' + (I18N.history_empty || '暂无历史记录,去工作台生成一个吧') + '</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = records.map(r => `
|
||||
<div class="record-item">
|
||||
<div>
|
||||
<div class="record-title">${r.title || I18N.unnamed_title || '未命名'}</div>
|
||||
<div class="record-meta">${r.scene === 'report' ? (I18N.scene_report || '社内汇报') : (I18N.scene_training || '社内培训')} · ${r.language === 'ja' ? (I18N.lang_ja || '日本語') : (I18N.lang_zh || '中文')} · ${r.page_count || '-'} ${I18N.preview_pages || '页'} · ${r.created_at}</div>
|
||||
</div>
|
||||
<div class="record-actions">
|
||||
<button class="btn-dl" onclick="download(${r.id})">${I18N.btn_download || '下载'}</button>
|
||||
<button class="btn-del" onclick="del(${r.id})">${I18N.btn_delete || '删除'}</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
function download(id) { window.location.href = '/api/files/' + id + '/download'; }
|
||||
async function del(id) {
|
||||
if (!confirm(I18N.confirm_del_record || '确认删除该记录?')) return;
|
||||
await fetch('/api/records/' + id, { method: 'DELETE' });
|
||||
loadRecords();
|
||||
}
|
||||
async function doLogout() { await fetch('/api/logout', { method: 'POST' }); window.location.href = '/login'; }
|
||||
|
||||
(async function init() {
|
||||
const saved = localStorage.getItem('ui_lang');
|
||||
await loadLang(saved === 'ja' ? 'ja' : 'zh');
|
||||
loadRecords();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"app_name": "プレゼン自動生成",
|
||||
"login_title": "ログイン",
|
||||
"register_title": "新規登録",
|
||||
"username": "ユーザー名",
|
||||
"password": "パスワード",
|
||||
"confirm_password": "パスワード確認",
|
||||
"login_btn": "ログイン",
|
||||
"register_btn": "登録",
|
||||
"brand_sub": "AI による PPT 自動作成ツール",
|
||||
|
||||
"nav_generate": "PPT 作成",
|
||||
"nav_history": "履歴",
|
||||
"nav_settings": "設定",
|
||||
|
||||
"page_title": "新規プレゼン作成",
|
||||
"step1": "内容入力",
|
||||
"step2": "設定調整",
|
||||
"step3": "ワンクリック生成",
|
||||
"label_title": "PPT タイトル",
|
||||
"title_placeholder": "タイトルを入力、または内容から自動生成…",
|
||||
"label_content": "PPT 内容",
|
||||
"tab_paste": "テキスト入力",
|
||||
"tab_upload": "文書アップロード",
|
||||
"content_placeholder": "ここに PPT のテキスト内容を入力…",
|
||||
"char_count": "文字",
|
||||
"upload_hint": "PDF・Word・Excel・PPT・Markdown・TXT・HTML・Webリンク",
|
||||
"upload_text": "クリックして文書をアップロード、またはここにドラッグ",
|
||||
"url_placeholder": "Webリンク(http(s)://…)を貼り付けて直接解析",
|
||||
"btn_parse_url": "リンクを解析",
|
||||
"btn_parse": "内容を解析",
|
||||
"parse_parsing": "解析中…",
|
||||
"parse_done": "解析完了。内容をテキストに反映しました",
|
||||
"parse_fail": "解析に失敗しました。再試行してください",
|
||||
"upload_ready": "準備完了",
|
||||
"url_needed": "先に Web リンクを貼り付けてください",
|
||||
|
||||
"config_title": "生成設定",
|
||||
"config_scene": "生成シーン",
|
||||
"scene_report": "社内報告",
|
||||
"scene_training": "社内研修",
|
||||
"config_color": "配色",
|
||||
"config_lang": "生成言語",
|
||||
"lang_zh": "中文",
|
||||
"lang_ja": "日本語",
|
||||
"config_canvas": "画面サイズ",
|
||||
"canvas_169": "ワイド 16:9",
|
||||
"canvas_43": "標準 4:3",
|
||||
"config_image": "画像設定",
|
||||
"image_web": "Web検索",
|
||||
"image_ai": "AI生成",
|
||||
"image_off": "画像なし",
|
||||
"config_pages": "ページ数",
|
||||
"btn_generate": "ワンクリック生成",
|
||||
"generating": "生成中…",
|
||||
|
||||
"btn_download": "ダウンロード",
|
||||
"btn_preview": "プレビュー",
|
||||
"btn_chat": "対話修正",
|
||||
"btn_regenerate": "再生成",
|
||||
|
||||
"chat_placeholder": "修正指示を入力:カラーを緑に変更 / タイトルを「XX」に変更 / 第2ページのAをBに変更",
|
||||
"chat_send": "送信",
|
||||
"chat_version": "現在のバージョン",
|
||||
"chat_history": "履歴",
|
||||
"chat_ge": "件",
|
||||
"chat_empty": "対話はまだありません。例:カラーを緑に変更 / 第2ページのAをBに変更",
|
||||
|
||||
"preview_loading": "プレビュー生成中…初回は数秒かかります",
|
||||
"preview_pages": "ページ",
|
||||
|
||||
"status_analyzing": "内容を分析中…",
|
||||
"status_planning": "構成を計画中…",
|
||||
"status_rendering": "レイアウト描画中…",
|
||||
"status_done": "生成完了",
|
||||
"status_failed": "生成失敗",
|
||||
|
||||
"toast_need_content": "先に PPT の内容を入力してください",
|
||||
"toast_page_invalid": "最少ページ数は最多ページ数を超えられません",
|
||||
"title_loading": "✨ 生成中…",
|
||||
"title_more": "候補:",
|
||||
"title_fail": "タイトル生成に失敗しました",
|
||||
|
||||
"btn_logout": "ログアウト",
|
||||
"sec_account": "アカウント保護",
|
||||
"label_username": "ユーザー名",
|
||||
"tip_username": "3〜20 文字",
|
||||
"label_old_password": "現在のパスワード",
|
||||
"label_new_password": "新しいパスワード(6 文字以上)",
|
||||
"label_confirm_password": "新しいパスワード(確認)",
|
||||
"btn_save_account": "アカウント変更を保存",
|
||||
"sec_prefs": "デフォルト設定",
|
||||
"color_blue": "ビジネスブルー",
|
||||
"color_green": "フレッシュグリーン",
|
||||
"color_purple": "テックパープル",
|
||||
"color_orange": "アクティブオレンジ",
|
||||
"color_gray": "ダークグレー",
|
||||
"tip_prefs": "保存後、新規作成時にこの設定が自動反映されます。",
|
||||
"btn_save_prefs": "設定を保存",
|
||||
"btn_back": "戻る",
|
||||
"save_ok": "保存しました",
|
||||
"save_fail": "保存に失敗しました",
|
||||
|
||||
"filter_all_lang": "全言語",
|
||||
|
||||
"history_search_ph": "タイトルを検索…",
|
||||
"btn_new_doc": "+ 新規作成",
|
||||
"btn_delete": "削除",
|
||||
"confirm_del_record": "この記録を削除しますか?",
|
||||
"unnamed_title": "タイトルなし",
|
||||
"history_empty": "履歴はまだありません。ワークスペースで作成しましょう",
|
||||
|
||||
"ph_username": "ユーザー名",
|
||||
"ph_password": "パスワード",
|
||||
"ph_reg_username": "ユーザー名(3〜20文字)",
|
||||
"ph_reg_password": "パスワード(6文字以上)",
|
||||
"ph_confirm_pwd": "パスワード確認",
|
||||
"login_need_input": "ユーザー名とパスワードを入力してください",
|
||||
"reg_need_input": "全項目を入力してください",
|
||||
"pwd_mismatch": "パスワードが一致しません",
|
||||
"login_loading": "ログイン中…",
|
||||
"reg_loading": "登録中…",
|
||||
|
||||
"footer": "© 2026 訊和創新科技(北京)有限公司"
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"app_name": "演示文稿智能生成",
|
||||
"login_title": "登录",
|
||||
"register_title": "注册",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"confirm_password": "确认密码",
|
||||
"login_btn": "登录",
|
||||
"register_btn": "注册",
|
||||
"brand_sub": "AI 驱动的 PPT 自动生成工具",
|
||||
|
||||
"nav_generate": "PPT 生成",
|
||||
"nav_history": "历史记录",
|
||||
"nav_settings": "个人设置",
|
||||
|
||||
"page_title": "新建演示文稿",
|
||||
"step1": "填写内容",
|
||||
"step2": "调整配置",
|
||||
"step3": "一键生成",
|
||||
"label_title": "PPT 标题",
|
||||
"title_placeholder": "输入或根据下方内容智能生成标题…",
|
||||
"label_content": "PPT 内容",
|
||||
"tab_paste": "输入文本",
|
||||
"tab_upload": "上传文档",
|
||||
"content_placeholder": "在此输入您的 PPT 文本内容…",
|
||||
"char_count": "字",
|
||||
"upload_hint": "支持 PDF · Word · Excel · PPT · Markdown · TXT · HTML · 网页链接",
|
||||
"upload_text": "点击上传文档,或将文档拖拽到此处",
|
||||
"url_placeholder": "或粘贴网页链接(http(s)://…),直接解析",
|
||||
"btn_parse_url": "解析链接",
|
||||
"btn_parse": "解析内容",
|
||||
"parse_parsing": "解析中…",
|
||||
"parse_done": "解析完成,内容已填入文本框",
|
||||
"parse_fail": "解析失败,请重试",
|
||||
"upload_ready": "已就绪",
|
||||
"url_needed": "请先粘贴网页链接",
|
||||
|
||||
"config_title": "生成配置",
|
||||
"config_scene": "生成场景",
|
||||
"scene_report": "社内汇报",
|
||||
"scene_training": "社内培训",
|
||||
"config_color": "配色方案",
|
||||
"config_lang": "生成语言",
|
||||
"lang_zh": "中文",
|
||||
"lang_ja": "日本語",
|
||||
"config_canvas": "画布格式",
|
||||
"canvas_169": "宽屏 16:9",
|
||||
"canvas_43": "标准 4:3",
|
||||
"config_image": "配图方式",
|
||||
"image_web": "网络搜图",
|
||||
"image_ai": "AI生成",
|
||||
"image_off": "不用图",
|
||||
"config_pages": "页数范围",
|
||||
"btn_generate": "一键生成",
|
||||
"generating": "正在生成…",
|
||||
|
||||
"btn_download": "下载",
|
||||
"btn_preview": "预览",
|
||||
"btn_chat": "对话修改",
|
||||
"btn_regenerate": "重新生成",
|
||||
|
||||
"chat_placeholder": "输入修改指令:换成绿色 / 把标题改成XX / 第2页把A改成B",
|
||||
"chat_send": "发送",
|
||||
"chat_version": "当前版本",
|
||||
"chat_history": "含历史",
|
||||
"chat_ge": "个版本",
|
||||
"chat_empty": "暂无对话。试试:换成绿色 / 第2页把A改成B / 第3页添加\"新的要点\"",
|
||||
|
||||
"preview_loading": "正在生成预览,首次约需数秒…",
|
||||
"preview_pages": "页",
|
||||
|
||||
"status_analyzing": "正在分析内容…",
|
||||
"status_planning": "正在规划结构…",
|
||||
"status_rendering": "正在排版渲染…",
|
||||
"status_done": "生成完成",
|
||||
"status_failed": "生成失败",
|
||||
|
||||
"toast_need_content": "请先输入 PPT 内容再生成",
|
||||
"toast_page_invalid": "最少页数不能大于最多页数,请调整",
|
||||
"title_loading": "✨ 生成中…",
|
||||
"title_more": "备选:",
|
||||
"title_fail": "智能标题生成失败,请重试",
|
||||
|
||||
"btn_logout": "退出登录",
|
||||
"sec_account": "账户安全",
|
||||
"label_username": "用户名",
|
||||
"tip_username": "3-20 个字符",
|
||||
"label_old_password": "原密码",
|
||||
"label_new_password": "新密码(至少 6 位)",
|
||||
"label_confirm_password": "确认新密码",
|
||||
"btn_save_account": "保存账户修改",
|
||||
"sec_prefs": "默认偏好",
|
||||
"color_blue": "商务蓝",
|
||||
"color_green": "清新绿",
|
||||
"color_purple": "科技紫",
|
||||
"color_orange": "活力橙",
|
||||
"color_gray": "深灰",
|
||||
"tip_prefs": "保存后,工作台新建演示时将按以上偏好预填。",
|
||||
"btn_save_prefs": "保存偏好",
|
||||
"btn_back": "返回",
|
||||
"save_ok": "保存成功",
|
||||
"save_fail": "保存失败",
|
||||
|
||||
"filter_all_lang": "全部语言",
|
||||
|
||||
"history_search_ph": "搜索标题…",
|
||||
"btn_new_doc": "+ 新建",
|
||||
"btn_delete": "删除",
|
||||
"confirm_del_record": "确认删除该记录?",
|
||||
"unnamed_title": "未命名",
|
||||
"history_empty": "暂无历史记录,去工作台生成一个吧",
|
||||
|
||||
"ph_username": "用户名",
|
||||
"ph_password": "密码",
|
||||
"ph_reg_username": "用户名(3-20位)",
|
||||
"ph_reg_password": "密码(至少6位)",
|
||||
"ph_confirm_pwd": "确认密码",
|
||||
"login_need_input": "请输入用户名和密码",
|
||||
"reg_need_input": "请填写完整信息",
|
||||
"pwd_mismatch": "两次输入的密码不一致",
|
||||
"login_loading": "登录中…",
|
||||
"reg_loading": "注册中…",
|
||||
|
||||
"footer": "© 2026 讯和創新科技(北京)有限公司"
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>演示文稿智能生成</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "Microsoft YaHei", sans-serif;
|
||||
background: #F5F6FA;
|
||||
height: 100vh; display: flex;
|
||||
align-items: center; justify-content: center;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.bg-circle-top { position: fixed; top: -120px; right: 320px; width: 320px; height: 320px; border-radius: 50%; background: #EDF4FD; z-index: 0; }
|
||||
.bg-circle-bottom { position: fixed; bottom: -60px; left: 280px; width: 160px; height: 160px; border-radius: 50%; background: #EDF4FD; z-index: 0; }
|
||||
|
||||
.login-card {
|
||||
width: 400px; background: #fff;
|
||||
border-radius: 16px; box-shadow: 0 8px 30px rgba(0,0,0,0.08);
|
||||
padding: 40px 36px; position: relative; z-index: 1;
|
||||
}
|
||||
.brand { text-align: center; margin-bottom: 24px; }
|
||||
.brand-icon { margin-bottom: 12px; }
|
||||
.brand-title { font-size: 20px; font-weight: 700; color: #1565C0; }
|
||||
.brand-sub { font-size: 13px; color: #999; margin-top: 6px; }
|
||||
|
||||
.tabs { display: flex; margin-bottom: 24px; border-bottom: 2px solid #E8ECF0; }
|
||||
.tab {
|
||||
flex: 1; padding: 10px 0; text-align: center; font-size: 15px;
|
||||
color: #999; cursor: pointer; border: none; background: none;
|
||||
font-family: inherit; border-bottom: 2px solid transparent; margin-bottom: -2px;
|
||||
}
|
||||
.tab.active { color: #1565C0; font-weight: 600; border-bottom-color: #1565C0; }
|
||||
|
||||
.form-group { margin-bottom: 16px; position: relative; }
|
||||
.form-input {
|
||||
width: 100%; height: 44px; border: 1px solid #E0E0E0; border-radius: 8px;
|
||||
padding: 0 14px; font-size: 14px; font-family: inherit; outline: none;
|
||||
transition: border 0.2s;
|
||||
}
|
||||
.form-input:focus { border-color: #1565C0; }
|
||||
.input-error { border-color: #E53935 !important; }
|
||||
.error-text { color: #E53935; font-size: 12px; margin-top: 4px; min-height: 16px; }
|
||||
|
||||
.btn-submit {
|
||||
width: 100%; height: 46px; border: none; border-radius: 8px;
|
||||
background: linear-gradient(135deg, #1565C0, #1E88E5);
|
||||
color: #fff; font-size: 15px; font-weight: 600; cursor: pointer;
|
||||
font-family: inherit; transition: all 0.3s; margin-top: 8px;
|
||||
}
|
||||
.btn-submit:hover { background: linear-gradient(135deg, #0D47A1, #1565C0); }
|
||||
.btn-submit:disabled { background: #C8CDD4; cursor: not-allowed; }
|
||||
|
||||
.toast { position: fixed; top: 24px; left: 50%; transform: translateX(-50%); background: #FFF3E0; border: 1px solid #FFB74D; color: #E65100; padding: 12px 24px; border-radius: 8px; font-size: 14px; z-index: 100; opacity: 0; pointer-events: none; transition: opacity 0.3s; }
|
||||
.toast.show { opacity: 1; }
|
||||
.footer { text-align: center; font-size: 12px; color: #BBB; margin-top: 20px; }
|
||||
.lang-switch { position: fixed; top: 20px; right: 24px; display: flex; gap: 6px; z-index: 2; }
|
||||
.lang-btn { height: 32px; padding: 0 14px; border: 1px solid #E0E0E0; border-radius: 8px; background: #fff; color: #666; font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||
.lang-btn.active { border-color: #1565C0; color: #1565C0; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-circle-top"></div>
|
||||
<div class="bg-circle-bottom"></div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<div class="lang-switch">
|
||||
<button class="lang-btn" id="langZh" onclick="setLang('zh')">中文</button>
|
||||
<button class="lang-btn" id="langJa" onclick="setLang('ja')">日本語</button>
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
<div class="brand">
|
||||
<svg class="brand-icon" width="56" height="56" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><circle cx="10" cy="10" r="9.5" fill="#E3F2FD"/><circle cx="9.5" cy="9.5" r="8" fill="#1565C0"/><circle cx="9.5" cy="9.5" r="6.5" fill="none" stroke="white" stroke-width="0.3" opacity="0.12"/><rect x="3.5" y="5" width="6" height="9" rx="0.8" fill="white"/><path d="M8 5L9.5 5L9.5 7.5Z" fill="#E3F2FD"/><circle cx="12" cy="8" r="1.2" fill="#2196F3"/><circle cx="11.5" cy="11.5" r="1" fill="white" opacity="0.85"/><circle cx="13.5" cy="10.5" r="1" fill="white" opacity="0.8"/></svg>
|
||||
<div class="brand-title" id="brandTitle" data-i18n="app_name">演示文稿智能生成</div>
|
||||
<div class="brand-sub" data-i18n="brand_sub">AI 驱动的 PPT 自动生成工具</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" id="tabLogin" onclick="switchForm('login')" data-i18n="login_btn">登录</button>
|
||||
<button class="tab" id="tabRegister" onclick="switchForm('register')" data-i18n="register_btn">注册</button>
|
||||
</div>
|
||||
|
||||
<div id="formLogin">
|
||||
<div class="form-group"><input class="form-input" id="loginUsername" data-i18n-ph="ph_username" placeholder="用户名" autocomplete="username"></div>
|
||||
<div class="form-group"><input class="form-input" id="loginPassword" type="password" data-i18n-ph="ph_password" placeholder="密码" autocomplete="current-password"></div>
|
||||
<div class="error-text" id="loginError"></div>
|
||||
<button class="btn-submit" id="btnLogin" onclick="doLogin()" data-i18n="login_btn">登录</button>
|
||||
</div>
|
||||
|
||||
<div id="formRegister" style="display:none;">
|
||||
<div class="form-group"><input class="form-input" id="regUsername" data-i18n-ph="ph_reg_username" placeholder="用户名(3-20位)" autocomplete="username"></div>
|
||||
<div class="form-group"><input class="form-input" id="regPassword" type="password" data-i18n-ph="ph_reg_password" placeholder="密码(至少6位)" autocomplete="new-password"></div>
|
||||
<div class="form-group"><input class="form-input" id="regConfirm" type="password" data-i18n-ph="ph_confirm_pwd" placeholder="确认密码" autocomplete="new-password"></div>
|
||||
<div class="error-text" id="regError"></div>
|
||||
<button class="btn-submit" id="btnRegister" onclick="doRegister()" data-i18n="register_btn">注册</button>
|
||||
</div>
|
||||
|
||||
<div class="footer" data-i18n="footer">© 2026 讯和創新科技(北京)有限公司</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let I18N = {};
|
||||
let currentLang = 'zh';
|
||||
|
||||
async function loadLang(lang) {
|
||||
currentLang = lang;
|
||||
try {
|
||||
const res = await fetch('/static/lang/' + lang + '.json');
|
||||
I18N = await res.json();
|
||||
} catch (e) { I18N = {}; }
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
el.textContent = I18N[el.dataset.i18n] || el.textContent;
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
|
||||
el.placeholder = I18N[el.dataset.i18nPh] || el.placeholder;
|
||||
});
|
||||
document.getElementById('langZh').classList.toggle('active', lang === 'zh');
|
||||
document.getElementById('langJa').classList.toggle('active', lang === 'ja');
|
||||
}
|
||||
|
||||
function setLang(lang) {
|
||||
loadLang(lang);
|
||||
localStorage.setItem('ui_lang', lang);
|
||||
}
|
||||
|
||||
function switchForm(mode) {
|
||||
document.getElementById('tabLogin').classList.toggle('active', mode === 'login');
|
||||
document.getElementById('tabRegister').classList.toggle('active', mode === 'register');
|
||||
document.getElementById('formLogin').style.display = mode === 'login' ? 'block' : 'none';
|
||||
document.getElementById('formRegister').style.display = mode === 'register' ? 'block' : 'none';
|
||||
document.getElementById('loginError').textContent = '';
|
||||
document.getElementById('regError').textContent = '';
|
||||
}
|
||||
|
||||
function showToast(msg) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg; t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 2500);
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
const username = document.getElementById('loginUsername').value.trim();
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
const btn = document.getElementById('btnLogin');
|
||||
const err = document.getElementById('loginError');
|
||||
err.textContent = '';
|
||||
if (!username || !password) { err.textContent = I18N.login_need_input || '请输入用户名和密码'; return; }
|
||||
btn.disabled = true; btn.textContent = I18N.login_loading || '登录中…';
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.code === 0) { window.location.href = '/workspace'; }
|
||||
else { err.textContent = data.message; }
|
||||
} catch (e) { showToast(I18N.toast_net || '网络异常,请检查网络后重试'); }
|
||||
btn.disabled = false; btn.textContent = I18N.login_btn || '登录';
|
||||
}
|
||||
|
||||
async function doRegister() {
|
||||
const username = document.getElementById('regUsername').value.trim();
|
||||
const password = document.getElementById('regPassword').value;
|
||||
const confirm = document.getElementById('regConfirm').value;
|
||||
const btn = document.getElementById('btnRegister');
|
||||
const err = document.getElementById('regError');
|
||||
err.textContent = '';
|
||||
if (!username || !password || !confirm) { err.textContent = I18N.reg_need_input || '请填写完整信息'; return; }
|
||||
if (password !== confirm) { err.textContent = I18N.pwd_mismatch || '两次输入的密码不一致'; return; }
|
||||
btn.disabled = true; btn.textContent = I18N.reg_loading || '注册中…';
|
||||
try {
|
||||
const res = await fetch('/api/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, confirm_password: confirm })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.code === 0) { window.location.href = '/workspace'; }
|
||||
else { err.textContent = data.message; }
|
||||
} catch (e) { showToast(I18N.toast_net || '网络异常,请检查网络后重试'); }
|
||||
btn.disabled = false; btn.textContent = I18N.register_btn || '注册';
|
||||
}
|
||||
|
||||
(async function init() {
|
||||
await loadLang(localStorage.getItem('ui_lang') === 'ja' ? 'ja' : 'zh');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,274 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>个人设置</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: "Microsoft YaHei", sans-serif; background: #EDEFF3; height: 100vh; overflow: hidden; display: flex; }
|
||||
.sidebar { width: 220px; height: 100vh; background: #FAFBFC; border-right: 1px solid #E4E7EB; display: flex; flex-direction: column; flex-shrink: 0; }
|
||||
.sidebar-brand { display: flex; align-items: center; gap: 8px; padding: 20px 16px; font-size: 15px; font-weight: 700; color: #1565C0; }
|
||||
.sidebar-nav { flex: 1; padding: 8px 0; }
|
||||
.nav-item { display: flex; align-items: center; height: 48px; padding: 0 16px; margin: 2px 8px; border-radius: 8px; cursor: pointer; font-size: 14px; color: #555; }
|
||||
.nav-item:hover { background: #EEF2F7; color: #333; }
|
||||
.nav-item.active { background: #E3F2FD; color: #1565C0; font-weight: 600; }
|
||||
.nav-icon { margin-right: 10px; }
|
||||
.sidebar-footer { padding: 16px; border-top: 1px solid #E8ECF0; }
|
||||
.btn-logout { width: 100%; height: 36px; border: 1px solid #E0E0E0; border-radius: 8px; background: #fff; color: #666; cursor: pointer; font-family: inherit; }
|
||||
.btn-logout:hover { border-color: #E53935; color: #E53935; }
|
||||
.main { flex: 1; overflow-y: auto; padding: 32px 48px; max-width: 980px; }
|
||||
.page-title { font-size: 22px; font-weight: 700; color: #1A1A1A; margin-bottom: 24px; }
|
||||
.card { background: #fff; border-radius: 10px; padding: 24px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
|
||||
.card-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 16px; }
|
||||
.field-grid { display: grid; grid-template-columns: repeat(3, minmax(180px, 1fr)); gap: 14px 20px; }
|
||||
@media (max-width: 1100px) { .field-grid { grid-template-columns: repeat(2, minmax(180px, 1fr)); } }
|
||||
.field { margin-bottom: 2px; }
|
||||
.field-label { font-size: 13px; color: #666; margin-bottom: 6px; display: block; }
|
||||
.field select, .field input[type="text"], .field input[type="password"] { width: 100%; height: 38px; border: 1px solid #E0E0E0; border-radius: 8px; padding: 0 12px; font-size: 14px; font-family: inherit; outline: none; background: #fff; }
|
||||
.field select:focus, .field input:focus { border-color: #1565C0; }
|
||||
.tip { font-size: 12px; color: #999; margin-top: 4px; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
|
||||
.btn-save { height: 40px; padding: 0 28px; border: none; border-radius: 8px; background: #1565C0; color: #fff; font-size: 14px; cursor: pointer; font-family: inherit; }
|
||||
.btn-save:hover { background: #0D47A1; }
|
||||
.btn-save:disabled { background: #BDBDBD; cursor: not-allowed; }
|
||||
.btn-back { height: 40px; padding: 0 18px; border: 1px solid #E0E0E0; border-radius: 8px; background: #fff; color: #666; cursor: pointer; font-family: inherit; }
|
||||
.btn-back:hover { border-color: #1565C0; color: #1565C0; }
|
||||
.toast { position: fixed; top: 24px; left: 50%; transform: translateX(-50%); background: #FFF3E0; border: 1px solid #FFB74D; color: #E65100; padding: 12px 24px; border-radius: 8px; font-size: 14px; z-index: 100; opacity: 0; pointer-events: none; transition: opacity 0.3s; }
|
||||
.toast.show { opacity: 1; }
|
||||
.lang-row { display: flex; gap: 8px; margin-bottom: 10px; }
|
||||
.lang-btn { flex: 1; height: 32px; border: 1px solid #E0E0E0; border-radius: 8px; background: #fff; color: #666; font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||
.lang-btn.active { border-color: #1565C0; color: #1565C0; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">📊 <span data-i18n="app_name">演示文稿智能生成</span></div>
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-item" onclick="location.href='/workspace'"><span class="nav-icon">📄</span><span data-i18n="nav_generate">PPT 生成</span></div>
|
||||
<div class="nav-item" onclick="location.href='/history'"><span class="nav-icon">🕐</span><span data-i18n="nav_history">历史记录</span></div>
|
||||
<div class="nav-item active"><span class="nav-icon">⚙️</span><span data-i18n="nav_settings">个人设置</span></div>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="lang-row">
|
||||
<button class="lang-btn" id="langZh" onclick="setLang('zh')">中文</button>
|
||||
<button class="lang-btn" id="langJa" onclick="setLang('ja')">日本語</button>
|
||||
</div>
|
||||
<button class="btn-logout" onclick="doLogout()" data-i18n="btn_logout">退出登录</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<div class="page-title" data-i18n="nav_settings">个人设置</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title" data-i18n="sec_account">账户安全</div>
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label class="field-label" data-i18n="label_username">用户名</label>
|
||||
<input type="text" id="accUsername" maxlength="20">
|
||||
<div class="tip" data-i18n="tip_username">3-20 个字符</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" data-i18n="label_old_password">原密码</label>
|
||||
<input type="password" id="accOldPwd">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" data-i18n="label_new_password">新密码(至少 6 位)</label>
|
||||
<input type="password" id="accNewPwd">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" data-i18n="label_confirm_password">确认新密码</label>
|
||||
<input type="password" id="accConfirmPwd">
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions"><button class="btn-save" id="btnAccount" onclick="saveAccount()" data-i18n="btn_save_account">保存账户修改</button></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title" data-i18n="sec_prefs">默认偏好</div>
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label class="field-label" data-i18n="config_scene">默认场景</label>
|
||||
<select id="prefScene">
|
||||
<option value="report" data-i18n="scene_report">社内汇报</option>
|
||||
<option value="training" data-i18n="scene_training">社内培训</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" data-i18n="config_color">默认配色</label>
|
||||
<select id="prefColor">
|
||||
<option value="blue" data-i18n="color_blue">商务蓝</option>
|
||||
<option value="green" data-i18n="color_green">清新绿</option>
|
||||
<option value="purple" data-i18n="color_purple">科技紫</option>
|
||||
<option value="orange" data-i18n="color_orange">活力橙</option>
|
||||
<option value="gray" data-i18n="color_gray">深灰</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" data-i18n="config_lang">默认生成语言</label>
|
||||
<select id="prefLang">
|
||||
<option value="zh" data-i18n="lang_zh">中文</option>
|
||||
<option value="ja" data-i18n="lang_ja">日本語</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" data-i18n="config_canvas">默认画布</label>
|
||||
<select id="prefCanvas">
|
||||
<option value="ppt169" data-i18n="canvas_169">宽屏 16:9</option>
|
||||
<option value="ppt43" data-i18n="canvas_43">标准 4:3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" data-i18n="config_image">默认配图</label>
|
||||
<select id="prefImage">
|
||||
<option value="web" data-i18n="image_web">网络搜图</option>
|
||||
<option value="ai" data-i18n="image_ai">AI生成</option>
|
||||
<option value="off" data-i18n="image_off">不用图</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" data-i18n="config_pages">默认页数范围</label>
|
||||
<select id="prefPages">
|
||||
<option value="5,10">5 - 10</option>
|
||||
<option value="10,15">10 - 15</option>
|
||||
<option value="15,20">15 - 20</option>
|
||||
<option value="20,30">20 - 30</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tip" style="margin-top:10px;" data-i18n="tip_prefs">保存后,工作台新建演示时将按以上偏好预填。</div>
|
||||
<div class="actions"><button class="btn-save" id="btnPrefs" onclick="savePrefs()" data-i18n="btn_save_prefs">保存偏好</button></div>
|
||||
</div>
|
||||
|
||||
<div class="actions" style="justify-content:flex-start;">
|
||||
<button class="btn-back" onclick="location.href='/workspace'" data-i18n="btn_back">返回</button>
|
||||
</div>
|
||||
</main>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
let I18N = {};
|
||||
let currentLang = 'zh';
|
||||
|
||||
async function loadLang(lang) {
|
||||
currentLang = lang;
|
||||
try {
|
||||
const res = await fetch('/static/lang/' + lang + '.json');
|
||||
I18N = await res.json();
|
||||
} catch (e) { I18N = {}; }
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
el.textContent = I18N[el.dataset.i18n] || el.textContent;
|
||||
});
|
||||
document.getElementById('langZh').classList.toggle('active', lang === 'zh');
|
||||
document.getElementById('langJa').classList.toggle('active', lang === 'ja');
|
||||
}
|
||||
|
||||
function setLang(lang) {
|
||||
loadLang(lang);
|
||||
localStorage.setItem('ui_lang', lang);
|
||||
}
|
||||
|
||||
function showToast(msg) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 2600);
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
const res = await fetch('/api/settings');
|
||||
const data = await res.json();
|
||||
if (data.code !== 0) { window.location.href = '/login'; return; }
|
||||
document.getElementById('accUsername').value = data.data.username || '';
|
||||
const p = data.data.preferences || {};
|
||||
if (p.default_scene) document.getElementById('prefScene').value = p.default_scene;
|
||||
if (p.default_color) document.getElementById('prefColor').value = p.default_color;
|
||||
if (p.default_language) document.getElementById('prefLang').value = p.default_language;
|
||||
if (p.default_canvas) document.getElementById('prefCanvas').value = p.default_canvas;
|
||||
if (p.default_image_strategy) document.getElementById('prefImage').value = p.default_image_strategy;
|
||||
if (p.default_page_min != null && p.default_page_max != null) {
|
||||
const range = p.default_page_min + ',' + p.default_page_max;
|
||||
const sel = document.getElementById('prefPages');
|
||||
sel.value = range;
|
||||
if (sel.value !== range) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = range;
|
||||
opt.textContent = range.replace(',', ' - ');
|
||||
sel.appendChild(opt);
|
||||
sel.value = range;
|
||||
}
|
||||
}
|
||||
} catch (e) { window.location.href = '/login'; }
|
||||
}
|
||||
|
||||
async function saveAccount() {
|
||||
const btn = document.getElementById('btnAccount');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const name = document.getElementById('accUsername').value.trim();
|
||||
const oldPwd = document.getElementById('accOldPwd').value;
|
||||
const newPwd = document.getElementById('accNewPwd').value;
|
||||
const confirm = document.getElementById('accConfirmPwd').value;
|
||||
|
||||
let res = await fetch('/api/settings/username', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: name })
|
||||
});
|
||||
let data = await res.json();
|
||||
if (data.code !== 0) { showToast(data.message || fail()); return; }
|
||||
|
||||
if (oldPwd || newPwd || confirm) {
|
||||
res = await fetch('/api/settings/password', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ old_password: oldPwd, new_password: newPwd, confirm_password: confirm })
|
||||
});
|
||||
data = await res.json();
|
||||
if (data.code !== 0) { showToast(data.message || fail()); return; }
|
||||
document.getElementById('accOldPwd').value = '';
|
||||
document.getElementById('accNewPwd').value = '';
|
||||
document.getElementById('accConfirmPwd').value = '';
|
||||
}
|
||||
showToast(I18N.save_ok || '保存成功');
|
||||
} catch (e) {
|
||||
showToast(I18N.toast_net || '网络异常,请检查网络后重试');
|
||||
} finally { btn.disabled = false; }
|
||||
}
|
||||
|
||||
function fail() { return I18N.save_fail || '保存失败'; }
|
||||
|
||||
async function savePrefs() {
|
||||
const btn = document.getElementById('btnPrefs');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const [mn, mx] = document.getElementById('prefPages').value.split(',').map(Number);
|
||||
const res = await fetch('/api/settings/preferences', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
default_scene: document.getElementById('prefScene').value,
|
||||
default_color: document.getElementById('prefColor').value,
|
||||
default_language: document.getElementById('prefLang').value,
|
||||
default_canvas: document.getElementById('prefCanvas').value,
|
||||
default_image_strategy: document.getElementById('prefImage').value,
|
||||
default_page_min: mn,
|
||||
default_page_max: mx
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.code !== 0) { showToast(data.message || fail()); return; }
|
||||
showToast(I18N.save_ok || '保存成功');
|
||||
} catch (e) {
|
||||
showToast(I18N.toast_net || '网络异常,请检查网络后重试');
|
||||
} finally { btn.disabled = false; }
|
||||
}
|
||||
|
||||
async function doLogout() { await fetch('/api/logout', { method: 'POST' }); window.location.href = '/login'; }
|
||||
|
||||
(async function init() {
|
||||
const saved = localStorage.getItem('ui_lang');
|
||||
await loadLang(saved === 'ja' ? 'ja' : 'zh');
|
||||
loadAll();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,812 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>演示文稿智能生成</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "Microsoft YaHei", sans-serif;
|
||||
background: #F5F6FA; height: 100vh; overflow: hidden; display: flex;
|
||||
}
|
||||
.bg-decor { position: fixed; pointer-events: none; z-index: 0; }
|
||||
.bg-circle-top { top: -120px; right: 320px; width: 320px; height: 320px; border-radius: 50%; background: #EDF4FD; }
|
||||
.bg-circle-bottom { bottom: -60px; left: 280px; width: 160px; height: 160px; border-radius: 50%; background: #EDF4FD; }
|
||||
|
||||
/* 侧边栏 */
|
||||
.sidebar {
|
||||
width: 220px; height: 100vh; background: #FAFBFC;
|
||||
border-right: 1px solid #E4E7EB; display: flex; flex-direction: column;
|
||||
transition: width 0.3s ease; flex-shrink: 0; z-index: 10;
|
||||
}
|
||||
.sidebar-collapsed { width: 60px; }
|
||||
.sidebar-brand { display: flex; align-items: center; gap: 8px; padding: 20px 16px; font-size: 15px; font-weight: 700; color: #1565C0; white-space: nowrap; overflow: hidden; }
|
||||
.sidebar-nav { flex: 1; padding: 8px 0; }
|
||||
.nav-item { display: flex; align-items: center; height: 48px; padding: 0 16px; margin: 2px 8px; border-radius: 8px; cursor: pointer; font-size: 14px; color: #555; white-space: nowrap; overflow: hidden; transition: all 0.2s; position: relative; }
|
||||
.nav-item:hover { background: #EEF2F7; color: #333; }
|
||||
.nav-item.active { background: #E3F2FD; color: #1565C0; font-weight: 600; }
|
||||
.nav-item.active::before { content: ""; position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 24px; background: #1565C0; border-radius: 0 3px 3px 0; }
|
||||
.nav-icon { font-size: 18px; margin-right: 10px; flex-shrink: 0; }
|
||||
.sidebar-footer { padding: 16px; border-top: 1px solid #E8ECF0; }
|
||||
.lang-switch { display: flex; gap: 4px; background: #F0F2F5; border-radius: 8px; padding: 3px; margin-bottom: 12px; }
|
||||
.lang-btn { flex: 1; border: none; background: transparent; padding: 6px 0; border-radius: 6px; font-size: 13px; color: #888; cursor: pointer; font-family: inherit; }
|
||||
.lang-btn.active { background: #fff; color: #1565C0; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.btn-logout { width: 100%; height: 36px; border: 1px solid #E0E0E0; border-radius: 8px; background: #fff; color: #666; font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||
.btn-logout:hover { border-color: #E53935; color: #E53935; }
|
||||
|
||||
/* 中间 */
|
||||
.main-content { flex: 1; height: 100vh; overflow: hidden; padding: 18px 32px 10px; background: #EDEFF3; position: relative; z-index: 1; display: flex; flex-direction: column; }
|
||||
.main-content-inner { width: 100%; flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: #1A1A1A; margin-bottom: 6px; flex-shrink: 0; }
|
||||
|
||||
.steps-bar { display: flex; align-items: center; gap: 0; margin-bottom: 12px; font-size: 13px; flex-shrink: 0; }
|
||||
.step-item { display: flex; align-items: center; gap: 6px; color: #BBB; }
|
||||
.step-num { width: 24px; height: 24px; border-radius: 50%; background: #E8ECF0; color: #AAA; font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
|
||||
.step-item.current { color: #1565C0; font-weight: 600; font-size: 14px; }
|
||||
.step-item.current .step-num { background: #1565C0; color: #fff; width: 26px; height: 26px; }
|
||||
.step-item.done { color: #666; }
|
||||
.step-item.done .step-num { background: #34A853; color: #fff; }
|
||||
.step-arrow { margin: 0 12px; color: #D0D0D0; font-size: 14px; }
|
||||
|
||||
.card { background: #fff; border-radius: 12px; padding: 20px 24px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); flex: 1; display: flex; flex-direction: column; min-height: 220px; }
|
||||
.form-row { margin-bottom: 14px; flex-shrink: 0; }
|
||||
.form-row:last-child { flex: 1; display: flex; flex-direction: column; min-height: 0; margin-bottom: 0; }
|
||||
.form-label { font-size: 14px; color: #666; margin-bottom: 8px; display: block; }
|
||||
.title-row { display: flex; gap: 8px; align-items: center; }
|
||||
.title-input { flex: 1; height: 44px; border: 1px solid #E0E0E0; border-radius: 8px; padding: 0 14px; font-size: 14px; font-family: inherit; outline: none; }
|
||||
.title-input:focus { border-color: #1565C0; }
|
||||
.btn-ai-title { height: 44px; padding: 0 18px; border: none; border-radius: 8px; font-size: 14px; font-family: inherit; white-space: nowrap; }
|
||||
.btn-ai-disabled { background: #F5F5F5; color: #BBB; cursor: not-allowed; }
|
||||
.btn-ai-idle { background: #E3F2FD; color: #1565C0; cursor: pointer; }
|
||||
.btn-ai-idle:hover { background: #BBDEFB; }
|
||||
|
||||
.source-tabs { display: flex; border-bottom: 2px solid #E8ECF0; flex-shrink: 0; }
|
||||
.source-tab { padding: 8px 20px; font-size: 14px; color: #999; cursor: pointer; border: none; background: none; font-family: inherit; border-bottom: 2px solid transparent; margin-bottom: -2px; }
|
||||
.source-tab.active { color: #1565C0; font-weight: 600; border-bottom-color: #1565C0; }
|
||||
.tab-panel { display: none; padding-top: 10px; }
|
||||
.tab-panel.active { display: flex; flex-direction: column; flex: 1; min-height: 0; padding-top: 10px; }
|
||||
.text-area { width: 100%; flex: 1; min-height: 0; border: 1px solid #E0E0E0; border-radius: 8px; padding: 12px 14px; font-size: 14px; line-height: 1.8; font-family: inherit; resize: none; outline: none; overflow-y: auto; }
|
||||
.text-area:focus { border-color: #1565C0; }
|
||||
.char-count { text-align: right; font-size: 12px; color: #BBB; margin-top: 4px; }
|
||||
.upload-zone { border: 2px dashed #D0D5DD; border-radius: 8px; padding: 20px; text-align: center; color: #999; font-size: 14px; cursor: pointer; }
|
||||
.upload-zone:hover, .upload-zone.drag { border-color: #1565C0; color: #1565C0; background: #FAFBFC; }
|
||||
.upload-zone.drag { background: #F0F6FF; }
|
||||
.url-row { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.url-input { flex: 1; height: 36px; min-width: 0; border: 1px solid #E0E4E9; border-radius: 8px; padding: 0 12px; font-size: 13px; font-family: inherit; outline: none; }
|
||||
.url-input:focus { border-color: #1565C0; }
|
||||
.btn-url { height: 36px; padding: 0 14px; flex-shrink: 0; border: none; border-radius: 8px; background: #262B33; color: #fff; font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||
.btn-url:disabled { background: #BDBDBD; cursor: not-allowed; }
|
||||
.upload-hint { font-size: 12px; color: #BBB; margin-top: 6px; }
|
||||
.file-list { margin-top: 12px; }
|
||||
.file-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; background: #F8FAFB; border: 1px solid #E8ECF0; border-radius: 8px; margin-bottom: 8px; font-size: 14px; }
|
||||
.file-item .file-name { color: #333; }
|
||||
.file-item .file-remove { width: 24px; height: 24px; border-radius: 50%; border: none; background: #EEE; color: #999; cursor: pointer; }
|
||||
.btn-parse { display: block; width: 100%; height: 40px; margin-top: 16px; border: none; border-radius: 8px; background: #1565C0; color: #fff; font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit; }
|
||||
.btn-parse:disabled { background: #BDBDBD; cursor: not-allowed; }
|
||||
|
||||
/* 右侧配置 */
|
||||
.config-panel { width: 320px; height: 100vh; padding: 18px 16px; flex-shrink: 0; z-index: 5; background: #F5F6FA; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.config-card { background: #fff; border-radius: 12px; padding: 18px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); flex: 1; display: flex; flex-direction: column; justify-content: space-between; min-height: 0; }
|
||||
.config-section { margin-bottom: 12px; flex-shrink: 0; }
|
||||
.config-label { font-size: 12px; color: #888; margin-bottom: 6px; display: block; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.scene-btn { display: block; width: 100%; height: 40px; border: 1.5px solid #E0E0E0; border-radius: 8px; background: #fff; color: #555; font-size: 14px; cursor: pointer; font-family: inherit; margin-bottom: 6px; }
|
||||
.scene-btn.active { background: #E3F2FD; border-color: #1565C0; color: #1565C0; font-weight: 600; }
|
||||
.opt-group { display: flex; gap: 6px; }
|
||||
.opt-btn { flex: 1; height: 34px; border: 1.5px solid #E0E0E0; border-radius: 8px; background: #fff; color: #555; font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||
.opt-btn.active { background: #E3F2FD; border-color: #1565C0; color: #1565C0; font-weight: 600; }
|
||||
.color-dots { display: flex; gap: 12px; }
|
||||
.color-dot { width: 32px; height: 32px; border-radius: 50%; border: 3px solid transparent; cursor: pointer; position: relative; }
|
||||
.color-dot.active { border-color: #1565C0; box-shadow: 0 0 0 2px #E3F2FD; }
|
||||
.color-dot.active::after { content: "✓"; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 14px; font-weight: 700; }
|
||||
.page-select { width: 100%; height: 40px; border: 1px solid #E0E0E0; border-radius: 8px; padding: 0 12px; font-size: 14px; font-family: inherit; outline: none; background: #fff; }
|
||||
.btn-generate { width: 100%; height: 46px; border: none; border-radius: 8px; background: linear-gradient(135deg, #1565C0, #1E88E5); color: #fff; font-size: 16px; font-weight: 700; cursor: pointer; font-family: inherit; transition: all 0.3s; flex-shrink: 0; }
|
||||
.btn-generate:hover { background: linear-gradient(135deg, #0D47A1, #1565C0); }
|
||||
.btn-generate.loading { background: #C8CDD4; color: #888; cursor: not-allowed; }
|
||||
|
||||
.toast { position: fixed; top: 24px; left: 50%; transform: translateX(-50%); background: #FFF3E0; border: 1px solid #FFB74D; color: #E65100; padding: 12px 24px; border-radius: 8px; font-size: 14px; z-index: 100; opacity: 0; pointer-events: none; transition: opacity 0.3s; }
|
||||
.toast.show { opacity: 1; }
|
||||
|
||||
/* 进度面板 */
|
||||
.progress-panel { display: none; margin-top: 12px; padding: 12px 16px; background: #E3F2FD; border-radius: 8px; flex-shrink: 0; }
|
||||
.progress-panel.show { display: block; }
|
||||
.progress-text { font-size: 14px; color: #1565C0; margin-bottom: 8px; }
|
||||
.progress-bar { height: 6px; background: #BBDEFB; border-radius: 3px; overflow: hidden; }
|
||||
.progress-bar-fill { height: 100%; width: 0; background: #1565C0; transition: width 0.4s ease; }
|
||||
|
||||
/* 结果卡片 */
|
||||
.result-card { display: none; margin-top: 12px; flex-shrink: 0; }
|
||||
.result-card.show { display: block; }
|
||||
.result-info { padding: 16px; background: #F1F8E9; border-radius: 8px 8px 0 0; font-size: 14px; }
|
||||
.result-name { font-weight: 600; color: #2E7D32; margin-bottom: 4px; }
|
||||
.result-size { font-size: 12px; color: #888; margin-bottom: 10px; }
|
||||
.result-actions { display: flex; gap: 8px; padding: 12px 16px; background: #fff; border: 1px solid #E8F5E9; border-top: none; border-radius: 0 0 8px 8px; }
|
||||
.btn-download, .btn-preview, .btn-chat { flex: 1; height: 36px; border-radius: 6px; font-size: 13px; cursor: pointer; font-family: inherit; border: none; color: #fff; }
|
||||
.btn-download { background: #1565C0; }
|
||||
.btn-preview { background: #34A853; }
|
||||
.btn-chat { background: #7C3AED; }
|
||||
.btn-chat:hover { background: #6D28D9; }
|
||||
.footer-bar { text-align: center; padding: 8px 0 0; font-size: 12px; color: #BBB; margin-top: 8px; flex-shrink: 0; }
|
||||
|
||||
/* 对话修改弹窗 */
|
||||
.chat-overlay { display: none; position: fixed; inset: 0; background: rgba(15,23,42,0.45); z-index: 200; align-items: center; justify-content: center; }
|
||||
.chat-overlay.show { display: flex; }
|
||||
.chat-modal { width: 560px; max-width: 92vw; height: 640px; max-height: 88vh; background: #fff; border-radius: 12px; display: flex; flex-direction: column; box-shadow: 0 12px 40px rgba(0,0,0,0.18); overflow: hidden; }
|
||||
.chat-head { padding: 14px 18px; border-bottom: 1px solid #EEF1F5; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
|
||||
.chat-title { font-size: 15px; font-weight: 700; color: #1A1A1A; }
|
||||
.chat-ver { font-size: 12px; color: #888; margin-top: 2px; }
|
||||
.chat-close { width: 28px; height: 28px; border: none; border-radius: 50%; background: #F2F4F7; color: #888; cursor: pointer; font-size: 14px; }
|
||||
.chat-close:hover { background: #E5E9EF; color: #333; }
|
||||
.chat-body { flex: 1; overflow-y: auto; padding: 16px 18px; background: #F7F9FB; display: flex; flex-direction: column; gap: 10px; }
|
||||
.msg { max-width: 82%; padding: 9px 13px; border-radius: 10px; font-size: 13.5px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
|
||||
.msg.user { align-self: flex-end; background: #1565C0; color: #fff; border-bottom-right-radius: 3px; }
|
||||
.msg.assistant { align-self: flex-start; background: #fff; color: #333; border: 1px solid #E8ECF0; border-bottom-left-radius: 3px; }
|
||||
.chat-foot { padding: 12px 14px; border-top: 1px solid #EEF1F5; display: flex; gap: 8px; background: #fff; flex-shrink: 0; }
|
||||
.chat-input { flex: 1; height: 40px; border: 1px solid #E0E0E0; border-radius: 8px; padding: 0 12px; font-size: 13.5px; font-family: inherit; outline: none; }
|
||||
.chat-input:focus { border-color: #1565C0; }
|
||||
.chat-send { width: 76px; height: 40px; border: none; border-radius: 8px; background: linear-gradient(135deg, #1565C0, #1E88E5); color: #fff; font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit; }
|
||||
.chat-send:disabled { background: #C8CDD4; cursor: not-allowed; }
|
||||
|
||||
/* 预览弹层 */
|
||||
.preview-overlay { display: none; position: fixed; inset: 0; background: rgba(15,23,42,0.55); z-index: 210; align-items: center; justify-content: center; }
|
||||
.preview-overlay.show { display: flex; }
|
||||
.preview-modal { width: 860px; max-width: 94vw; height: 88vh; background: #1B1F27; border-radius: 12px; display: flex; flex-direction: column; box-shadow: 0 12px 40px rgba(0,0,0,0.3); overflow: hidden; }
|
||||
.preview-head { padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; background: #14171D; flex-shrink: 0; }
|
||||
.preview-title { font-size: 14px; font-weight: 600; color: #E8EAED; }
|
||||
.preview-sub { font-size: 12px; color: #9AA0A6; margin-top: 2px; }
|
||||
.preview-body { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; align-items: center; gap: 16px; }
|
||||
.preview-body img { width: 100%; max-width: 780px; border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,0.4); background: #fff; }
|
||||
.preview-loading { color: #9AA0A6; font-size: 14px; padding: 60px 0; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-decor bg-circle-top"></div>
|
||||
<div class="bg-decor bg-circle-bottom"></div>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><circle cx="10" cy="10" r="9.5" fill="#E3F2FD"/><circle cx="9.5" cy="9.5" r="8" fill="#1565C0"/><rect x="3.5" y="5" width="6" height="9" rx="0.8" fill="white"/><path d="M8 5L9.5 5L9.5 7.5Z" fill="#E3F2FD"/><circle cx="12" cy="8" r="1.2" fill="#2196F3"/></svg>
|
||||
<span id="sidebarBrandText">演示文稿智能生成</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-item active"><span class="nav-icon">📄</span><span data-i18n="nav_generate">PPT 生成</span></div>
|
||||
<div class="nav-item" onclick="location.href='/history'"><span class="nav-icon">🕐</span><span data-i18n="nav_history">历史记录</span></div>
|
||||
<div class="nav-item" onclick="location.href='/settings'"><span class="nav-icon">⚙️</span><span data-i18n="nav_settings">个人设置</span></div>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="lang-switch">
|
||||
<button class="lang-btn active" id="langZh" onclick="setLang('zh')">中文</button>
|
||||
<button class="lang-btn" id="langJa" onclick="setLang('ja')">日本語</button>
|
||||
</div>
|
||||
<button class="btn-logout" onclick="doLogout()" data-i18n="btn_logout">退出登录</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="main-content-inner">
|
||||
<div class="page-title" data-i18n="page_title">新建演示文稿</div>
|
||||
<div class="steps-bar">
|
||||
<div class="step-item current" id="step1"><span class="step-num">1</span> <span data-i18n="step1">填写内容</span></div>
|
||||
<span class="step-arrow">→</span>
|
||||
<div class="step-item" id="step2"><span class="step-num">2</span> <span data-i18n="step2">调整配置</span></div>
|
||||
<span class="step-arrow">→</span>
|
||||
<div class="step-item" id="step3"><span class="step-num">3</span> <span data-i18n="step3">一键生成</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="form-row">
|
||||
<label class="form-label" data-i18n="label_title">PPT 标题</label>
|
||||
<div class="title-row">
|
||||
<input type="text" class="title-input" id="titleInput" data-i18n-ph="title_placeholder" placeholder="输入或根据下方内容智能生成标题…">
|
||||
<button class="btn-ai-title btn-ai-disabled" id="btnAiTitle" disabled onclick="suggestTitle()" data-i18n="btn_ai_title">✨ 智能标题</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label" data-i18n="label_content">PPT 内容</label>
|
||||
<div class="source-tabs">
|
||||
<button class="source-tab active" onclick="switchTab('paste')" data-i18n="tab_paste">输入文本</button>
|
||||
<button class="source-tab" onclick="switchTab('upload')" data-i18n="tab_upload">上传文档</button>
|
||||
</div>
|
||||
<div class="tab-panel active" id="panelPaste">
|
||||
<textarea class="text-area" id="contentArea" data-i18n-ph="content_placeholder" placeholder="在此输入您的 PPT 文本内容…" oninput="onContentChange()"></textarea>
|
||||
<div class="char-count" id="charCount">0 <span data-i18n="char_count">字</span></div>
|
||||
</div>
|
||||
<div class="tab-panel" id="panelUpload">
|
||||
<div class="upload-zone" id="uploadZone">
|
||||
<div data-i18n="upload_text">点击上传文档,或将文档拖拽到此处</div>
|
||||
<div class="upload-hint" data-i18n="upload_hint">支持 PDF · Word · Excel · PPT · Markdown · TXT · HTML · 网页链接</div>
|
||||
</div>
|
||||
<input type="file" id="fileInput" hidden accept=".pdf,.doc,.docx,.xlsx,.xlsm,.pptx,.md,.txt,.html,.htm">
|
||||
<div class="url-row">
|
||||
<input type="text" class="url-input" id="urlInput" data-i18n-ph="url_placeholder" placeholder="或粘贴网页链接(http(s)://…),直接解析">
|
||||
<button class="btn-url" id="btnParseUrl" onclick="parseUrl()" data-i18n="btn_parse_url">解析链接</button>
|
||||
</div>
|
||||
<div class="file-list" id="fileList"></div>
|
||||
<button class="btn-parse" id="btnParse" disabled onclick="parseUploaded()" data-i18n="btn_parse">解析内容</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-panel" id="progressPanel">
|
||||
<div class="progress-text" id="progressText">正在生成…</div>
|
||||
<div class="progress-bar"><div class="progress-bar-fill" id="progressBarFill"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="result-card" id="resultCard">
|
||||
<div class="result-info">
|
||||
<div class="result-name" id="resultName"></div>
|
||||
<div class="result-size" id="resultSize"></div>
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<button class="btn-download" onclick="downloadResult()" data-i18n="btn_download">下载</button>
|
||||
<button class="btn-preview" onclick="previewResult()" data-i18n="btn_preview">预览</button>
|
||||
<button class="btn-chat" onclick="openChat()" data-i18n="btn_chat">对话修改</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-bar" data-i18n="footer">© 2026 讯和創新科技(北京)有限公司</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="config-panel">
|
||||
<div class="config-card">
|
||||
<div style="font-size:14px;font-weight:700;color:#333;margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid #EEE;flex-shrink:0;" data-i18n="config_title">生成配置</div>
|
||||
|
||||
<div class="config-section">
|
||||
<label class="config-label" data-i18n="config_scene">生成场景</label>
|
||||
<button class="scene-btn" data-scene="report" onclick="selectScene(this)" data-i18n="scene_report">社内汇报</button>
|
||||
<button class="scene-btn active" data-scene="training" onclick="selectScene(this)" data-i18n="scene_training">社内培训</button>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<label class="config-label" data-i18n="config_color">配色方案</label>
|
||||
<div class="color-dots">
|
||||
<div class="color-dot active" style="background:#1565C0;" data-color="blue" onclick="selectColor(this)" title="商务蓝"></div>
|
||||
<div class="color-dot" style="background:#34A853;" data-color="green" onclick="selectColor(this)" title="清新绿"></div>
|
||||
<div class="color-dot" style="background:#7C3AED;" data-color="purple" onclick="selectColor(this)" title="科技紫"></div>
|
||||
<div class="color-dot" style="background:#F59E0B;" data-color="orange" onclick="selectColor(this)" title="活力橙"></div>
|
||||
<div class="color-dot" style="background:#374151;" data-color="gray" onclick="selectColor(this)" title="深灰"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<label class="config-label" data-i18n="config_pages">页数范围</label>
|
||||
<select class="page-select" id="pageSelect" onchange="onPageChange()">
|
||||
<option value="5,10">5-10 页</option>
|
||||
<option value="10,15" selected>10-15 页</option>
|
||||
<option value="15,20">15-20 页</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<label class="config-label" data-i18n="config_lang">生成语言</label>
|
||||
<div class="opt-group" data-group="lang">
|
||||
<button class="opt-btn active" data-value="zh" onclick="selectOpt(this)">中文</button>
|
||||
<button class="opt-btn" data-value="ja" onclick="selectOpt(this)">日本語</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<label class="config-label" data-i18n="config_canvas">画布格式</label>
|
||||
<div class="opt-group" data-group="canvas">
|
||||
<button class="opt-btn active" data-value="ppt169" onclick="selectOpt(this)">宽屏 16:9</button>
|
||||
<button class="opt-btn" data-value="ppt43" onclick="selectOpt(this)">标准 4:3</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<label class="config-label" data-i18n="config_image">配图方式</label>
|
||||
<div class="opt-group" data-group="image">
|
||||
<button class="opt-btn active" data-value="web" onclick="selectOpt(this)">网络搜图</button>
|
||||
<button class="opt-btn" data-value="ai" onclick="selectOpt(this)">AI生成</button>
|
||||
<button class="opt-btn" data-value="off" onclick="selectOpt(this)">不用图</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-generate" id="btnGenerate" onclick="startGenerate()" data-i18n="btn_generate">一键生成</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 预览弹层 -->
|
||||
<div class="preview-overlay" id="previewOverlay" onclick="if(event.target===this)closePreview()">
|
||||
<div class="preview-modal">
|
||||
<div class="preview-head">
|
||||
<div>
|
||||
<div class="preview-title" id="previewTitle"></div>
|
||||
<div class="preview-sub" id="previewSub"></div>
|
||||
</div>
|
||||
<button class="chat-close" onclick="closePreview()" style="background:#262B33;color:#9AA0A6;">✕</button>
|
||||
</div>
|
||||
<div class="preview-body" id="previewBody">
|
||||
<div class="preview-loading" id="previewLoading"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对话修改弹窗 -->
|
||||
<div class="chat-overlay" id="chatOverlay">
|
||||
<div class="chat-modal">
|
||||
<div class="chat-head">
|
||||
<div>
|
||||
<div class="chat-title" data-i18n="btn_chat">对话修改</div>
|
||||
<div class="chat-ver" id="chatVer"></div>
|
||||
</div>
|
||||
<button class="chat-close" onclick="closeChat()">✕</button>
|
||||
</div>
|
||||
<div class="chat-body" id="chatBody"></div>
|
||||
<div class="chat-foot">
|
||||
<input class="chat-input" id="chatInput" data-i18n-ph="chat_placeholder"
|
||||
placeholder="输入修改指令:换成绿色 / 把标题改成XX / 第2页把A改成B"
|
||||
onkeydown="if(event.key==='Enter')sendChat()">
|
||||
<button class="chat-send" id="chatSend" onclick="sendChat()" data-i18n="chat_send">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ===== 语言包 =====
|
||||
let I18N = {};
|
||||
let currentLang = 'zh';
|
||||
|
||||
async function loadLang(lang) {
|
||||
try {
|
||||
const res = await fetch('/static/lang/' + lang + '.json');
|
||||
I18N = await res.json();
|
||||
} catch (e) { I18N = {}; }
|
||||
applyI18n();
|
||||
}
|
||||
|
||||
function applyI18n() {
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.dataset.i18n;
|
||||
el.textContent = I18N[key] || el.textContent;
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
|
||||
const key = el.dataset.i18nPh;
|
||||
el.placeholder = I18N[key] || el.placeholder;
|
||||
});
|
||||
document.getElementById('sidebarBrandText').textContent = I18N.app_name || '演示文稿智能生成';
|
||||
}
|
||||
|
||||
function setLang(lang) {
|
||||
currentLang = lang;
|
||||
document.getElementById('langZh').classList.toggle('active', lang === 'zh');
|
||||
document.getElementById('langJa').classList.toggle('active', lang === 'ja');
|
||||
loadLang(lang);
|
||||
localStorage.setItem('ui_lang', lang);
|
||||
}
|
||||
|
||||
// ===== 配置状态 =====
|
||||
const state = {
|
||||
scene: 'training', color: 'blue', lang: 'zh',
|
||||
canvas: 'ppt169', image: 'web',
|
||||
pageMin: 10, pageMax: 15,
|
||||
taskId: null, recordId: null, polling: null
|
||||
};
|
||||
|
||||
function selectScene(btn) {
|
||||
document.querySelectorAll('.scene-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
state.scene = btn.dataset.scene;
|
||||
}
|
||||
function selectColor(dot) {
|
||||
document.querySelectorAll('.color-dot').forEach(d => d.classList.remove('active'));
|
||||
dot.classList.add('active');
|
||||
state.color = dot.dataset.color;
|
||||
}
|
||||
function selectOpt(btn) {
|
||||
const group = btn.parentElement.dataset.group;
|
||||
const value = btn.dataset.value;
|
||||
btn.parentElement.querySelectorAll('.opt-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
if (group === 'lang') state.lang = value;
|
||||
else if (group === 'canvas') state.canvas = value;
|
||||
else if (group === 'image') state.image = value;
|
||||
}
|
||||
function onPageChange() {
|
||||
const [min, max] = document.getElementById('pageSelect').value.split(',').map(Number);
|
||||
state.pageMin = min; state.pageMax = max;
|
||||
}
|
||||
function syncOptGroup(group, idx) {
|
||||
document.querySelectorAll('.config-section .opt-group[data-group="' + group + '"] .opt-btn')
|
||||
.forEach((b, i) => b.classList.toggle('active', i === idx));
|
||||
}
|
||||
|
||||
// ===== 内容交互 =====
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.source-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
if (tab === 'paste') { document.querySelectorAll('.source-tab')[0].classList.add('active'); document.getElementById('panelPaste').classList.add('active'); }
|
||||
else { document.querySelectorAll('.source-tab')[1].classList.add('active'); document.getElementById('panelUpload').classList.add('active'); }
|
||||
}
|
||||
function onContentChange() {
|
||||
const area = document.getElementById('contentArea');
|
||||
const btn = document.getElementById('btnAiTitle');
|
||||
const unit = I18N.char_count || '字';
|
||||
document.getElementById('charCount').innerHTML = area.value.length + ' <span data-i18n="char_count">' + unit + '</span>';
|
||||
if (area.value.trim().length >= 20) {
|
||||
btn.classList.remove('btn-ai-disabled'); btn.classList.add('btn-ai-idle'); btn.disabled = false;
|
||||
document.getElementById('step1').className = 'step-item done';
|
||||
document.getElementById('step2').className = 'step-item current';
|
||||
} else {
|
||||
btn.classList.remove('btn-ai-idle'); btn.classList.add('btn-ai-disabled'); btn.disabled = true;
|
||||
document.getElementById('step1').className = 'step-item current';
|
||||
document.getElementById('step2').className = 'step-item';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 上传解析 =====
|
||||
let uploadedFile = null; // {id, name}
|
||||
|
||||
function initUpload() {
|
||||
const zone = document.getElementById('uploadZone');
|
||||
const input = document.getElementById('fileInput');
|
||||
zone.addEventListener('click', () => input.click());
|
||||
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag'); });
|
||||
zone.addEventListener('dragleave', () => zone.classList.remove('drag'));
|
||||
zone.addEventListener('drop', e => {
|
||||
e.preventDefault(); zone.classList.remove('drag');
|
||||
if (e.dataTransfer.files.length) handlePickedFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
input.addEventListener('change', () => {
|
||||
if (input.files.length) handlePickedFile(input.files[0]);
|
||||
input.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePickedFile(file) {
|
||||
renderFileItem(file.name, true);
|
||||
document.getElementById('btnParse').disabled = true;
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: fd });
|
||||
const b = await res.json();
|
||||
if (b.code !== 0) { clearUploaded(); showToast(b.message || I18N.toast_net || '上传失败'); return; }
|
||||
uploadedFile = { id: b.data.file_id, name: b.data.name };
|
||||
renderFileItem(b.data.name, false);
|
||||
document.getElementById('btnParse').disabled = false;
|
||||
} catch (e) {
|
||||
clearUploaded();
|
||||
showToast(I18N.toast_net || '网络异常,请检查网络后重试');
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileItem(name, busy) {
|
||||
const unit = I18N.upload_parsing || '已就绪';
|
||||
document.getElementById('fileList').innerHTML =
|
||||
'<div class="file-item"><span class="file-name">' + escapeHtml(name) +
|
||||
'</span><span style="display:flex;align-items:center;gap:8px;">' +
|
||||
'<span style="font-size:12px;color:' + (busy ? '#999' : '#2E7D32') + ';">' + (busy ? '…' : '✓ ' + unit) + '</span>' +
|
||||
'<button class="file-remove" onclick="removeUploaded()">✕</button></span></div>';
|
||||
}
|
||||
|
||||
function removeUploaded() {
|
||||
clearUploaded();
|
||||
}
|
||||
|
||||
function clearUploaded() {
|
||||
uploadedFile = null;
|
||||
document.getElementById('fileList').innerHTML = '';
|
||||
document.getElementById('btnParse').disabled = true;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
async function parseUploaded() {
|
||||
if (!uploadedFile) return;
|
||||
await doParse({ file_id: uploadedFile.id }, document.getElementById('btnParse'));
|
||||
}
|
||||
|
||||
async function parseUrl() {
|
||||
const url = document.getElementById('urlInput').value.trim();
|
||||
if (!url) { showToast(I18N.url_needed || '请先粘贴网页链接'); return; }
|
||||
const btn = document.getElementById('btnParseUrl');
|
||||
await doParse({ url }, btn);
|
||||
}
|
||||
|
||||
async function doParse(body, btn) {
|
||||
const oldText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = I18N.parse_parsing || '解析中…';
|
||||
try {
|
||||
const res = await fetch('/api/parse', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const b = await res.json();
|
||||
if (b.code !== 0) { showToast(b.message || I18N.parse_fail || '解析失败'); return; }
|
||||
fillParsedContent(b.data.text);
|
||||
} catch (e) {
|
||||
showToast(I18N.toast_net || '网络异常,请检查网络后重试');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = oldText;
|
||||
}
|
||||
}
|
||||
|
||||
function fillParsedContent(text) {
|
||||
const area = document.getElementById('contentArea');
|
||||
area.value = text;
|
||||
switchTab('paste');
|
||||
onContentChange();
|
||||
removeUploaded();
|
||||
document.getElementById('urlInput').value = '';
|
||||
showToast(I18N.parse_done || '解析完成,内容已填入文本框');
|
||||
}
|
||||
|
||||
// ===== 智能标题 =====
|
||||
async function suggestTitle() {
|
||||
const area = document.getElementById('contentArea');
|
||||
if (area.value.trim().length < 20) { showToast(I18N.toast_need_content || '请先输入 PPT 内容再生成'); return; }
|
||||
const btn = document.getElementById('btnAiTitle');
|
||||
const old = btn.innerHTML;
|
||||
btn.disabled = true; btn.innerHTML = I18N.title_loading || '✨ 生成中…';
|
||||
try {
|
||||
const res = await fetch('/api/title/suggest', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: area.value, language: state.lang })
|
||||
});
|
||||
const d = await res.json();
|
||||
if (d.code === 0 && d.data.titles.length) {
|
||||
document.getElementById('titleInput').value = d.data.titles[0];
|
||||
if (d.data.titles.length > 1) showToast((I18N.title_more || '备选:') + d.data.titles.slice(1).join(' / '));
|
||||
} else {
|
||||
showToast(d.message || (I18N.title_fail || '智能标题生成失败'));
|
||||
}
|
||||
} catch (e) { showToast(I18N.toast_net || '网络异常,请检查网络后重试'); }
|
||||
btn.disabled = false; btn.innerHTML = old;
|
||||
}
|
||||
|
||||
// ===== 生成 =====
|
||||
function showToast(msg) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg; t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 2500);
|
||||
}
|
||||
function setStep(n, cls) { const el = document.getElementById('step' + n); if (el) el.className = 'step-item ' + cls; }
|
||||
|
||||
async function startGenerate() {
|
||||
const area = document.getElementById('contentArea');
|
||||
if (area.value.trim().length < 20) { showToast(I18N.toast_need_content || '请先输入 PPT 内容再生成'); return; }
|
||||
if (state.pageMin > state.pageMax) { showToast(I18N.toast_page_invalid || '页数范围不合法'); return; }
|
||||
|
||||
const title = document.getElementById('titleInput').value.trim();
|
||||
const btn = document.getElementById('btnGenerate');
|
||||
btn.disabled = true; btn.innerHTML = I18N.generating || '正在生成…';
|
||||
btn.classList.add('loading');
|
||||
setStep(2, 'done'); setStep(3, 'current');
|
||||
document.getElementById('resultCard').classList.remove('show');
|
||||
document.getElementById('progressPanel').classList.add('show');
|
||||
document.getElementById('progressText').textContent = I18N.status_analyzing || '正在分析内容…';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title, content: area.value,
|
||||
scene: state.scene, language: state.lang, canvas: state.canvas,
|
||||
image_strategy: state.image, color_scheme: state.color,
|
||||
page_min: state.pageMin, page_max: state.pageMax
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.code !== 0) { showToast(data.message); resetGenerateBtn(); return; }
|
||||
state.taskId = data.data.task_id;
|
||||
startPolling();
|
||||
} catch (e) { showToast('网络异常,请检查网络后重试'); resetGenerateBtn(); }
|
||||
}
|
||||
|
||||
function resetGenerateBtn() {
|
||||
const btn = document.getElementById('btnGenerate');
|
||||
btn.disabled = false; btn.classList.remove('loading');
|
||||
btn.innerHTML = I18N.btn_generate || '一键生成';
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (state.polling) clearInterval(state.polling);
|
||||
state.polling = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/generate/' + state.taskId + '/status');
|
||||
const data = await res.json();
|
||||
if (data.code !== 0) { stopPolling(); resetGenerateBtn(); return; }
|
||||
const t = data.data;
|
||||
const statusText = I18N['status_' + t.status] || t.message;
|
||||
document.getElementById('progressText').textContent = t.message || statusText;
|
||||
document.getElementById('progressBarFill').style.width = t.progress + '%';
|
||||
if (t.status === 'done') {
|
||||
stopPolling();
|
||||
document.getElementById('progressPanel').classList.remove('show');
|
||||
fetchResult();
|
||||
resetGenerateBtn();
|
||||
setStep(3, 'done');
|
||||
} else if (t.status === 'failed') {
|
||||
stopPolling();
|
||||
document.getElementById('progressPanel').classList.remove('show');
|
||||
const generic = !t.message || t.message === '生成失败,请重试';
|
||||
showToast(generic ? (I18N.status_failed || '生成失败,请重试') : t.message);
|
||||
resetGenerateBtn();
|
||||
setStep(3, 'current');
|
||||
}
|
||||
} catch (e) { /* 轮询保活 */ }
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function stopPolling() { if (state.polling) { clearInterval(state.polling); state.polling = null; } }
|
||||
|
||||
async function fetchResult() {
|
||||
try {
|
||||
const res = await fetch('/api/generate/' + state.taskId + '/result');
|
||||
const data = await res.json();
|
||||
if (data.code === 0) {
|
||||
state.recordId = data.data.record.id;
|
||||
const r = data.data.record;
|
||||
document.getElementById('resultName').textContent = r.title + '.pptx';
|
||||
document.getElementById('resultSize').textContent = formatSize(r.file_size) + ' · 共 ' + r.page_count + ' 页';
|
||||
document.getElementById('resultCard').classList.add('show');
|
||||
}
|
||||
} catch (e) { showToast('获取结果失败'); }
|
||||
}
|
||||
function formatSize(b) {
|
||||
if (!b) return '--';
|
||||
return b > 1048576 ? (b / 1048576).toFixed(1) + ' MB' : Math.round(b / 1024) + ' KB';
|
||||
}
|
||||
function downloadResult() {
|
||||
if (state.recordId) window.location.href = '/api/files/' + state.recordId + '/download';
|
||||
else showToast('暂无文件可下载');
|
||||
}
|
||||
async function previewResult() {
|
||||
if (!state.recordId) { showToast('暂无记录'); return; }
|
||||
document.getElementById('previewOverlay').classList.add('show');
|
||||
const title = document.getElementById('resultName').textContent.replace(/\.pptx$/, '');
|
||||
document.getElementById('previewTitle').textContent = title;
|
||||
document.getElementById('previewSub').textContent = '';
|
||||
const body = document.getElementById('previewBody');
|
||||
body.innerHTML = '<div class="preview-loading">' + (I18N.preview_loading || '正在生成预览…') + '</div>';
|
||||
try {
|
||||
const res = await fetch('/api/files/' + state.recordId + '/preview');
|
||||
const d = await res.json();
|
||||
if (d.code !== 0) { body.innerHTML = '<div class="preview-loading">' + (d.message || 'Error') + '</div>'; return; }
|
||||
document.getElementById('previewSub').textContent =
|
||||
(I18N.chat_version || '版本') + ' v' + d.data.version_no + ' · ' + d.data.images.length + ' ' + (I18N.preview_pages || '页');
|
||||
body.innerHTML = '';
|
||||
d.data.images.forEach(u => {
|
||||
const img = document.createElement('img');
|
||||
img.src = u; img.loading = 'lazy';
|
||||
body.appendChild(img);
|
||||
});
|
||||
} catch (e) {
|
||||
body.innerHTML = '<div class="preview-loading">' + (I18N.toast_net || '网络异常') + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function closePreview() { document.getElementById('previewOverlay').classList.remove('show'); }
|
||||
|
||||
// ===== 对话修改 =====
|
||||
function openChat() {
|
||||
if (!state.recordId) { showToast('暂无记录'); return; }
|
||||
document.getElementById('chatOverlay').classList.add('show');
|
||||
loadChatMeta();
|
||||
loadMessages();
|
||||
}
|
||||
function closeChat() { document.getElementById('chatOverlay').classList.remove('show'); }
|
||||
|
||||
async function loadChatMeta() {
|
||||
try {
|
||||
const res = await fetch('/api/records/' + state.recordId);
|
||||
const d = await res.json();
|
||||
if (d.code === 0) {
|
||||
const vs = d.data.versions || [];
|
||||
const cur = vs[0] ? vs[0].version_no : 1;
|
||||
document.getElementById('chatVer').textContent =
|
||||
(I18N.chat_version || '当前版本') + ' v' + cur +
|
||||
' · ' + (I18N.chat_history || '含历史') + ' ' + vs.length + ' ' + (I18N.chat_ge || '个');
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
async function loadMessages() {
|
||||
try {
|
||||
const res = await fetch('/api/chat/' + state.recordId + '/messages');
|
||||
const d = await res.json();
|
||||
const body = document.getElementById('chatBody');
|
||||
body.innerHTML = '';
|
||||
const msgs = (d.data && d.data.messages) || [];
|
||||
if (!msgs.length) {
|
||||
const tip = document.createElement('div');
|
||||
tip.className = 'msg assistant';
|
||||
tip.textContent = I18N.chat_empty || '暂无对话,发送指令开始修改';
|
||||
body.appendChild(tip);
|
||||
}
|
||||
msgs.forEach(m => appendMsg(m.role, m.content));
|
||||
body.scrollTop = body.scrollHeight;
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function appendMsg(role, text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg ' + role;
|
||||
div.textContent = text;
|
||||
const body = document.getElementById('chatBody');
|
||||
body.appendChild(div);
|
||||
body.scrollTop = body.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
const input = document.getElementById('chatInput');
|
||||
const msg = input.value.trim();
|
||||
if (!msg || !state.recordId) return;
|
||||
const btn = document.getElementById('chatSend');
|
||||
btn.disabled = true;
|
||||
appendMsg('user', msg);
|
||||
input.value = '';
|
||||
try {
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ record_id: state.recordId, message: msg })
|
||||
});
|
||||
const d = await res.json();
|
||||
if (d.code === 0) {
|
||||
appendMsg('assistant', d.data.reply);
|
||||
if (d.data.changed && d.data.record) {
|
||||
const r = d.data.record;
|
||||
document.getElementById('resultName').textContent = r.title + '.pptx';
|
||||
document.getElementById('resultSize').textContent =
|
||||
formatSize(r.file_size) + ' · ' + (I18N.chat_version || '版本') + ' v' + d.data.version_no;
|
||||
loadChatMeta();
|
||||
}
|
||||
} else {
|
||||
appendMsg('assistant', d.message || 'Error');
|
||||
}
|
||||
} catch (e) {
|
||||
appendMsg('assistant', I18N.toast_net || '网络异常,请检查网络后重试');
|
||||
}
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
// ===== 退出 =====
|
||||
async function doLogout() {
|
||||
await fetch('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
// ===== 初始化 =====
|
||||
(async function init() {
|
||||
const saved = localStorage.getItem('ui_lang');
|
||||
setLang(saved === 'ja' ? 'ja' : 'zh');
|
||||
initUpload();
|
||||
try {
|
||||
const res = await fetch('/api/me');
|
||||
const data = await res.json();
|
||||
if (data.code === 0) {
|
||||
const p = data.data.preferences || {};
|
||||
state.scene = p.default_scene || 'training';
|
||||
state.color = p.default_color || 'blue';
|
||||
state.lang = p.default_language || 'zh';
|
||||
state.canvas = p.default_canvas || 'ppt169';
|
||||
state.image = p.default_image_strategy || 'web';
|
||||
state.pageMin = p.default_page_min || 10;
|
||||
state.pageMax = p.default_page_max || 15;
|
||||
applyPrefs();
|
||||
}
|
||||
} catch (e) { window.location.href = '/login'; }
|
||||
})();
|
||||
|
||||
function applyPrefs() {
|
||||
document.querySelectorAll('.scene-btn').forEach(b => b.classList.toggle('active', b.dataset.scene === state.scene));
|
||||
document.querySelectorAll('.color-dot').forEach(d => d.classList.toggle('active', d.dataset.color === state.color));
|
||||
document.getElementById('pageSelect').value = state.pageMin + ',' + state.pageMax;
|
||||
syncOptGroup('lang', state.lang === 'zh' ? 0 : 1);
|
||||
syncOptGroup('canvas', state.canvas === 'ppt169' ? 0 : 1);
|
||||
const imgMap = { web: 0, ai: 1, off: 2 };
|
||||
syncOptGroup('image', imgMap[state.image] || 0);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""候选免费模型基准测试:大纲任务耗时 + JSON 有效性。"""
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, r"D:\00.张华丹\VS专用\04.PPT自动生成\ppt-agent")
|
||||
|
||||
BASE = "http://127.0.0.1:4096"
|
||||
SAMPLE = """# 项目背景
|
||||
为提升知识周转效率启动平台建设,前期调研两个月
|
||||
# 本期成果
|
||||
部署完成基础服务,导入文档120份
|
||||
混合检索准确率较基线提升明显
|
||||
完成三个部门试点接入
|
||||
# 下一步
|
||||
扩大试点范围,建立运营机制
|
||||
"""
|
||||
|
||||
PROMPT = """请把下面的内容规划成一份 PPT 的页面大纲。
|
||||
要求:
|
||||
1. 页面总数 6~9 页(含封面、目录、结尾)
|
||||
2. 输出语言:中文
|
||||
3. 页面类型只能用:cover / toc / section / content / two_column / end
|
||||
4. 第一页 cover,最后一页 end
|
||||
5. content 页的 content 数组为要点(每条一句话,4~6 条)
|
||||
6. 每页写 notes:2~3 句口语化演讲备注
|
||||
7. 标题要有信息量
|
||||
输出格式:{"slides": [{"type": "...", "title": "...", "content": ["..."], "notes": "..."}]}
|
||||
|
||||
【用户标题】知识平台周报
|
||||
【用户内容】
|
||||
""" + SAMPLE
|
||||
|
||||
|
||||
def post(path, payload, timeout):
|
||||
req = urllib.request.Request(
|
||||
BASE + path, data=json.dumps(payload).encode("utf-8"), method="POST",
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read() or b"{}")
|
||||
|
||||
|
||||
def run_model(model_id):
|
||||
t0 = time.time()
|
||||
try:
|
||||
sess = post("/session", {"title": "bench"}, 30)
|
||||
sid = sess["id"]
|
||||
provider, _, model = model_id.partition("/")
|
||||
result = post(f"/session/{sid}/message", {
|
||||
"model": {"providerID": provider, "modelID": model},
|
||||
"parts": [{"type": "text", "text": PROMPT}],
|
||||
}, 300)
|
||||
dt = time.time() - t0
|
||||
text = "\n".join(p.get("text", "") for p in result.get("parts", [])
|
||||
if p.get("type") == "text")
|
||||
s, e = text.find("{"), text.rfind("}")
|
||||
ok = False
|
||||
n = 0
|
||||
if 0 <= s < e:
|
||||
try:
|
||||
data = json.loads(text[s:e + 1])
|
||||
slides = data.get("slides", [])
|
||||
n = len(slides)
|
||||
ok = 4 <= n <= 12 and all("title" in x for x in slides)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
print(f"{model_id:45s} {dt:6.1f}s pages={n:2d} valid={ok}")
|
||||
return dt, ok
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"{model_id:45s} FAIL {str(exc)[:60]}")
|
||||
return 999, False
|
||||
|
||||
|
||||
for m in sys.argv[1:] or ["opencode/nemotron-3.5-lightning-free",
|
||||
"opencode/mimo-v2.5-free",
|
||||
"opencode/hy3-free"]:
|
||||
run_model(m)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""对话修改闭环 e2e:生成 → 换色 → 改标题 → 改页内容 → 两版本/下载验证。
|
||||
|
||||
用法:python tests/e2e_chat.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
BASE = "http://127.0.0.1:5098"
|
||||
USER = f"chat_e2e_{int(time.time())}"
|
||||
PASS = "chat_e2e_123"
|
||||
SAMPLE = "# 项目背景\n为提升内部知识周转效率,启动知识库平台建设\n已完成技术选型与原型验证\n# 本期成果\n部署完成基础服务\n导入文档120份\n"
|
||||
|
||||
COOKIE = {"value": ""}
|
||||
|
||||
|
||||
def call(method, path, payload=None, raw=False):
|
||||
url = BASE + path
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
if payload is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if COOKIE["value"]:
|
||||
req.add_header("Cookie", COOKIE["value"])
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
body = resp.read()
|
||||
sc = resp.headers.get("Set-Cookie")
|
||||
if sc and not COOKIE["value"]:
|
||||
COOKIE["value"] = sc.split(";")[0]
|
||||
return resp.status, (body if raw else json.loads(body or b"{}"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()
|
||||
try:
|
||||
parsed = json.loads(body or b"{}")
|
||||
except Exception:
|
||||
parsed = {"raw": body.decode("utf-8", "ignore")}
|
||||
sc = e.headers.get("Set-Cookie")
|
||||
if sc and not COOKIE["value"]:
|
||||
COOKIE["value"] = sc.split(";")[0]
|
||||
return e.code, parsed
|
||||
|
||||
|
||||
def main():
|
||||
failures = []
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
print(f" [{'OK' if cond else 'NG'}] {name} {extra}")
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||
subprocess.run([sys.executable, "-m", "src.db.init"], cwd=str(ROOT),
|
||||
capture_output=True, env=env)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-c",
|
||||
f"import sys; sys.path.insert(0, r'{ROOT}');"
|
||||
"from src.web.app import app; app.run(host='127.0.0.1', port=5098)"],
|
||||
cwd=str(ROOT), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
up = False
|
||||
for _ in range(30):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
call("GET", "/api/me")
|
||||
up = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
check("server up", up)
|
||||
|
||||
s, b = call("POST", "/api/register",
|
||||
{"username": USER, "password": PASS, "confirm_password": PASS})
|
||||
check("register", s == 200 and b.get("code") == 0)
|
||||
|
||||
s, b = call("POST", "/api/generate", {
|
||||
"title": "对话修改测试", "content": SAMPLE,
|
||||
"scene": "report", "language": "zh", "canvas": "ppt169",
|
||||
"image_strategy": "off", "color_scheme": "blue",
|
||||
"page_min": 5, "page_max": 8,
|
||||
})
|
||||
check("generate accepted", b.get("code") == 0, str(b)[:60])
|
||||
task_id = b["data"]["task_id"]
|
||||
for _ in range(120):
|
||||
time.sleep(1)
|
||||
s, b = call("GET", f"/api/generate/{task_id}/status")
|
||||
if b["data"]["status"] in ("done", "failed"):
|
||||
break
|
||||
check("generation done", b["data"]["status"] == "done", str(b)[:80])
|
||||
s, b = call("GET", f"/api/generate/{task_id}/result")
|
||||
rid = b["data"]["record"]["id"]
|
||||
check("record ready", bool(rid), f"rid={rid}")
|
||||
|
||||
def chat(msg):
|
||||
return call("POST", "/api/chat", {"record_id": rid, "message": msg})
|
||||
|
||||
s, b = chat("换成绿色")
|
||||
d = b.get("data") or {}
|
||||
check("change color", d.get("changed") is True, str(d.get("reply", ""))[:60])
|
||||
check("record color updated", (d.get("record") or {}).get("color_scheme") == "green")
|
||||
|
||||
s, b = chat('把标题改成"知识平台周报"')
|
||||
d = b.get("data") or {}
|
||||
check("change title", d.get("changed") is True
|
||||
and (d.get("record") or {}).get("title") == "知识平台周报",
|
||||
str(d.get("reply", ""))[:60])
|
||||
|
||||
s, b = chat('把"知识周转"改成"信息流转"')
|
||||
d = b.get("data") or {}
|
||||
check("edit page replace", d.get("changed") is True, str(d.get("reply", ""))[:60])
|
||||
|
||||
s, b = chat("今天天气怎么样")
|
||||
d = b.get("data") or {}
|
||||
check("unknown intent no change", d.get("changed") is False
|
||||
and "不支持" in d.get("reply", ""), str(d.get("reply", ""))[:50])
|
||||
|
||||
s, b = call("GET", f"/api/records/{rid}")
|
||||
versions = b["data"]["versions"]
|
||||
check("two versions kept", len(versions) == 2 and versions[0]["version_no"] == 1,
|
||||
f"n={len(versions)}")
|
||||
|
||||
s, b = call("GET", f"/api/chat/{rid}/messages")
|
||||
msgs = b["data"]["messages"]
|
||||
check("messages logged", len(msgs) == 8, f"n={len(msgs)}")
|
||||
|
||||
s, raw = call("GET", f"/api/files/{rid}/download", raw=True)
|
||||
check("download ok", s == 200 and raw[:2] == b"PK", f"{len(raw)}B")
|
||||
p = ROOT / "tests" / "_chat_download.pptx"
|
||||
p.write_bytes(raw)
|
||||
from pptx import Presentation
|
||||
prs = Presentation(str(p))
|
||||
texts = "\n".join(sh.text_frame.text for sl in prs.slides
|
||||
for sh in sl.shapes if sh.has_text_frame)
|
||||
check("new title in pptx", "知识平台周报" in texts)
|
||||
check("edited text in pptx", "信息流转" in texts and "知识周转" not in texts)
|
||||
p.unlink()
|
||||
finally:
|
||||
proc.terminate()
|
||||
|
||||
print("\n" + ("CHAT E2E ALL PASSED" if not failures
|
||||
else "FAILURES: " + "; ".join(failures)))
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""网络配图 e2e:image_strategy=web 全链路(test 通道)+ 空图源优雅降级。
|
||||
|
||||
用法:python tests/e2e_image.py
|
||||
环境:IMAGE_SEARCH_MODE=test + IMAGE_SEARCH_TEST_DIR=<样例图目录>
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
BASE = "http://127.0.0.1:5102"
|
||||
USER = f"img_e2e_{int(time.time())}"
|
||||
PASS = "img_e2e_123"
|
||||
SAMPLE = ("# 配图测试主题\n本季度完成检索服务上线\n准确率达到百分之九十八\n"
|
||||
"# 运营情况\n用户增长明显\n文档数量翻倍\n")
|
||||
|
||||
TMP = ROOT / "tests" / "_up"
|
||||
|
||||
|
||||
def make_test_images():
|
||||
from PIL import Image
|
||||
TMP.mkdir(exist_ok=True)
|
||||
for i, color in enumerate([(21, 101, 192), (46, 125, 50), (123, 31, 162)]):
|
||||
Image.new("RGB", (800, 500), color).save(str(TMP / f"sample_{i}.jpg"))
|
||||
|
||||
|
||||
COOKIE = {"value": ""}
|
||||
|
||||
|
||||
def call(method, path, payload=None):
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(BASE + path, data=data, method=method)
|
||||
if payload is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if COOKIE["value"]:
|
||||
req.add_header("Cookie", COOKIE["value"])
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
sc = resp.headers.get("Set-Cookie")
|
||||
if sc and not COOKIE["value"]:
|
||||
COOKIE["value"] = sc.split(";")[0]
|
||||
return resp.status, json.loads(resp.read() or b"{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
return e.code, json.loads(e.read() or b"{}")
|
||||
except Exception:
|
||||
return e.code, {}
|
||||
|
||||
|
||||
def generate_and_wait(title):
|
||||
s, b = call("POST", "/api/generate", {
|
||||
"title": title, "content": SAMPLE,
|
||||
"scene": "report", "language": "zh", "canvas": "ppt169",
|
||||
"image_strategy": "web", "color_scheme": "blue",
|
||||
"page_min": 5, "page_max": 7,
|
||||
})
|
||||
if b.get("code") != 0:
|
||||
return None, str(b)[:100]
|
||||
task_id = b["data"]["task_id"]
|
||||
for _ in range(120):
|
||||
time.sleep(1)
|
||||
s, b = call("GET", f"/api/generate/{task_id}/status")
|
||||
st = b.get("data", {}).get("status")
|
||||
if st in ("done", "failed"):
|
||||
return (task_id if st == "done" else None), json.dumps(b.get("data"), ensure_ascii=False)[:120]
|
||||
return None, "timeout"
|
||||
|
||||
|
||||
def main():
|
||||
failures = []
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
print(f" [{'OK' if cond else 'NG'}] {name} {extra}")
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
make_test_images()
|
||||
env = {**os.environ, "PYTHONIOENCODING": "utf-8",
|
||||
"LLM_BACKEND": "off",
|
||||
"IMAGE_SEARCH_MODE": "test",
|
||||
"IMAGE_SEARCH_TEST_DIR": str(TMP),
|
||||
"MAX_IMAGE_SLIDES": "3"}
|
||||
subprocess.run([sys.executable, "-m", "src.db.init"], cwd=str(ROOT),
|
||||
capture_output=True, env=env)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-c",
|
||||
f"import sys; sys.path.insert(0, r'{ROOT}');"
|
||||
"from src.web.app import app; app.run(host='127.0.0.1', port=5102)"],
|
||||
cwd=str(ROOT), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
up = False
|
||||
for _ in range(30):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
call("GET", "/api/me")
|
||||
up = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
check("server up", up)
|
||||
|
||||
s, b = call("POST", "/api/register",
|
||||
{"username": USER, "password": PASS, "confirm_password": PASS})
|
||||
check("register", s == 200 and b.get("code") == 0)
|
||||
|
||||
# ---- 场景1:配图成功 ----
|
||||
task_id, info = generate_and_wait("配图全链路测试")
|
||||
check("generation done (with images)", task_id is not None, info)
|
||||
if task_id:
|
||||
s, b = call("GET", f"/api/generate/{task_id}/result")
|
||||
rec = b["data"]["record"]
|
||||
project_dir = ROOT / "src" / "web" / "data" / "projects" / rec["project_dir"] \
|
||||
if rec.get("project_dir") else None
|
||||
# 兜底:从 versions 表拿不到目录名时按标题匹配最新目录
|
||||
if not project_dir or not project_dir.exists():
|
||||
dirs = sorted((ROOT / "src" / "web" / "data" / "projects").glob("配图全链路测试_*"))
|
||||
project_dir = dirs[-1]
|
||||
plan = json.loads((project_dir / "plan.json").read_text(encoding="utf-8"))
|
||||
with_img = [sl for sl in plan["slides"] if sl.get("image")]
|
||||
check("plan has image slides", len(with_img) >= 2, f"n={len(with_img)}")
|
||||
check("image files exist", all(Path(sl["image"]).exists() for sl in with_img))
|
||||
check("images inside project dir",
|
||||
all(str(sl["image"]).startswith(str(project_dir)) for sl in with_img))
|
||||
|
||||
s, raw = call("GET", f"/api/files/{rec['id']}/download", raw=True) \
|
||||
if False else (None, None)
|
||||
# 直接读导出文件验证 PPTX 内嵌图片形状
|
||||
pptx_path = project_dir / "exports" / rec["file_name"] \
|
||||
if rec.get("file_name") else None
|
||||
if not pptx_path or not pptx_path.exists():
|
||||
files = sorted((project_dir / "exports").glob("*.pptx"))
|
||||
pptx_path = files[-1]
|
||||
from pptx import Presentation
|
||||
prs = Presentation(str(pptx_path))
|
||||
pic_slides = [i for i, sl in enumerate(prs.slides)
|
||||
if any(sh.shape_type == 13 for sh in sl.shapes)]
|
||||
check("pptx contains pictures", len(pic_slides) >= 2, f"slides={pic_slides}")
|
||||
# 保真验证已在生成流程内通过(done 状态),此处确认无 image 键误报的副作用
|
||||
check("no cover/end images", all(
|
||||
sl.get("type") == "content" for sl in with_img))
|
||||
|
||||
# ---- 场景2:空图源 → 优雅降级 ----
|
||||
empty_dir = TMP / "empty_src"
|
||||
empty_dir.mkdir(exist_ok=True)
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
env2 = {**env, "IMAGE_SEARCH_TEST_DIR": str(empty_dir)}
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-c",
|
||||
f"import sys; sys.path.insert(0, r'{ROOT}');"
|
||||
"from src.web.app import app; app.run(host='127.0.0.1', port=5102)"],
|
||||
cwd=str(ROOT), env=env2, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
for _ in range(30):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
call("GET", "/api/me")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
task_id, info = generate_and_wait("空图源降级测试")
|
||||
check("generation done (fallback no-image)", task_id is not None, info)
|
||||
if task_id:
|
||||
s, b = call("GET", f"/api/generate/{task_id}/result")
|
||||
rec = b["data"]["record"]
|
||||
check("record still created", bool(rec and rec["id"]), str(rec)[:60])
|
||||
finally:
|
||||
proc.terminate()
|
||||
|
||||
import shutil
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
print("\n" + ("IMAGE E2E ALL PASSED" if not failures
|
||||
else "FAILURES: " + "; ".join(failures)))
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,153 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""LLM 接入集成测试:规划/标题/意图/禁止降级/长文档两级规划。
|
||||
|
||||
用法:python tests/e2e_llm.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
os.environ["PLAN_CHUNK_CHARS"] = "2500" # 压低阈值,确保长文档用例真正走分段通读
|
||||
|
||||
from src.agent import llm # noqa: E402
|
||||
from src.agent.perception import GenerateConfig # noqa: E402
|
||||
from src.agent import planning as P # noqa: E402
|
||||
from src.agent.planning import build_plan, PlanningError # noqa: E402
|
||||
from src.agent.chat import parse_intent_llm, parse_intent # noqa: E402
|
||||
|
||||
SAMPLE = """# 项目背景
|
||||
为提升知识周转效率启动平台建设,前期调研两个月
|
||||
# 本期成果
|
||||
部署完成基础服务,导入文档120份
|
||||
混合检索准确率较基线提升明显
|
||||
完成三个部门试点接入
|
||||
# 下一步
|
||||
扩大试点范围,建立运营机制
|
||||
"""
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
print(f" [{'OK' if cond else 'NG'}] {name} {extra}")
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
|
||||
def make_long_doc(sections=40):
|
||||
"""构造约 1.5-2 万字符的多节长文档(含数字,验证分段通读与页数硬约束)。"""
|
||||
parts = []
|
||||
for i in range(1, sections + 1):
|
||||
parts.append(f"# 第{i}章节 阶段性工作汇报")
|
||||
parts.append(f"本阶段完成任务{i}的设计与开发,投入人力3人,周期2周")
|
||||
parts.append(f"完成度达到{i*2}%,质量抽检合格率98%")
|
||||
parts.append("- 关键交付物已通过评审")
|
||||
parts.append("- 遗留问题已登记跟踪")
|
||||
parts.append("下一步将推进与业务系统的对接联调")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def main():
|
||||
print("== 0. 禁止降级:LLM 失败必须报错而非照搬 ==")
|
||||
cfg_nf = GenerateConfig(title="降级测试", user_id=1, content=SAMPLE,
|
||||
page_min=5, page_max=8)
|
||||
orig_chat = llm.chat_json
|
||||
try:
|
||||
llm.chat_json = lambda *a, **k: None
|
||||
try:
|
||||
build_plan(cfg_nf)
|
||||
check("no silent fallback", False, "未抛出 PlanningError")
|
||||
except PlanningError as e:
|
||||
check("no silent fallback", True, str(e)[:50])
|
||||
finally:
|
||||
llm.chat_json = orig_chat
|
||||
|
||||
ok = llm.ensure_backend()
|
||||
check("llm backend", ok)
|
||||
|
||||
print("== 1. LLM 内容规划(短文档直通) ==")
|
||||
t = time.time()
|
||||
cfg = GenerateConfig(title="知识平台周报", user_id=1, content=SAMPLE,
|
||||
scene="report", language="zh", page_min=6, page_max=9)
|
||||
r = build_plan(cfg)
|
||||
plan = r.plan
|
||||
slides = plan["slides"]
|
||||
print(f" mode={r.mode} pages={len(slides)} 耗时={time.time()-t:.0f}s")
|
||||
check("llm mode used", r.mode == "llm")
|
||||
check("page count in range", 6 <= len(slides) <= 9, f"n={len(slides)}")
|
||||
check("cover first / end last", slides[0]["type"] == "cover" and slides[-1]["type"] == "end")
|
||||
notes_n = sum(1 for s in slides if s.get("notes"))
|
||||
check("per-slide notes", notes_n >= len(slides) - 2, f"notes={notes_n}")
|
||||
generic = sum(1 for s in slides if s["title"] in ("",) or s["title"].startswith("要点"))
|
||||
check("no generic titles", generic == 0)
|
||||
|
||||
def avg_len(sl):
|
||||
bl = [len(b) for x in sl if x["type"] == "content" for b in x.get("content", [])]
|
||||
return sum(bl) / max(len(bl), 1)
|
||||
check("full-sentence bullets (short doc)", avg_len(slides) >= 12,
|
||||
f"avg={avg_len(slides):.0f}")
|
||||
for s in slides:
|
||||
print(f" - [{s['type']:8s}] {s['title'][:24]} notes={'Y' if s.get('notes') else '-'}")
|
||||
|
||||
print("== 2. 长文档两级规划(分段通读→汇总大纲) ==")
|
||||
long_doc = make_long_doc()
|
||||
t = time.time()
|
||||
cfg2 = GenerateConfig(title="季度工作总结", user_id=1, content=long_doc,
|
||||
scene="report", language="zh", page_min=10, page_max=15)
|
||||
r2 = build_plan(cfg2)
|
||||
slides2 = r2.plan["slides"]
|
||||
n2 = len(slides2)
|
||||
print(f" mode={r2.mode} pages={n2} 源={len(long_doc)}字 耗时={time.time()-t:.0f}s")
|
||||
check("long doc page hard cap", n2 <= 15 and n2 >= 4, f"n={n2}")
|
||||
notes2 = sum(1 for s in slides2 if s.get("notes"))
|
||||
check("long doc has notes", notes2 >= n2 - 2, f"notes={notes2}")
|
||||
covered = sum(1 for i in range(1, 41) if str(i) in "".join(
|
||||
s["title"] + "".join(s["content"]) for s in slides2))
|
||||
check("late sections represented", covered >= 3, f"covered_sections={covered}")
|
||||
check("full-sentence bullets (long doc)", avg_len(slides2) >= 12,
|
||||
f"avg={avg_len(slides2):.0f}")
|
||||
|
||||
print("== 3. 智能标题 ==")
|
||||
t = time.time()
|
||||
data = llm.chat_json(
|
||||
"根据以下 PPT 内容拟 3 个标题(每个不超过 20 字)。"
|
||||
'输出 JSON {"titles": ["..."]}。\n\n' + SAMPLE)
|
||||
titles = [str(x).strip() for x in (data or {}).get("titles", []) if str(x).strip()]
|
||||
print(f" titles={titles} 耗时={time.time()-t:.0f}s")
|
||||
check("title suggest", len(titles) >= 1)
|
||||
|
||||
print("== 4. LLM 意图解析(规则无法处理的句式) ==")
|
||||
msg = '第2页末尾添加一条要点:风险与依赖已同步全组'
|
||||
rule = parse_intent(msg, {})
|
||||
llm_it = parse_intent_llm(msg, plan)
|
||||
check("rule cannot parse (no quotes)", rule["type"] == "unknown", str(rule))
|
||||
check("llm parses add", llm_it and llm_it.get("type") == "edit_page"
|
||||
and llm_it.get("op") == "add" and llm_it.get("page_no") == 2,
|
||||
str(llm_it))
|
||||
msg2 = "把标题改成 AI 知识平台建设汇报"
|
||||
it2 = parse_intent_llm(msg2, plan)
|
||||
check("llm parses title", it2 and it2.get("type") == "change_title"
|
||||
and "知识平台" in it2.get("title", ""), str(it2))
|
||||
|
||||
print("== 5. 渲染 LLM plan(含 notes) ==")
|
||||
import json
|
||||
import tempfile
|
||||
from src.engine_bridge import render_plan, verify_output
|
||||
tmp = Path(tempfile.mkdtemp(prefix="llm_plan_"))
|
||||
p = tmp / "plan.json"
|
||||
p.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
out = tmp / "out.pptx"
|
||||
render_plan(p, out)
|
||||
vok, detail = verify_output(out, p)
|
||||
check("render+verify llm plan", vok, detail[:60])
|
||||
|
||||
print("\n" + ("LLM E2E ALL PASSED" if not failures else "FAILURES: " + "; ".join(failures)))
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,200 @@
|
||||
"""个人设置 e2e:偏好读写校验、改用户名、改密码、历史语言筛选。
|
||||
|
||||
用法:python tests/e2e_settings.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
BASE = "http://127.0.0.1:5101"
|
||||
_TS = int(time.time())
|
||||
UA = f"sxa{_TS}"
|
||||
UB = f"sxb{_TS}"
|
||||
NEW_NAME = f"sxc{_TS}"
|
||||
PASS = "set_e2e_123"
|
||||
|
||||
COOKIE = {"value": ""}
|
||||
|
||||
|
||||
def call(method, path, payload=None):
|
||||
url = BASE + path
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
if payload is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if COOKIE["value"]:
|
||||
req.add_header("Cookie", COOKIE["value"])
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
body = resp.read()
|
||||
sc = resp.headers.get("Set-Cookie")
|
||||
if sc and not COOKIE["value"]:
|
||||
COOKIE["value"] = sc.split(";")[0]
|
||||
return resp.status, json.loads(body or b"{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
return e.code, json.loads(e.read() or b"{}")
|
||||
except Exception:
|
||||
return e.code, {}
|
||||
|
||||
|
||||
def drop_cookie():
|
||||
COOKIE["value"] = ""
|
||||
|
||||
|
||||
def insert_record(user_id, title, language, created_at):
|
||||
from config import DB_PATH
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.execute(
|
||||
"INSERT INTO ppt_records (user_id,title,content,scene,language,color_scheme,"
|
||||
"page_min,page_max,status,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(user_id, title, "内容示例文本,用于语言筛选测试", "report", language,
|
||||
"blue", 10, 15, "done", created_at),
|
||||
)
|
||||
conn.commit()
|
||||
uid = conn.execute("SELECT id FROM ppt_records WHERE title=?", (title,)).fetchone()[0]
|
||||
conn.close()
|
||||
return uid
|
||||
|
||||
|
||||
def main():
|
||||
failures = []
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
print(f" [{'OK' if cond else 'NG'}] {name} {extra}")
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||
subprocess.run([sys.executable, "-m", "src.db.init"], cwd=str(ROOT),
|
||||
capture_output=True, env=env)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-c",
|
||||
f"import sys; sys.path.insert(0, r'{ROOT}');"
|
||||
"from src.web.app import app; app.run(host='127.0.0.1', port=5101)"],
|
||||
cwd=str(ROOT), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
up = False
|
||||
for _ in range(30):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
call("GET", "/api/me")
|
||||
up = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
check("server up", up)
|
||||
|
||||
s, b = call("POST", "/api/register",
|
||||
{"username": UA, "password": PASS, "confirm_password": PASS})
|
||||
check("register A", s == 200 and b.get("code") == 0)
|
||||
|
||||
# ---- GET /api/settings 默认值 ----
|
||||
s, b = call("GET", "/api/settings")
|
||||
p = (b.get("data") or {}).get("preferences") or {}
|
||||
check("settings defaults", s == 200 and b["data"]["username"] == UA
|
||||
and p.get("default_scene") == "training" and p.get("default_color") == "blue"
|
||||
and p.get("default_language") == "zh" and p.get("default_canvas") == "ppt169"
|
||||
and p.get("default_image_strategy") == "web"
|
||||
and p.get("default_page_min") == 10 and p.get("default_page_max") == 15,
|
||||
str(p)[:80])
|
||||
|
||||
# ---- 偏好保存(部分字段)----
|
||||
s, b = call("PUT", "/api/settings/preferences",
|
||||
{"default_color": "purple", "default_language": "ja",
|
||||
"default_page_min": 5, "default_page_max": 8})
|
||||
p = ((b.get("data") or {}).get("preferences")) or {}
|
||||
check("save prefs partial", s == 200 and p.get("default_color") == "purple"
|
||||
and p.get("default_language") == "ja" and p.get("default_page_min") == 5
|
||||
and p.get("default_page_max") == 8 and p.get("default_scene") == "training",
|
||||
str(p)[:80])
|
||||
s, b = call("PUT", "/api/settings/preferences", {"default_color": "pink"})
|
||||
check("prefs invalid enum -> 5002", s == 400 and b.get("code") == 5002, str(b)[:60])
|
||||
s, b = call("PUT", "/api/settings/preferences", {"default_page_min": 20, "default_page_max": 8})
|
||||
check("prefs min>max -> 2002", s == 400 and b.get("code") == 2002, str(b)[:60])
|
||||
|
||||
# ---- 改用户名 ----
|
||||
s, b = call("PUT", "/api/settings/username", {"username": "ab"})
|
||||
check("username too short -> 5002", s == 400 and b.get("code") == 5002, str(b)[:50])
|
||||
s, b = call("POST", "/api/register",
|
||||
{"username": UB, "password": PASS, "confirm_password": PASS})
|
||||
drop_cookie()
|
||||
call("POST", "/api/login", {"username": UA, "password": PASS})
|
||||
s, b = call("PUT", "/api/settings/username", {"username": UB})
|
||||
check("username duplicate -> 1001", s == 409 and b.get("code") == 1001, str(b)[:50])
|
||||
new_name = NEW_NAME
|
||||
s, b = call("PUT", "/api/settings/username", {"username": new_name})
|
||||
s2, b2 = call("GET", "/api/me")
|
||||
check("username changed", s == 200 and b2["data"]["username"] == new_name,
|
||||
str(b2)[:60])
|
||||
|
||||
# ---- 改密码 ----
|
||||
s, b = call("PUT", "/api/settings/password",
|
||||
{"old_password": "wrong_old", "new_password": "newpwd456",
|
||||
"confirm_password": "newpwd456"})
|
||||
check("wrong old pwd -> 5001", s == 400 and b.get("code") == 5001, str(b)[:50])
|
||||
s, b = call("PUT", "/api/settings/password",
|
||||
{"old_password": PASS, "new_password": "newpwd456",
|
||||
"confirm_password": "different"})
|
||||
check("confirm mismatch -> 1003", s == 400 and b.get("code") == 1003, str(b)[:50])
|
||||
s, b = call("PUT", "/api/settings/password",
|
||||
{"old_password": PASS, "new_password": "abc",
|
||||
"confirm_password": "abc"})
|
||||
check("short new pwd -> 5002", s == 400 and b.get("code") == 5002, str(b)[:50])
|
||||
s, b = call("PUT", "/api/settings/password",
|
||||
{"old_password": PASS, "new_password": "newpwd456",
|
||||
"confirm_password": "newpwd456"})
|
||||
check("password changed", s == 200 and b.get("code") == 0)
|
||||
drop_cookie()
|
||||
s, _ = call("POST", "/api/login", {"username": new_name, "password": PASS})
|
||||
check("old password rejected -> 401", s == 401)
|
||||
s, b = call("POST", "/api/login", {"username": new_name, "password": "newpwd456"})
|
||||
check("login with new password", s == 200 and b.get("code") == 0)
|
||||
|
||||
# ---- 历史语言筛选 ----
|
||||
uid = None
|
||||
from src.agent import memory as mem
|
||||
u = mem.get_user_by_name(new_name)
|
||||
uid = u["id"]
|
||||
insert_record(uid, "筛选测试中文稿", "zh", "2026-08-25 10:00:00")
|
||||
insert_record(uid, "フィルタ日本語資料", "ja", "2026-08-25 10:01:00")
|
||||
s, b = call("GET", "/api/records?language=ja")
|
||||
titles_ja = [r["title"] for r in b["data"]["records"]]
|
||||
s, b = call("GET", "/api/records?language=zh")
|
||||
titles_zh = [r["title"] for r in b["data"]["records"]]
|
||||
s, b = call("GET", "/api/records")
|
||||
titles_all = [r["title"] for r in b["data"]["records"]]
|
||||
check("filter ja only", "フィルタ日本語資料" in titles_ja
|
||||
and "筛选测试中文稿" not in titles_ja, str(titles_ja)[:70])
|
||||
check("filter zh only", "筛选测试中文稿" in titles_zh
|
||||
and "フィルタ日本語資料" not in titles_zh, str(titles_zh)[:70])
|
||||
check("no filter shows both", "筛选测试中文稿" in titles_all
|
||||
and "フィルタ日本語資料" in titles_all, f"n={len(titles_all)}")
|
||||
|
||||
# 清理测试插入的记录
|
||||
conn = sqlite3.connect(str(ROOT / "src" / "web" / "data" / "ppt.db"))
|
||||
conn.execute("DELETE FROM ppt_records WHERE title IN (?,?)",
|
||||
("筛选测试中文稿", "フィルタ日本語資料"))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
finally:
|
||||
proc.terminate()
|
||||
|
||||
print("\n" + ("SETTINGS E2E ALL PASSED" if not failures
|
||||
else "FAILURES: " + "; ".join(failures)))
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,225 @@
|
||||
"""上传解析 e2e:上传 → 解析回填全链路(覆盖 md/txt/pdf/html/xlsx/pptx/URL + 异常)。
|
||||
|
||||
用法:python tests/e2e_upload.py
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import uuid
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
BASE = "http://127.0.0.1:5099"
|
||||
USER = f"up_e2e_{int(time.time())}"
|
||||
PASS = "up_e2e_123"
|
||||
TMP = ROOT / "tests" / "_up"
|
||||
|
||||
COOKIE = {"value": ""}
|
||||
|
||||
|
||||
def call(method, path, payload=None, raw=False):
|
||||
url = BASE + path
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
if payload is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if COOKIE["value"]:
|
||||
req.add_header("Cookie", COOKIE["value"])
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
body = resp.read()
|
||||
sc = resp.headers.get("Set-Cookie")
|
||||
if sc and not COOKIE["value"]:
|
||||
COOKIE["value"] = sc.split(";")[0]
|
||||
return resp.status, (body if raw else json.loads(body or b"{}"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()
|
||||
try:
|
||||
parsed = json.loads(body or b"{}")
|
||||
except Exception:
|
||||
parsed = {"raw": body.decode("utf-8", "ignore")}
|
||||
return e.code, parsed
|
||||
|
||||
|
||||
def upload(filename, data, with_cookie=True):
|
||||
"""multipart 上传,返回 (status, body)。"""
|
||||
boundary = "----pptagente2e" + uuid.uuid4().hex[:8]
|
||||
head = (f'--{boundary}\r\nContent-Disposition: form-data; name="file"; '
|
||||
f'filename="{filename}"\r\nContent-Type: application/octet-stream\r\n\r\n')
|
||||
body = head.encode("utf-8") + data + f"\r\n--{boundary}--\r\n".encode("utf-8")
|
||||
req = urllib.request.Request(BASE + "/api/upload", data=body, method="POST")
|
||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||
if with_cookie and COOKIE["value"]:
|
||||
req.add_header("Cookie", COOKIE["value"])
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return resp.status, json.loads(resp.read() or b"{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
return e.code, json.loads(e.read() or b"{}")
|
||||
except Exception:
|
||||
return e.code, {}
|
||||
|
||||
|
||||
def make_samples():
|
||||
"""生成本地样例文件,返回 {格式名: (文件名, 字节)}。"""
|
||||
TMP.mkdir(exist_ok=True)
|
||||
samples = {}
|
||||
|
||||
md = "# UPLOAD_E2E_MARKER_MD\n知识库平台建设背景说明\n本期完成检索服务上线\n"
|
||||
samples["md"] = ("sample.md", md.encode("utf-8"))
|
||||
|
||||
txt = "UPLOAD_E2E_MARKER_TXT\n知识库平台建设背景说明\n本期完成检索服务上线"
|
||||
samples["txt"] = ("sample.txt", txt.encode("utf-8"))
|
||||
|
||||
html = ("<html><head><style>.x{color:red}</style></head><body>"
|
||||
"<h1>UPLOAD_E2E_MARKER_HTML</h1><p>知识库平台季度汇报</p></body></html>")
|
||||
samples["html"] = ("sample.html", html.encode("utf-8"))
|
||||
|
||||
import fitz
|
||||
doc = fitz.open()
|
||||
page = doc.new_page()
|
||||
page.insert_text((72, 72), "UPLOAD_E2E_MARKER_PDF quarterly report of knowledge platform")
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
samples["pdf"] = ("sample.pdf", buf.getvalue())
|
||||
|
||||
from openpyxl import Workbook
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws["A1"] = "UPLOAD_E2E_MARKER_XLSX"
|
||||
ws["A2"] = "knowledge platform kpi"
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
samples["xlsx"] = ("sample.xlsx", buf.getvalue())
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches
|
||||
prs = Presentation()
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[5])
|
||||
tb = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(6), Inches(1))
|
||||
tb.text_frame.text = "UPLOAD_E2E_MARKER_PPTX knowledge platform review"
|
||||
buf = io.BytesIO()
|
||||
prs.save(buf)
|
||||
samples["pptx"] = ("sample.pptx", buf.getvalue())
|
||||
|
||||
return samples
|
||||
|
||||
|
||||
def start_url_server():
|
||||
"""本地起一个极简网页服务器,供 URL 抓取分支测试。"""
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = (b"<html><head><title>t</title></head><body>"
|
||||
b"<h1>UPLOAD_E2E_MARKER_URL</h1><p>web capture ok</p></body></html>")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
srv = HTTPServer(("127.0.0.1", 5199), H)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
return srv
|
||||
|
||||
|
||||
def main():
|
||||
failures = []
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
print(f" [{'OK' if cond else 'NG'}] {name} {extra}")
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||
subprocess.run([sys.executable, "-m", "src.db.init"], cwd=str(ROOT),
|
||||
capture_output=True, env=env)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-c",
|
||||
f"import sys; sys.path.insert(0, r'{ROOT}');"
|
||||
"from src.web.app import app; app.run(host='127.0.0.1', port=5099)"],
|
||||
cwd=str(ROOT), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
srv = start_url_server()
|
||||
samples = make_samples()
|
||||
try:
|
||||
up = False
|
||||
for _ in range(30):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
call("GET", "/api/me")
|
||||
up = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
check("server up", up)
|
||||
|
||||
s, b = call("POST", "/api/register",
|
||||
{"username": USER, "password": PASS, "confirm_password": PASS})
|
||||
check("register", s == 200 and b.get("code") == 0)
|
||||
|
||||
# ---- 鉴权与非法输入 ----
|
||||
s, b = upload("x.md", b"# hi", with_cookie=False)
|
||||
check("upload without login -> 401", s == 401, str(s))
|
||||
s, b = upload("evil.exe", b"MZ fake")
|
||||
check("upload exe -> 3001", s == 400 and b.get("code") == 3001, str(b)[:60])
|
||||
s, b = upload("huge.txt", b"A" * (21 * 1024 * 1024))
|
||||
check("upload 21MB -> 3002", b.get("code") == 3002, str(b)[:60])
|
||||
s, b = call("POST", "/api/parse", {"file_id": "nothex"})
|
||||
check("parse bad file_id -> 400", s == 400, str(b)[:60])
|
||||
s, b = call("POST", "/api/parse", {"file_id": "f" * 32})
|
||||
check("parse unknown file_id -> 404", s == 404 and b.get("code") == 3003, str(b)[:60])
|
||||
|
||||
# ---- 各格式解析 ----
|
||||
file_ids = {}
|
||||
for fmt, (fname, data) in samples.items():
|
||||
s, b = upload(fname, data)
|
||||
ok_up = s == 200 and b.get("code") == 0 and b.get("data", {}).get("file_id")
|
||||
check(f"upload {fmt}", bool(ok_up), str(b)[:60])
|
||||
if not ok_up:
|
||||
continue
|
||||
fid = b["data"]["file_id"]
|
||||
file_ids[fmt] = fid
|
||||
marker = {
|
||||
"md": "UPLOAD_E2E_MARKER_MD", "txt": "UPLOAD_E2E_MARKER_TXT",
|
||||
"html": "UPLOAD_E2E_MARKER_HTML", "pdf": "UPLOAD_E2E_MARKER_PDF",
|
||||
"xlsx": "UPLOAD_E2E_MARKER_XLSX", "pptx": "UPLOAD_E2E_MARKER_PPTX",
|
||||
}[fmt]
|
||||
s, b = call("POST", "/api/parse", {"file_id": fid})
|
||||
d = b.get("data") or {}
|
||||
check(f"parse {fmt}", s == 200 and b.get("code") == 0
|
||||
and marker in d.get("text", "") and d.get("chars", 0) >= 20,
|
||||
str(b)[:80])
|
||||
|
||||
# ---- URL 抓取分支 ----
|
||||
s, b = call("POST", "/api/parse", {"url": "notaurl"})
|
||||
check("parse bad url -> 400", s == 400, str(b)[:60])
|
||||
s, b = call("POST", "/api/parse", {"url": "http://127.0.0.1:5199/page"})
|
||||
d = b.get("data") or {}
|
||||
check("parse local url", s == 200 and "UPLOAD_E2E_MARKER_URL" in d.get("text", ""),
|
||||
str(b)[:80])
|
||||
finally:
|
||||
proc.terminate()
|
||||
srv.shutdown()
|
||||
|
||||
import shutil
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
print("\n" + ("UPLOAD E2E ALL PASSED" if not failures
|
||||
else "FAILURES: " + "; ".join(failures)))
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Web 全链路端到端测试:初始化DB → 启动Flask → 注册 → 生成 → 轮询 → 下载。
|
||||
|
||||
用法:
|
||||
python tests/e2e_web.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
BASE = "http://127.0.0.1:5099"
|
||||
USER = f"e2e_user_{int(time.time())}"
|
||||
PASS = "e2e_pass_123"
|
||||
SAMPLE = """# 项目背景
|
||||
为提升内部知识周转效率,启动知识库平台建设
|
||||
已完成技术选型与原型验证
|
||||
# 本期成果
|
||||
部署完成基础服务,导入文档120份
|
||||
混合检索准确率显著优于基线方案
|
||||
# 下一步
|
||||
扩大试点范围至三个部门
|
||||
建立内容运营与质量巡检机制
|
||||
"""
|
||||
|
||||
|
||||
def call(method: str, path: str, payload=None, raw=False):
|
||||
url = BASE + path
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
if payload is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
# cookie 会话
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
body = resp.read()
|
||||
set_cookie = resp.headers.get("Set-Cookie")
|
||||
return resp.status, (body if raw else json.loads(body or b"{}")), set_cookie
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()
|
||||
try:
|
||||
return e.code, json.loads(body or b"{}"), e.headers.get("Set-Cookie")
|
||||
except Exception:
|
||||
return e.code, {"raw": body.decode("utf-8", "ignore")}, e.headers.get("Set-Cookie")
|
||||
|
||||
|
||||
COOKIE = {"value": ""}
|
||||
|
||||
|
||||
def callx(method, path, payload=None, raw=False):
|
||||
status, body, sc = call(method, path, payload, raw)
|
||||
if sc and COOKIE["value"] == "":
|
||||
COOKIE["value"] = sc.split(";")[0]
|
||||
return status, body
|
||||
|
||||
|
||||
# 注入 cookie
|
||||
_orig_call = call
|
||||
|
||||
|
||||
def call(method, path, payload=None, raw=False): # noqa: F811
|
||||
url = BASE + path
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
if payload is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if COOKIE["value"]:
|
||||
req.add_header("Cookie", COOKIE["value"])
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
body = resp.read()
|
||||
sc = resp.headers.get("Set-Cookie")
|
||||
if sc and not COOKIE["value"]:
|
||||
COOKIE["value"] = sc.split(";")[0]
|
||||
return resp.status, (body if raw else json.loads(body or b"{}"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()
|
||||
try:
|
||||
parsed = json.loads(body or b"{}")
|
||||
except Exception:
|
||||
parsed = {"raw": body.decode("utf-8", "ignore")}
|
||||
sc = e.headers.get("Set-Cookie")
|
||||
if sc and not COOKIE["value"]:
|
||||
COOKIE["value"] = sc.split(";")[0]
|
||||
return e.code, parsed
|
||||
|
||||
|
||||
def main():
|
||||
failures = []
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
print(f" [{'OK' if cond else 'NG'}] {name} {extra}")
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
print("== 初始化数据库 ==")
|
||||
import subprocess
|
||||
r = subprocess.run([sys.executable, "-m", "src.db.init"], cwd=str(ROOT),
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
env={**__import__("os").environ, "PYTHONIOENCODING": "utf-8"})
|
||||
check("db init", r.returncode == 0, r.stdout.strip()[-40:] + r.stderr.strip()[-80:])
|
||||
|
||||
print("== 启动 Flask ==")
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-c",
|
||||
f"import sys; sys.path.insert(0, r'{ROOT}');"
|
||||
"from src.web.app import app; app.run(host='127.0.0.1', port=5099)"],
|
||||
cwd=str(ROOT), env={**__import__("os").environ, "PYTHONIOENCODING": "utf-8"},
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
up = False
|
||||
for _ in range(30):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
s, _b = call("GET", "/api/me")
|
||||
up = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
check("server up", up)
|
||||
|
||||
print("== 认证链路 ==")
|
||||
s, b = call("POST", "/api/register",
|
||||
{"username": USER, "password": PASS, "confirm_password": PASS})
|
||||
check("register", s == 200 and b.get("code") == 0, str(b)[:60])
|
||||
|
||||
s, b = call("GET", "/api/me")
|
||||
check("me with session", s == 200 and b.get("data", {}).get("username") == USER)
|
||||
prefs = b.get("data", {}).get("preferences") or {}
|
||||
|
||||
print("== 校验拦截 ==")
|
||||
s, b = call("POST", "/api/generate",
|
||||
{**{"title": "t", "content": "太短", "scene": "report"},
|
||||
**{k: prefs.get("default_" + k[0:-4], v) for k, v in []}})
|
||||
check("short content rejected (2001)", b.get("code") == 2001, str(b)[:60])
|
||||
|
||||
print("== 真实生成 ==")
|
||||
gen_payload = {
|
||||
"title": "E2E冒烟报告",
|
||||
"content": SAMPLE,
|
||||
"scene": "report", "language": "zh", "canvas": "ppt169",
|
||||
"image_strategy": "off", "color_scheme": "blue",
|
||||
"page_min": 5, "page_max": 8,
|
||||
}
|
||||
s, b = call("POST", "/api/generate", gen_payload)
|
||||
check("generate accepted", s == 200 and b.get("code") == 0, str(b)[:80])
|
||||
task_id = (b.get("data") or {}).get("task_id")
|
||||
|
||||
final = None
|
||||
for _ in range(120):
|
||||
time.sleep(1)
|
||||
s, b = call("GET", f"/api/generate/{task_id}/status")
|
||||
d = b.get("data") or {}
|
||||
if d.get("status") in ("done", "failed"):
|
||||
final = d
|
||||
break
|
||||
check("generation done", final and final.get("status") == "done", str(final)[:120])
|
||||
|
||||
s, b = call("GET", f"/api/generate/{task_id}/result")
|
||||
record = (b.get("data") or {}).get("record") or {}
|
||||
rid = record.get("id")
|
||||
check("result has record", bool(rid), f"id={rid} pages={record.get('page_count')} size={record.get('file_size')}")
|
||||
check("page_count sane", 5 <= (record.get("page_count") or 0) <= 8,
|
||||
str(record.get("page_count")))
|
||||
|
||||
print("== 历史与下载 ==")
|
||||
s, b = call("GET", "/api/records?keyword=E2E")
|
||||
recs = ((b.get("data") or {}).get("records")) or []
|
||||
check("records list contains new", any(x["id"] == rid for x in recs))
|
||||
|
||||
s, body_bytes = call("GET", f"/api/files/{rid}/download", raw=True)
|
||||
ok_pptx = body_bytes[:2] == b"P" + b"K" # PK zip header
|
||||
check("download pptx bytes", s == 200 and ok_pptx, f"{len(body_bytes)} bytes head={body_bytes[:2]}")
|
||||
|
||||
pptx_path = ROOT / "tests" / "_e2e_download.pptx"
|
||||
pptx_path.write_bytes(body_bytes)
|
||||
from pptx import Presentation
|
||||
prs = Presentation(str(pptx_path))
|
||||
texts = "\n".join(sh.text_frame.text for sl in prs.slides
|
||||
for sh in sl.shapes if sh.has_text_frame)
|
||||
check("pptx contains title", "E2E冒烟报告" in texts)
|
||||
check("pptx contains content keyword", "混合检索" in texts)
|
||||
finally:
|
||||
proc.terminate()
|
||||
|
||||
print("\n" + ("E2E ALL PASSED" if not failures else "E2E FAILURES: " + "; ".join(failures)))
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""冒烟测试:build_plan 结构 + aura 引擎直接渲染。
|
||||
|
||||
用法:
|
||||
python tests/smoke_build_plan.py # 仅构建 plan 并打印结构
|
||||
python tests/smoke_build_plan.py --render # 追加真实渲染 + 保真验证
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from src.agent.perception import GenerateConfig # noqa: E402
|
||||
from src.agent.planning import build_plan # noqa: E402
|
||||
|
||||
SAMPLE_ZH = """# 本月完成情况
|
||||
完成模块A开发,进度符合计划
|
||||
修复线上3个缺陷,系统可用性提升至99.9%
|
||||
新增用户反馈收集通道
|
||||
# 下月计划
|
||||
启动模块B概要设计
|
||||
组织跨部门代码评审
|
||||
开展性能压测与容量评估
|
||||
补充核心链路自动化测试用例
|
||||
# 风险与求助
|
||||
人力存在缺口,希望增援1名后端
|
||||
第三方接口联调依赖对方排期
|
||||
"""
|
||||
|
||||
SAMPLE_JA = """# 今月の進捗
|
||||
モジュールAの開発完了、予定どおり
|
||||
本番障害3件を修正し、可用性99.9%を達成
|
||||
# 来月の計画
|
||||
モジュールBの概要設計を開始
|
||||
性能テストを実施する
|
||||
"""
|
||||
|
||||
|
||||
def make_cfg(lang="zh"):
|
||||
return GenerateConfig(
|
||||
title="七月项目周报" if lang == "zh" else "7月度プロジェクト報告",
|
||||
user_id=1, content=SAMPLE_ZH if lang == "zh" else SAMPLE_JA,
|
||||
scene="report", language=lang, canvas="ppt169",
|
||||
image_strategy="off",
|
||||
color_scheme="blue", page_min=6, page_max=10,
|
||||
)
|
||||
|
||||
|
||||
def show(plan):
|
||||
print(f"design={plan['design']} slides={len(plan['slides'])}")
|
||||
for i, s in enumerate(plan["slides"], 1):
|
||||
n = len(s.get("content") or [])
|
||||
print(f" {i:02d}. [{s['type']:9s}] {s.get('title','')[:30]} bullets={n}")
|
||||
|
||||
|
||||
def main():
|
||||
failures = []
|
||||
for lang in ("zh", "ja"):
|
||||
r = build_plan(make_cfg(lang))
|
||||
plan = r.plan
|
||||
print(f"\n===== {lang} ===== warnings={r.warnings}")
|
||||
show(plan)
|
||||
types = [s["type"] for s in plan["slides"]]
|
||||
if types[0] != "cover" or types[-1] != "end":
|
||||
failures.append(f"{lang}: 首尾页面类型错误")
|
||||
if not (6 <= len(plan["slides"]) <= 10) and lang == "zh":
|
||||
failures.append(f"{lang}: 页数 {len(plan['slides'])} 超出目标区间")
|
||||
if lang == "ja" and plan["design"] != "report-jp":
|
||||
failures.append("ja 未映射到 report-jp")
|
||||
if lang == "zh" and plan["design"] != "corp-blue":
|
||||
failures.append("zh/blue 未映射到 corp-blue")
|
||||
|
||||
if "--render" in sys.argv:
|
||||
from config import COMPANY_TEMPLATE
|
||||
from src.engine_bridge import render_plan, verify_output
|
||||
tmp = Path(tempfile.mkdtemp(prefix="aura_smoke_"))
|
||||
for lang in ("zh", "ja"):
|
||||
r = build_plan(make_cfg(lang))
|
||||
p = tmp / f"plan_{lang}.json"
|
||||
p.write_text(json.dumps(r.plan, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
out = tmp / f"smoke_{lang}.pptx"
|
||||
tpl = COMPANY_TEMPLATE if (lang == "ja" and COMPANY_TEMPLATE.exists()) else None
|
||||
render_plan(p, out, template=tpl)
|
||||
ok, detail = verify_output(out, p)
|
||||
print(f"\n[render {lang}] -> {out}")
|
||||
print(f"[verify {lang}] passed={ok}\n{detail}")
|
||||
if not ok:
|
||||
failures.append(f"{lang}: 渲染保真验证未通过")
|
||||
|
||||
print("\n" + ("ALL SMOKE PASSED" if not failures else "FAILURES: " + "; ".join(failures)))
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user