diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1e5fc24..1027fb7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,10 @@ "Bash(git:*)", "Bash(wc -l /d/Develop/Project/AIsystem/teacher-platform/src/views/*.vue | sort -rn | head -20)", "WebSearch", - "Bash(start:*)" + "Bash(start:*)", + "WebFetch(domain:docmee.cn)", + "WebFetch(domain:open.docmee.cn)", + "WebFetch(domain:github.com)" ] } } diff --git "a/DOCMEE_API\346\226\207\346\241\243\346\225\264\347\220\206.md" "b/DOCMEE_API\346\226\207\346\241\243\346\225\264\347\220\206.md" new file mode 100644 index 0000000..b6be90d --- /dev/null +++ "b/DOCMEE_API\346\226\207\346\241\243\346\225\264\347\220\206.md" @@ -0,0 +1,2102 @@ +# 文多多AiPPT开放平台API文档 + +## 目录 +- [接入流程](#接入流程) +- [接口鉴权](#接口鉴权) +- [API接口分类](#api接口分类) + - [AI PPT - V2](#ai-ppt---v2) + - [AI PPT - V1](#ai-ppt---v1) + - [模板管理](#模板管理) + - [PPT获取与操作](#ppt获取与操作) + - [积分和使用记录](#积分和使用记录) + - [其他接口](#其他接口) +- [错误码](#错误码) +- [特殊说明](#特殊说明) + +## 接入流程 + +文多多AiPPT API接入遵循标准的RESTful设计模式,主要流程如下: + +1. **获取API Key**:在[开放平台](https://open.docmee.cn/open)注册账号并获取API Key。 +2. **创建访问Token**:通过`createApiToken`接口创建具有时效性的访问Token。 +3. **调用业务接口**:在请求头中携带Token调用具体的业务接口。 +4. **处理响应**:根据接口返回的数据进行后续处理。 + +> **重要提示**:该接口请在服务端调用,同一个uid创建token时,之前通过该uid创建的token会在10秒内过期。 + +## 接口鉴权 + +### 创建接口Token + +用于生成调用鉴权Token,支持限制生成次数与数据隔离。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/user/createApiToken` | +| 请求方法 | POST | +| 请求头 | `Content-Type: application/json`, `Api-Key: xxx` | + +#### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| uid | string | 否 | 用户ID(自定义用户ID,非必填,建议不超过32位字符串),不同uid创建的token数据会相互隔离,主要用于数据隔离 | +| limit | number | 否 | 限制token最大生成PPT次数(数字,为空则不限制,为0时不允许生成PPT,大于0时限制生成PPT次数)。UI iframe接入时强烈建议传limit参数,避免token泄露造成损失! | +| timeOfHours | number | 否 | 过期时间,单位:小时,默认两小时过期,最大可设置为48小时 | + +#### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.token | string | token (调用api接口鉴权用,请求头传token) | +| data.expireTime | number | 过期时间(秒) | +| code | number | 状态码 | +| message | string | 提示信息 | + +#### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/user/createApiToken' \ +--header 'Content-Type: application/json' \ +--header 'Api-Key: xxx' \ +--data '{"uid": "xxx","limit": 10}' +``` + +### API接口鉴权 + +所有业务接口都需要进行鉴权。 + +| 属性 | 说明 | +|------|------| +| 请求头 | `token: {token}` 或直接使用 `Api-Key: {apiKey}` | + +#### 接口请求示例 +```bash +curl --location 'https://open.docmee.cn/api/ppt/xxx' \ +--header 'token: xxx' +``` + +> **注意**:封面图片资源访问,需要在url上拼接`?token=xxx`。 + +## API接口分类 + +### AI PPT - V2 + +官方推荐使用的最新版本API。 + +#### 创建任务 + +开启一个生成PPT的任务。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/v2/createTask` | +| 请求方法 | POST | +| Content-Type | `multipart/form-data` | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| type | number | 是 | 类型:
1. 智能生成(主题、要求)
2. 上传文件生成
3. 上传思维导图生成
4. 通过word精准转ppt
5. 通过网页链接生成
6. 粘贴文本内容生成
7. Markdown大纲生成 | +| content | string | 否 | 内容:
- type=1:用户输入主题或要求(不超过1000字符)
- type=2,4:不传
- type=3:幕布等分享链接
- type=5:网页链接地址(http/https)
- type=6:粘贴文本内容(不超过20000字符)
- type=7:大纲内容(markdown) | +| file | File[] | 否 | 文件列表(文件数不超过5个,总大小不超过50M):
- type=1:上传参考文件(非必传,支持多个)
- type=2:上传文件(支持多个)
- type=3:上传思维导图(xmind/mm/md)(仅支持一个)
- type=4:上传word文件(仅支持一个)
- type=5,6,7:不传 | + +**支持格式**:doc/docx/pdf/ppt/pptx/txt/md/xls/xlsx/csv/html/epub/mobi/xmind/mm + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.id | string | 任务ID | +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/v2/createTask' \ +--header 'Content-Type: multipart/form-data' \ +--form 'type=1' \ +--form 'content="AI未来的发展"' +``` + +#### 获取生成选项 + +获取调用生成大纲内容需要使用的相关选项。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/v2/options` | +| 请求方法 | GET | + +> 该接口支持国际化,URL携带lang参数指定。 + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.lang | array | 语种选项 | +| data.scene | array | 场景选项 | +| data.audience | array | 受众选项 | +| code | number | 状态码 | +| message | string | 提示信息 | + +**语种(lang)选项**: +1. {name: "简体中文", value: "zh"} +2. {name: "繁體中文", value: "zh-Hant"} +3. {name: "English", value: "en"} +4. {name: "日本語", value: "ja"} +5. {name: "한국어", value: "ko"} +6. {name: "Français", value: "fr"} +7. {name: "Русский", value: "ru"} +8. {name: "العربية", value: "ar"} +9. {name: "Deutsch", value: "de"} +10. {name: "Español", value: "es"} +11. {name: "Italiano", value: "it"} +12. {name: "Português", value: "pt"} + +**场景(scene)选项**: +1. {name: "通用场景", value: "通用场景"} +2. {name: "教学课件", value: "教学课件"} +3. {name: "工作总结", value: "工作总结"} +4. {name: "工作计划", value: "工作计划"} +5. {name: "项目汇报", value: "项目汇报"} +6. {name: "解决方案", value: "解决方案"} +7. {name: "研究报告", value: "研究报告"} +8. {name: "会议材料", value: "会议材料"} +9. {name: "产品介绍", value: "产品介绍"} +10. {name: "公司介绍", value: "公司介绍"} +11. {name: "商业计划书", value: "商业计划书"} +12. {name: "科普宣传", value: "科普宣传"} +13. {name: "公众演讲", value: "公众演讲"} + +**受众(audience)选项**: +1. {name: "大众", value: "大众"} +2. {name: "学生", value: "学生"} +3. {name: "老师", value: "老师"} +4. {name: "上级领导", value: "上级领导"} +5. {name: "下属", value: "下属"} +6. {name: "面试官", value: "面试官"} +7. {name: "同事", value: "同事"} + +##### 请求示例 +```bash +curl -X GET --location 'https://open.docmee.cn/api/ppt/v2/options?lang=zh' +``` + +#### 生成大纲内容 + +生成当前任务的大纲及内容。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/v2/generateContent` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| id | string | 是 | 任务ID | +| stream | boolean | 否 | 是否流式(默认true) | +| length | string | 否 | 篇幅长度:short/medium/long => 10-15页/20-30页/25-35页 | +| scene | string | 否 | 演示场景:通用场景、教学课件、工作总结、工作计划、项目汇报、解决方案、研究报告、会议材料、产品介绍、公司介绍、商业计划书、科普宣传、公众演讲等任意场景类型 | +| audience | string | 否 | 受众:大众、学生、老师、上级领导、下属、面试官、同事等任意受众类型 | +| lang | string | 否 | 语言: zh/zh-Hant/en/ja/ko/ar/de/fr/it/pt/es/ru | +| prompt | string | 否 | 用户要求(小于50字) | + +> **特别提醒**:参数`prompt`只会在创建的任务类型为1(智能生成), 2(上传文件生成), 5(通过网页链接生成), 6(粘贴文本内容生成)这些类型时生效,其他类型会忽略该字段。 + +##### 响应 +**流式响应(event-stream)**: +```json +{"text": "#", "status": 3} +{"text": " ", "status": 3} +{"text": "主题", "status": 3} +... +{ + "text": "", + "status": 4, + "result": { + "level": 1, + "name": "主题", + "children": [ + { + "level": 2, + "name": "章节", + "children": [ + { + "level": 3, + "name": "页面标题", + "children": [ + { + "level": 4, + "name": "内容标题", + "children": [ + { + "level": 0, + "name": "内容" + } + ] + } + ] + } + ] + } + ] + } +} +``` + +**非流式响应(application/json)**: +```json +{ + "code": 0, + "data": { + "text": "# 主题\\n## 章节\\n### 页面标题\\n#### 内容标题\\n- 内容", + "result": { + "level": 1, + "name": "主题", + "children": [ + { + "level": 2, + "name": "章节", + "children": [ + { + "level": 3, + "name": "页面标题", + "children": [ + { + "level": 4, + "name": "内容标题", + "children": [ + { + "level": 0, + "name": "内容" + } + ] + } + ] + } + ] + } + ] + } + }, + "message": "ok" +} +``` + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/v2/generateContent' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"id":"xxx","stream":true,"length":"medium"}' +``` + +#### 修改大纲内容 + +根据用户指令修改大纲内容。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/v2/updateContent` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| id | string | 是 | 任务ID | +| stream | boolean | 否 | 是否流式(默认true) | +| markdown | string | 是 | 大纲内容markdown | +| question | string | 否 | 用户修改建议 | + +##### 响应 +event-stream或application/json,结构同生成大纲内容。 + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/v2/updateContent' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"id":"xxx","markdown":"# 主题\\n## 章节","question":"帮我优化一下结构"}' +``` + +#### 生成PPT + +根据markdown格式的PPT大纲与内容生成PPT作品。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/v2/generatePptx` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| id | string | 是 | 任务ID | +| templateId | string | 是 | 模板ID(调用模板接口获取) | +| markdown | string | 是 | 大纲内容markdown | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.pptInfo.id | string | ppt id | +| data.pptInfo.subject | string | 主题 | +| data.pptInfo.coverUrl | string | 封面 | +| data.pptInfo.templateId | string | 模板ID | +| data.pptInfo.pptxProperty | string | PPT数据结构(json gzip base64) | +| data.pptInfo.userId | string | 用户ID | +| data.pptInfo.userName | string | 用户名称 | +| data.pptInfo.companyId | number | 公司ID | +| data.pptInfo.updateTime | string/null | 更新时间 | +| data.pptInfo.createTime | string | 创建时间 | +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/v2/generatePptx' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"id":"xxx","templateId":"xxx","markdown":"# 主题\\n## 章节"}' +``` + +> 通过API生成PPT后,如果需要在前端进行编辑和渲染,推荐使用以下iframe方式编辑器: +> - github +> - gitee + +### AI PPT - V1 + +旧版本API,仍可使用但推荐使用V2版本。 + +#### 解析文件内容 + +将若干支持的参数选项转化成dataUrl以便后续接口使用。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/parseFileData` | +| 请求方法 | POST | +| Content-Type | `multipart/form-data` | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| file | File | 否 | 文件(限制50M以内,最大解析2万字) | +| content | string | 否 | 用户粘贴文本内容 | +| fileUrl | string | 否 | 文件公网链接 | +| website | string | 否 | 网址(http/https) | +| websearch | string | 否 | 网络搜索关键词 | + +**支持格式**:doc/docx/pdf/ppt/pptx/txt/md/xls/xlsx/csv/html/epub/mobi/xmind/mm + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.dataUrl | string | 文件数据url(有效期:当天) | +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/parseFileData' \ +--header 'Content-Type: multipart/form-data' \ +--header 'token: {token}' \ +--form 'file=@test.doc;filename=test.doc' \ +--form 'content=文本内容' \ +--form 'fileUrl=https://xxx.pdf' \ +--form 'website=https://example.com' \ +--form 'websearch=上海元符号智能科技有限公司' +``` + +#### 生成大纲 + +V1版本的生成大纲接口。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/generateOutline` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| stream | boolean | 否 | 是否流式生成(默认流式) | +| length | string | 否 | 篇幅长度:short/medium/long, 默认medium, 分别对应: 10-15页/20-30页/25-35页 | +| lang | string | 否 | 语言: zh/zh-Hant/en/ja/ko/ar/de/fr/it/pt/es/ru | +| prompt | string | 否 | 用户要求(小于50字) | +| subject | string | 否 | 主题(与dataUrl可同时存在) | +| dataUrl | string | 否 | 文件数据url,通过解析文件内容接口返回(与subject可同时存在) | + +##### 响应 +```json +{"text": "", "status": 1} +{"text": "# ", "status": 3} +{"text": " ", "status": 3} +{"text": "主题", "status": 3} +... +{ + "text": "", + "status": 4, + "result": { + "level": 1, + "name": "主题", + "children": [ + { + "level": 2, + "name": "章节", + "children": [ + { + "level": 3, + "name": "页面标题", + "children": [ + { + "level": 4, + "name": "内容标题" + } + ] + } + ] + } + ] + } +} +``` +> 状态:-1异常 1解析文件 3生成中 4完成 + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/generateOutline' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"subject": "AI未来的发展"}' +``` + +#### 修改大纲 + +根据用户指令修改大纲。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/ppt/updateOutline` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| outlineMarkdown | string | 是 | 大纲markdown内容 | +| length | string | 否 | 篇幅长度:short/medium/long, 默认medium, 分别对应: 10-15页/20-30页/25-35页 | +| question | string | 否 | 用户修改建议
系统内置:用金字塔原理优化、强化大纲结构和过渡、突出大纲主题、让大纲更专业
用户自定义:比如"帮我把大纲改成金字塔结构" | + +##### 响应 +event-stream,结构同generateOutline生成大纲。 + +#### 生成大纲内容 + +通过Markdown格式的大纲生成PPT内容。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/generateContent` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| stream | boolean | 否 | 是否流式生成(默认流式) | +| outlineMarkdown | string | 是 | 大纲markdown文本 | +| asyncGenPptx | boolean | 否 | 是否异步生成 | +| lang | string | 否 | 语言: zh/zh-Hant/en/ja/ko/ar/de/fr/it/pt/es/ru | +| prompt | string | 否 | 用户要求 | +| dataUrl | string | 否 | 文件数据url,调用解析文件内容接口返回 | + +##### 响应 +```json +{"text": "", "status": 3} +{"text": "#", "status": 3} +{"text": " ", "status": 3} +{"text": "主题", "status": 3} +... +{ + "text": "", + "status": 4, + "result": { + "level": 1, + "name": "主题", + "children": [ + { + "level": 2, + "name": "章节", + "children": [ + { + "level": 3, + "name": "页面标题", + "children": [ + { + "level": 4, + "name": "内容标题", + "children": [ + { + "level": 0, + "name": "内容" + } + ] + } + ] + } + ] + } + ] + } +} +``` +> 状态:-1异常 1解析文件 3生成中 4完成 + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/generateContent' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"outlineMarkdown": "xxx"}' +``` + +#### 生成PPT + +通过大纲与内容生成PPT。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/generatePptx` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| templateId | string | 否 | 模板ID(非必填) | +| pptxProperty | boolean | 否 | 是否返回PPT数据结构 | +| outlineContentMarkdown | string | 是 | 大纲内容markdown | +| notes | array | 否 | 备注(PPT页面备注,非必填,数组["内容页面一备注", "内容页面二备注"]) | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.pptInfo.id | string | ppt id | +| data.pptInfo.subject | string | 主题 | +| data.pptInfo.coverUrl | string | 封面 | +| data.pptInfo.fileUrl | string | PPT文件 | +| data.pptInfo.templateId | string | 模板ID | +| data.pptInfo.pptxProperty | string | PPT数据结构(json数据通过gzip压缩base64编码返回) | +| data.pptInfo.userId | string | 用户ID | +| data.pptInfo.userName | string | 用户名称 | +| data.pptInfo.companyId | number | 公司ID | +| data.pptInfo.updateTime | string/null | 更新时间 | +| data.pptInfo.createTime | string | 创建时间 | +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/generatePptx' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"outlineContentMarkdown": "xxx", "pptxProperty": false}' +``` + +### Word 转 PPT + +不同于通过解析文件生成PPT,Word转PPT会尽可能保留word文件中的层级结构和内容表述去生成PPT。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/v1/word2pptx` | +| 请求方法 | POST | +| Content-Type | `multipart/form-data` | + +##### 请求参数 +| 字段 | 类型 | 说明 | +|------|------|------| +| file | 文件 | docx文件(小于30M) | +| templateId | string | 模板ID(可空,为空时随机) | +| stream | boolean | 是否流式 | + +##### 非流式响应(application/json) +```json +{ + "code": 0, + "data": { + "pptInfo": { + "id": "xxx", + "subject": "xxx", + "coverUrl": "https://xxx.png", + "fileUrl": "https://xxx.pptx", + "templateId": "xxx", + "pptxProperty": "xxx", + "userId": "xxx", + "userName": "xxx", + "companyId": 1000, + "updateTime": null, + "createTime": "2024-01-01 10:00:00" + } + }, + "message": "操作成功" +} +``` + +##### 流式响应(event-stream) +```json +{"text": "", "status": 3} +{"text": "#", "status": 3} +{"text": " ", "status": 3} +{"text": "主题", "status": 3} +... +{"text": "", "status": 3, "pptId": "xxx"} +{ + "text": "", + "status": 4, + "result": { + "id": "xxx", + "subject": "xxx", + "coverUrl": "https://xxx.png", + "fileUrl": "https://xxx.pptx", + "templateId": "xxx", + "pptxProperty": "xxx", + "userId": "xxx", + "userName": "xxx", + "companyId": 1000, + "updateTime": null, + "createTime": "2024-01-01 10:00:00" + } +} +``` +> 状态:-1异常 1解析文件 3生成中 4完成 + +##### 请求示例 +```bash +# 非流式 +curl -X POST --location 'https://open.docmee.cn/api/ppt/v1/word2pptx' \ +--header 'Content-Type: multipart/form-data' \ +--header 'token: {token}' \ +--form 'stream=false' \ +--form 'file=@test.docx;filename=test.docx' + +# 流式 +curl -X POST --location 'https://open.docmee.cn/api/ppt/v1/word2pptx' \ +--header 'Content-Type: multipart/form-data' \ +--header 'token: {token}' \ +--form 'stream=true' \ +--form 'file=@test.docx;filename=test.docx' +``` + +### 直接生成PPT + +直接让模型生成PPT。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/directGeneratePptx` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| stream | boolean | 否 | 是否流式生成 | +| templateId | string | 否 | 模板ID(非必填,为空则随机模板) | +| pptxProperty | boolean | 否 | 是否返回PPT数据结构 | +| length | string | 否 | 篇幅长度:short/medium/long, 默认medium, 分别对应: 10-15页/20-30页/25-35页 | +| lang | string | 否 | 语言: zh/zh-Hant/en/ja/ko/ar/de/fr/it/pt/es/ru | +| prompt | string | 否 | 用户要求(小于50字) | +| subject | string | 否 | 主题(与dataUrl可同时存在) | +| dataUrl | string | 否 | 文件数据url,调用解析文件内容接口返回(与subject可同时存在) | + +##### 非流式响应(application/json) +```json +{ + "code": 0, + "data": { + "pptInfo": { + "id": "xxx", + "subject": "xxx", + "coverUrl": "https://xxx.png", + "fileUrl": "https://xxx.pptx", + "templateId": "xxx", + "pptxProperty": "xxx", + "userId": "xxx", + "userName": "xxx", + "companyId": 1000, + "updateTime": null, + "createTime": "2024-01-01 10:00:00" + } + }, + "message": "操作成功" +} +``` + +##### 流式响应(event-stream) +```json +{"text": "", "status": 3} +{"text": "#", "status": 3} +{"text": " ", "status": 3} +{"text": "主题", "status": 3} +... +{"text": "", "status": 3, "pptId": "xxx"} +{ + "text": "", + "status": 4, + "result": { + "id": "xxx", + "subject": "xxx", + "coverUrl": "https://xxx.png", + "fileUrl": "https://xxx.pptx", + "templateId": "xxx", + "pptxProperty": "xxx", + "userId": "xxx", + "userName": "xxx", + "companyId": 1000, + "updateTime": null, + "createTime": "2024-01-01 10:00:00" + } +} +``` +> 状态:-1异常 1解析文件 3生成中 4完成 + +##### 请求示例 +```bash +# 非流式 +curl -X POST --location 'https://open.docmee.cn/api/ppt/directGeneratePptx' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"stream": false, "subject": "AI未来的发展", "pptxProperty": false}' + +# 流式 +curl -X POST --location 'https://open.docmee.cn/api/ppt/directGeneratePptx' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"stream": true, "subject": "AI未来的发展", "pptxProperty": false}' +``` + +### AI PPT (异步) + +Ai异步流式生成PPT,只需在调用生成大纲接口后调用下面的generateContent接口即可生成PPT,无需再次调用生成PPT接口。 + +#### 生成大纲内容同时异步生成PPT + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/generateContent` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| templateId | string | 否 | 模板ID(非必填) | +| outlineMarkdown | string | 是 | 大纲markdown文本 | +| asyncGenPptx | boolean | 是 | 异步生成PPT(这里必须为true才会流式生成) | +| prompt | string | 否 | 用户要求 | +| dataUrl | string | 否 | 文件数据url,调用解析文件内容接口返回 | + +##### 响应 +```json +{"text": "", "status": 3, "pptId": "xxx", "total": 23, "current": 1} +{"text": "#", "status": 3} +{"text": " ", "status": 3} +{"text": "主题", "status": 3} +... +{ + "text": "", + "status": 4, + "result": { + "level": 1, + "name": "主题", + "children": [ + { + "level": 2, + "name": "章节", + "children": [ + { + "level": 3, + "name": "页面标题", + "children": [ + { + "level": 4, + "name": "内容标题", + "children": [ + { + "level": 0, + "name": "内容" + } + ] + } + ] + } + ] + } + ] + } +} +``` +> 状态:-1异常 0模型重置 1解析文件 2搜索网页 3生成中 4完成 + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/generateContent' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"outlineMarkdown":"xxx","asyncGenPptx":true}' +``` + +#### 查询异步生成PPT信息 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/asyncPptInfo?pptId=` | +| 请求方法 | GET | + +##### 参数 +**pptId** 为generateContent接口流式返回的pptId + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.total | number | 总页数 | +| data.current | number | 当前已生成页数(如果current >= total时表示PPT生成完成) | +| data.pptxProperty | string | PPT数据结构(json gzip base64) | +| code | number | 状态码 | + +##### 请求示例 +```bash +curl -X GET --location 'https://open.docmee.cn/api/ppt/asyncPptInfo?pptId=xxx' \ +--header 'token: {token}' +``` + +> **说明**:该接口不需要轮询,在generateContent接口流式返回pptId数据时调用,每出现一次pptId就调用一次获取最新的PPT信息。 +> +> **注意**:这个接口只有在流式生成过程中能查询到数据(临时缓存数据),在PPT生成完成的30秒内过期(查不到数据),此时需要调用loadPptx加载PPT数据接口查询。 + +### 对话生成PPT + +兼容openai chat接口生成PPT,鉴权支持请求头Api-Key和Authorization Bearer两种方式。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt-openai/v1/chat/completions` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| stream | boolean | 否 | 是否流式 | +| model | string | 是 | 模型(固定:direct-generate-pptx) | +| messages | array | 是 | 消息体:不支持连续对话(多个时默认取最后一个user) | +| appendLink | boolean | 否 | 大纲内容生成完成后是否在文本后面追加封面图片和下载链接(默认true) | +| templateId | string | 否 | 模板ID(默认为null,系统随机) | +| lang | string | 否 | 语言: zh/zh-Hant/en/ja/ko/ar/de/fr/it/pt/es/ru | +| prompt | string | 否 | 用户要求(不超过50字) | + +messages结构: +```json +[ + { + "role": "user", + "content": "AI未来的发展" + } +] +``` +> 支持:主题、文档内容、文件链接(公网可访问) + +##### 非流式响应(application/json) +```json +{ + "id": "1833839690764124160", + "model": "direct-generate-pptx", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "# AI未来的发展\\n## 1 技术进步\\n### 1.1 算法优化\\n#### 1.1.1 深度学习\\n- 深度学习模型不断优化,提高准确性和效率...|\\n\\n[封面图片]\\n\\n[点击下载]" + }, + "ppt_data": { + "id": "1833839690764124160", + "subject": "AI未来的发展", + "templateId": "xxx", + "coverUrl": "https://xxx.png", + "fileUrl": "https://xxx.pptx" + } + } + ], + "object": "chat.completion", + "created": 1726056513, + "usage": { + "completion_tokens": 10000, + "prompt_tokens": 2000, + "total_tokens": 12000 + } +} +``` + +##### 流式响应(event-stream) +```text +data: {"id":"1833839690764124160","choices":[{"delta":{"content":"#","role":"assistant"},"finish_reason":null,"index":0}],"created":1726056518,"model":"direct-generate-pptx","object":"chat.completion.chunk"} +data: {"id":"1833839690764124160","choices":[{"delta":{"content":" ","role":"assistant"},"finish_reason":null,"index":0}],"created":1726056518,"model":"direct-generate-pptx","object":"chat.completion.chunk"} +data: {"id":"1833839690764124160","choices":[{"delta":{"content":"AI","role":"assistant"},"finish_reason":null,"index":0}],"created":1726056518,"model":"direct-generate-pptx","object":"chat.completion.chunk"} +... +data: {"id":"1833839690764124160","choices":[{"delta":{"content":null,"role":"assistant"},"finish_reason":"stop","index":0,"ppt_data":{"companyId":1000,"coverUrl":"https://xxx.png","createTime":1726056519624,"fileUrl":"https://xxx.pptx","id":"1833839690764124160","name":"AI未来的发展","subject":"AI未来的发展","templateId":"1815308477845987328","updateTime":1726056519624,"userId":"xxx","userName":"xxx"}}],"created":1726056529,"model":"direct-generate-pptx","object":"chat.completion.chunk"} +``` + +##### Python请求示例 +```python +import json +from openai import OpenAI + +if __name__ == '__main__': + # 通过openai库直接请求 + client = OpenAI(base_url='{域名}/api/ppt-openai/v1', api_key='sk-xxx') + # 是否流式请求 + stream = True + response = client.chat.completions.create( + timeout=120, + stream=stream, + model='direct-generate-pptx', + messages=[ + { + 'role': 'user', + 'content': 'AI未来的发展' + } + ] + ) + if stream: + # 流式 + for trunk in response: + choice = trunk['choices'][0] + print(choice['delta']['content'], end='') + if 'ppt_data' in choice: + print(json.dumps(choice['ppt_data'])) + else: + # 非流式 + choice = response['choices'][0] + print(choice['message']['content']) + if 'ppt_data' in choice: + print(json.dumps(choice['ppt_data'])) +``` + +### MCP + +兼容Model Context Protocol(MCP)生成PPT。 + +| 属性 | 说明 | +|------|------| +| SSE Server端点 | `/api/mcp/sse?token=` | +| 鉴权 | 通过URL上的token参数鉴权,设置为你在平台的Api-Key或通过接口创建的token | + +#### tools/list +```json +[ + { + "name": "ai_generate_ppt", + "description": "AI generate PPT", + "inputSchema": { + "type": "object", + "properties": { + "task_description": { + "type": "string" + } + }, + "required": ["task_description"], + "additionalProperties": false + } + } +] +``` + +#### Typescript接入示例 +```typescript +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' + +async function main() { + const apiKey = 'ak_xxx' + const mcpClient = new Client({ + name: 'mcp-client-test', + version: '1.0.0', + }) + const transport = new SSEClientTransport( + new URL('https://open.docmee.cn/api/mcp/sse?token=' + apiKey), + ) + mcpClient.connect(transport) + const toolsResult = await mcpClient.listTools() + console.log('Tools:', toolsResult) + const result = await mcpClient.callTool({ + name: 'ai_generate_ppt', + arguments: { + task_description: '请以AI未来的发展为主题生成PPT', + }, + }) + console.log('Result:', result) +} + +main() +``` + +## 模板管理 + +### 获取模板过滤选项 + +获取查询模版的过滤选项。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/template/options` | +| 请求方法 | GET | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.category | array | 类目筛选 | +| data.style | array | 风格筛选 | +| data.themeColor | array | 主题颜色筛选 | +| code | number | 状态码 | +| message | string | 提示信息 | + +**类目(category)筛选**: +1. {name: "全部", value: ""} +2. {name: "年终总结", value: "年终总结"} +3. {name: "教育培训", value: "教育培训"} +4. {name: "医学医疗", value: "医学医疗"} +5. {name: "商业计划书", value: "商业计划书"} +6. {name: "企业介绍", value: "企业介绍"} +7. {name: "毕业答辩", value: "毕业答辩"} +8. {name: "营销推广", value: "营销推广"} +9. {name: "晚会表彰", value: "晚会表彰"} +10. {name: "个人简历", value: "个人简历"} + +**风格(style)筛选**: +1. {name: "全部", value: ""} +2. {name: "扁平简约", value: "扁平简约"} +3. {name: "商务科技", value: "商务科技"} +4. {name: "文艺清新", value: "文艺清新"} +5. {name: "卡通手绘", value: "卡通手绘"} +6. {name: "中国风", value: "中国风"} +7. {name: "创意时尚", value: "创意时尚"} +8. {name: "创意趣味", value: "创意趣味"} + +**主题颜色(themeColor)筛选**: +1. {name: "全部", value: ""} +2. {name: "橙色", value: "#FA920A"} +3. {name: "蓝色", value: "#589AFD"} +4. {name: "紫色", value: "#7664FA"} +5. {name: "青色", value: "#65E5EC"} +6. {name: "绿色", value: "#61D328"} +7. {name: "黄色", value: "#F5FD59"} +8. {name: "红色", value: "#E05757"} +9. {name: "棕色", value: "#8F5A0B"} +10. {name: "白色", value: "#FFFFFF"} +11. {name: "黑色", value: "#000000"} + +##### 请求示例 +```bash +curl -X GET --location 'https://open.docmee.cn/api/ppt/template/options' +``` + +### 分页查询PPT模板 + +分页查询PPT模版。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/templates` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| page | number | 是 | 分页:第几页 | +| size | number | 是 | 分页:每页大小 | +| filters.type | number | 是 | 模板类型(必传):1系统模板、4用户自定义模板 | +| filters.category | string | 否 | 类目筛选 | +| filters.style | string | 否 | 风格筛选 | +| filters.themeColor | string | 否 | 主题颜色筛选 | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| code | number | 状态码 | +| total | number | 总数 | +| data[].id | string | 模板ID | +| data[].type | number | 模板类型:1大纲完整PPT、4用户模板 | +| data[].coverUrl | string | 封面(需要拼接?token=${token}访问) | +| data[].category | string/null | 类目 | +| data[].style | string/null | 风格 | +| data[].themeColor | string/null | 主题颜色 | +| data[].subject | string | 主题 | +| data[].num | number | 模板页数 | +| data[].createTime | string | 创建时间 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/templates' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"page": 1, "size":10, "filters": { "type": 1 }}' +``` + +> **注意**: +> - 封面图片资源访问,需要在url上拼接`?token=xxx` +> - 模板接口支持国际化,在请求URL上传lang参数,示例:/api/ppt/templates?lang=zh-CN +> - 国际化语种支持:zh,zh-Hant,en,ja,ko,ar,de,fr,it,pt,es,ru + +### 随机PPT模板 + +随机获取若干数量的PPT模版。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/randomTemplates` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| size | number | 是 | 数量 | +| filters.type | number | 是 | 模板类型(必传):1系统模板、4用户自定义模板 | +| filters.category | string | 否 | 类目 | +| filters.style | string | 否 | 风格 | +| filters.themeColor | string | 否 | 主题颜色 | +| filters.neq_id | array | 否 | 排查ID集合(把之前查询返回的id排除) | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| code | number | 状态码 | +| total | number | 总数 | +| data[].id | string | 模板ID | +| data[].type | number | 模板类型:1大纲完整PPT、4用户模板 | +| data[].coverUrl | string | 封面(需要拼接?token=${token}访问) | +| data[].category | string/null | 类目 | +| data[].style | string/null | 风格 | +| data[].themeColor | string/null | 主题颜色 | +| data[].subject | string | 主题 | +| data[].num | number | 模板页数 | +| data[].createTime | string | 创建时间 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/randomTemplates' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"size":10, "filters": { "type": 1 }}' +``` + +> **注意**: +> - 封面图片资源访问,需要在url上拼接`?token=xxx` +> - 模板接口支持国际化,在请求URL上传lang参数,示例:/api/ppt/randomTemplates?lang=zh-CN +> - 国际化语种支持:zh,zh-Hant,en,ja,ko,ar,de,fr,it,pt,es,ru + +## PPT获取与操作 + +### 获取PPT列表 + +分页查询您的PPT作品列表。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/listPptx` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| page | number | 是 | 分页:第几页 | +| size | number | 是 | 分页:每页大小 | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| code | number | 状态码 | +| total | number | 总数 | +| data[].id | string | ppt id | +| data[].subject | string | 主题 | +| data[].coverUrl | string | 封面 | +| data[].templateId | string | 模板ID | +| data[].userId | string | 用户ID | +| data[].userName | string | 用户名称 | +| data[].companyId | number | 公司ID | +| data[].updateTime | string/null | 更新时间 | +| data[].createTime | string | 创建时间 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/listPptx' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"page": 1, "size": 10}' +``` + +### 加载PPT数据 + +加载一个PPT的完整数据内容。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/loadPptx?id=` | +| 请求方法 | GET | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.pptInfo.id | string | ppt id | +| data.pptInfo.subject | string | 主题 | +| data.pptInfo.coverUrl | string | 封面 | +| data.pptInfo.templateId | string | 模板ID | +| data.pptInfo.pptxProperty | string | PPT数据结构(json数据通过gzip压缩base64编码返回) | +| data.pptInfo.userId | string | 用户ID | +| data.pptInfo.userName | string | 用户名称 | +| data.pptInfo.companyId | number | 公司ID | +| data.pptInfo.updateTime | string/null | 更新时间 | +| data.pptInfo.createTime | string | 创建时间 | +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X GET --location 'https://open.docmee.cn/api/ppt/loadPptx?id=xxx' \ +--header 'token: {token}' +``` + +### 加载PPT大纲内容 + +获取生成PPT所使用的大纲内容。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/loadPptxMarkdown` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| id | string | 是 | pptId | +| format | string | 是 | 输出格式:text大纲文本;tree大纲结构树 | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.markdownText | string | 大纲markdown文本(当format为text时返回) | +| data.markdownTree | object | 大纲结构树(当format为tree时返回) | +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/loadPptxMarkdown' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"id": "xxx", "format": "tree"}' +``` + +### 下载PPT + +下载PPT到本地。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/downloadPptx` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| id | string | 是 | pptId | +| refresh | boolean | 否 | 是否刷新(默认false) | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.id | string | pptId | +| data.name | string | 名称 | +| data.subject | string | 主题 | +| data.fileUrl | string | 文件链接(有效期:2小时) | +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/downloadPptx' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"id":"xxx"}' +``` + +### 下载-智能动画PPT + +给PPT自动加上动画再下载到本地。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/downloadWithAnimation?type=1&id=xxx` | +| 请求方法 | GET | + +##### URL请求参数 +| 参数 | 类型 | 描述 | +|------|------|------| +| type | number | 动画类型,1依次展示(默认);2单击展示 | +| id | string | PPT ID | + +##### 响应 +application/octet-stream 文件数据流 + +##### 请求示例 +```bash +curl -X GET --location 'https://open.docmee.cn/api/ppt/downloadWithAnimation?type=1&id=xxx' \ +--header 'token: {token}' +``` + +> **注意**: +> - 该接口会在原有的PPT元素对象上智能添加动画效果(元素入场动画 & 页面切场动画) +> - 动画类型介绍: +> - 1 依次展示,表示上一个元素动画结束后立马展示下一个元素动画 +> - 2 单击展示,表示在内容页,上一项内容展示完成后需要单击才会展示下一项内容,其他页面效果同依次展示。 + +### 更换PPT模板 + +更换PPT的模板。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/updatePptTemplate` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| pptId | string | 是 | ppt id | +| templateId | string | 是 | 模板ID | +| sync | boolean | 否 | 是否同步更新PPT文件(默认false异步更新,速度快) | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.pptId | string | pptId | +| data.templateId | string | 模板ID | +| data.pptxProperty | object | 更换后的pptx结构数据(json) | +| code | number | 状态码 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/updatePptTemplate' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"pptId":"xxx","templateId":"xxx","sync":false}' +``` + +### 更新PPT属性 + +修改PPT的名称或主题。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/updatePptxAttr` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| id | string | 是 | ppt id | +| name | string | 否 | 名称(不能为空则修改) | +| subject | string | 否 | 主题(不能为空则修改) | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/updatePptxAttr' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"id":"xxx","name":"xxx"}' +``` + +### 设置Logo + +设置PPT的LOGO。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/addPptLogo` | +| 请求方法 | POST | +| Content-Type | `multipart/form-data` | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| pptId | string | 是 | PPT ID | +| image | File | 是 | Logo图片文件(png / jpg) | +| position | string | 是 | Logo位置:`TOP_LEFT`、`TOP_CENTER`、`TOP_RIGHT`、`BOTTOM_LEFT`、`BOTTOM_CENTER`、`BOTTOM_RIGHT` | +| pageIndex | integer | 否 | 指定页码(从1开始) | +| pageTypes | number[] | 否 | 指定页面类型数组 | +| marginX | number | 否 | X方向边距 | +| marginY | number | 否 | Y方向边距 | +| scale | number | 是 | Logo缩放比例 | +| rmOrgLogo | boolean | 是 | 是否移除原有Logo | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.fileUrl | string | pptx文件下载地址 | +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/addPptLogo' \ +--header 'Content-Type: multipart/form-data' \ +--header 'token: {token}' \ +--form 'pptId=12354768' \ +--form 'image=@test.png;filename=test.png' \ +--form 'position=TOP_RIGHT' \ +--form 'scale=1' \ +--form 'rmOrgLogo=true' \ +--form 'marginX=0' \ +--form 'marginY=0' +``` + +### 移除Logo + +移除PPT的LOGO。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/removePptLogo` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| pptId | string | 是 | PPT ID | +| pageIndex | integer | 否 | 指定页码(从1开始) | +| pageTypes | number[] | 否 | 指定页面类型数组 0:首页, 1:目录页, 2:章节页, 3:内容页, 4:尾页 | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/removePptLogo' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"pptId":"12354768","pageIndex":1,"pageTypes":[1,2]}' +``` + +### 保存PPT + +保存PPT的修改。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/savePptx` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| id | string | 是 | ppt id | +| drawPptx | boolean | 否 | 是否重新渲染PPT文件并上传 | +| drawCover | boolean | 否 | 是否重新渲染PPT封面并上传 | +| pptxProperty | object | 否 | 修改过后的pptx页面数据结构树 | + +> 如果您只想要重新渲染ppt,您可以传递drawPptx为true。这时您可以传递最新的pptxProperty结构来渲染,如果您没有最新的pptxProperty结构,该字段请不要传递,留空即可。 + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.pptInfo | object | ppt信息(数据同generatePptx生成PPT接口结构) | +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/savePptx' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"id":"xxx","drawPptx":true,"drawCover":true,"pptxProperty":{}}' +``` + +### 删除PPT + +删除指定的PPT。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/delete` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| id | string | 是 | ppt id | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/delete' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"id":"xxx"}' +``` + +## 自定义模板 + +### 上传用户自定义模板 + +上传用户自定义的PPT模板。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/uploadTemplate` | +| 请求方法 | POST | +| Content-Type | `multipart/form-data` | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| type | int | 是 | 类型,用户自定义模板传4(写死) | +| file | File | 是 | 文件(仅支持pptx,幻灯片大小960x540) | +| templateId | string | 否 | 模板ID(更新时传,会覆盖该模板) | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.id | string | 模板ID | +| data.type | number | 模板类型:4用户模板 | +| data.coverUrl | string | 封面(需要拼接token才能访问) | +| data.subject | string | 主题 | +| data.pptxProperty | string | PPT数据结构(json gzip base64) | +| data.num | number | 页码 | +| data.createTime | string | 创建时间 | +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/uploadTemplate' \ +--header 'Content-Type: multipart/form-data' \ +--header 'token: {token}' \ +--form 'type=4' \ +--form 'file=@test.doc;filename=test.doc' +``` + +> **注意**: +> - 模板标准幻灯片大小(16:9)960x540 (33.867x19.05厘米),如果尺寸非标准大小,可在microsoft office中修改步骤:设计 > 幻灯片大小 > 自定义幻灯片大小 => 33.867x19.05厘米 +> - 上传用户自定义模板后,AI会自动标注学习,如果您觉得生成效果有问题,对不上,可以访问下面链接手动纠正AI标注结果:[https://docmee.cn/marker/{templateId}?token={apiKey}](https://docmee.cn/marker/%5C%7BtemplateId%5C%7D?token=%5C%7BapiKey%5C%7D) 请把{templateId}替换成真实的模板ID,{apiKey}替换成你的api-key,示例: +> - 注意:如果是覆盖公共模板,请把token换成Api-Key调用,不然无权限访问。 + +### 下载自定义模板 + +下载用户自定义的模板。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/downloadTemplate` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| id | string | 是 | 模板ID | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.id | string | 模板ID | +| data.subject | string | 主题 | +| data.type | number | 模板类型:4用户模板 | +| data.coverUrl | string | 封面(需要拼接token才能访问) | +| data.fileUrl | string | 模板下载地址(可直接访问) | +| data.createTime | string | 创建时间 | +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/downloadTemplate' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"id": "xxx"}' +``` + +### 删除自定义模板 + +删除用户自定义的模板。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/delTemplateId` | +| 请求方法 | POST | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| id | string | 是 | 模板ID | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/delTemplateId' \ +--header 'Content-Type: application/json' \ +--header 'token: {token}' \ +--data '{"id": "xxx"}' +``` + +### 修改模版属性(名称) + +修改自定义模版的信息(名称)。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/updateTemplate` | +| 请求方法 | POST | + +##### 请求头 +| 头部 | 说明 | +|------|------| +| Api-Key | 在开放平台获取 | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| id | string | 是 | 模板Id | +| name | string | 是 | 模板名称 | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/updateTemplate' \ +--header 'Content-Type: application/json' \ +--header 'Api-Key: {apiKey}' \ +--data '{"id": "xxx", "name": "测试修改111"}' +``` + +### 设置为公共模板 + +将自定义模版设置为Api-Key账号级别的公共模板,但并非平台公共模板。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/updateUserTemplate` | +| 请求方法 | POST | + +##### 请求头 +| 头部 | 说明 | +|------|------| +| Api-Key | 在开放平台获取 | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| templateId | string | 是 | 模板ID | +| isPublic | boolean | 是 | 是否公开(true公开,API-KEY下创建的所有token可以看到) | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/updateUserTemplate' \ +--header 'Content-Type: application/json' \ +--header 'Api-Key: {apiKey}' \ +--data '{"templateId": "xxx", "isPublic": true}' +``` + +## 积分和使用记录 + +### 查询API信息 + +获取当前用户的积分使用情况。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/user/apiInfo` | +| 请求方法 | GET | + +##### 请求头 +| 头部 | 说明 | +|------|------| +| Api-Key | 在开放平台获取 | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.availableCount | number | 可用次数 | +| data.usedCount | number | 已使用次数 | +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X GET --location 'https://open.docmee.cn/api/user/apiInfo' \ +--header 'Api-Key: xxx' +``` + +### 查询积分使用记录 + +获取一段时间内的积分使用记录。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/record/listPage` | +| 请求方法 | POST | + +##### 请求头 +| 头部 | 说明 | +|------|------| +| Api-Key | 在开放平台获取 | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| page | number | 是 | 分页:第几页 | +| size | number | 是 | 分页:每页大小 | +| type | number | 否 | 类型:1 PPT生成;2 模板上传;(默认全部) | +| uid | string | 否 | 第三方用户ID | +| startDate | string | 是 | 查询开始时间(必须) | +| endDate | string | 是 | 查询结束时间(必须) | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| total | number | 总数 | +| data[].id | string | 记录ID(ppt ID或模板ID) | +| data[].type | number | 记录类型:1 ppt生成; 2 模板上传 | +| data[].amount | number | 消耗积分 | +| data[].uid | string | 第三方用户ID | +| data[].createTime | string | 创建时间 | +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/record/listPage' \ +--header 'Content-Type: application/json' \ +--header 'Api-Key: xxx' \ +--data '{ "page": 1, "size": 100, "type": 1, "startDate": "2025-01-01", "endDate": "2025-12-31" }' +``` + +### 查询记录详情 + +获取一条积分使用记录的详细信息。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/record/getById?id=` | +| 请求方法 | GET | + +##### 参数 +id参数: 记录ID + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data.id | string | PPT Id或模板ID | +| data.name | string | 名称 | +| data.subject | string | 主题 | +| data.coverUrl | string | 封面图(需在URL拼接token才能访问) | +| data.fileUrl | string | pptx文件(需在URL拼接token才能访问) | +| data.templateId | string | type=1的PPT记录才有templateId字段 | +| data.userId | string | 用户ID | +| data.userName | string | 用户名称 | +| data.updateTime | string | 修改时间 | +| data.createTime | string | 创建时间 | +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X GET --location 'https://open.docmee.cn/api/record/getById?id=xxx' \ +--header 'Api-Key: xxx' +``` + +### 按小时统计积分使用 + +按小时统计积分使用情况。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/record/statisticHours` | +| 请求方法 | POST | + +##### 请求头 +| 头部 | 说明 | +|------|------| +| Api-Key | 在开放平台获取 | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| type | number | 否 | 类型:1 生成PPT;2 上传模板;默认全部 | +| uid | string | 否 | 第三方用户ID | +| date | string | 是 | 日期(必须) | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data[].count | number | 使用次数 | +| data[].hour | number | 时间-小时整点(0-24) | +| data[].amount | number | 消耗积分数 | +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/record/statisticHours' \ +--header 'Content-Type: application/json' \ +--header 'Api-Key: xxx' \ +--data '{ "type": 1, "date": "2025-01-01" }' +``` + +### 按天统计积分使用 + +按天统计积分使用情况。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/record/statisticDays` | +| 请求方法 | POST | + +##### 请求头 +| 头部 | 说明 | +|------|------| +| Api-Key | 在开放平台获取 | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| type | number | 否 | 类型:1 生成PPT;2 上传模板;默认全部 | +| uid | string | 否 | 第三方用户ID | +| startDate | string | 是 | 查询开始时间(必须) | +| endDate | string | 是 | 查询结束时间(必须) | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| data[].count | number | 使用次数 | +| data[].date | string | 日期 | +| data[].amount | number | 消耗积分 | +| code | number | 状态码 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/record/statisticDays' \ +--header 'Content-Type: application/json' \ +--header 'Api-Key: xxx' \ +--data '{ "type": 1, "startDate": "2025-01-01", "endDate": "2025-12-31" }' +``` + +### 查询所有PPT列表 + +获取当前Api-Key下一段时间内所有生成的PPT文件。 + +| 属性 | 说明 | +|------|------| +| 接口地址 | `https://open.docmee.cn/api/ppt/listAllPptx` | +| 请求方法 | POST | + +##### 请求头 +| 头部 | 说明 | +|------|------| +| Api-Key | 在开放平台获取 | + +##### 请求参数 +| 参数 | 类型 | 是否必传 | 说明 | +|------|------|----------|------| +| page | number | 是 | 分页 | +| size | number | 是 | 每页大小(最大不超过100) | +| id | string | 否 | ppt id(非必填) | +| uid | string | 否 | 第三方用户ID(非必填) | +| templateId | string | 否 | 模板ID(非必填) | +| startDate | string | 否 | 创建开始时间(非必填) | +| endDate | string | 否 | 创建结束时间(非必填) | +| desc | boolean | 否 | 按时间倒序返回(非必填) | + +##### 响应参数 +| 参数 | 类型 | 说明 | +|------|------|------| +| code | number | 状态码 | +| total | number | 总数 | +| data[].id | string | ppt id | +| data[].subject | string | 主题 | +| data[].coverUrl | string | 封面(需要拼接?token={API-KEY}访问) | +| data[].fileUrl | string | 文件(需要拼接?token={API-KEY}访问) | +| data[].templateId | string | 模板ID | +| data[].userId | string | 用户ID / uid | +| data[].companyId | number | 公司ID | +| data[].createTime | string | 创建时间 | +| message | string | 提示信息 | + +##### 请求示例 +```bash +curl -X POST --location 'https://open.docmee.cn/api/ppt/listAllPptx' \ +--header 'Content-Type: application/json' \ +--header 'Api-Key: xxx' \ +--data '{"page": 1, "size": 10}' +``` + +## 错误码 + +### 接口application/json错误 +```json +{ + "code": 0, + "message": "操作成功" +} +``` + +| 错误码 | 说明 | +|-------|------| +| 0 | 操作成功(正常) | +| -1 | 操作失败(未知错误) | +| 88 | 功能受限(积分已用完或非VIP) | +| 98 | 认证失败(检查token是否过期) | +| 99 | 登录过期 | +| 1001 | 数据不存在 | +| 1002 | 数据访问异常 | +| 1003 | 无权限访问 | +| 1006 | 内容涉及敏感信息 | +| 1009 | AI服务异常 | +| 1010 | 参数错误 | +| 1012 | 请求太频繁,限流 | + +### SEE流式请求错误 + +初始化流式调用时发生错误会返回application/json错误信息: +```json +{ + "code": 1010, + "message": "参数错误:name不能为空" +} +``` + +流式过程中遇到错误,会在流中返回text/event-stream流式错误信息: +```text +data: {"status":-1, "error":"AI模型执行异常"} +``` + +## 特殊说明 + +### PPT前端渲染 + +关于ppt数据结构在前端渲染问题,我们已经把前端代码开源到github: + +- +- +- + +### Markdown规范 + +适用于调用方需要通过自己内容和模型生成PPT内容,调用方根据我们的规范生成markdown内容,然后调用接口合成PPT。 + +#### 规范示例 +```markdown +# 主题 + +## 章节 + +### 页面标题 + +#### 内容标题一 + +这是文本内容... +![图片一](https://xxx.png) + +#### 内容标题二 + +这是文本内容... +![图片二](https://xxx.png) +``` + +#### 规范说明 +- `# 主题`(一级标题,必须包含,只能有一个) +- `## 章节一`(二级标题,目录章节,必须包含,建议6个章节左右) +- `### 页面一`(三级标题,页面标题,每个章节下建议3个左右,30字以内) +- `#### 段落标题一`(四级标题,段落标题,每个页面下建议3个左右,30字以内) +- `- 段落文本内容`(内容长度建议在40-80字之间) + +表格支持: +```markdown +| 季度 | 销售额 | +| -------- | ------ | +| 第一季度 | 380.0 | +| 第二季度 | 826.5 | +| 第三季度 | 512.2 | +| 第四季度 | 674.0 | +``` + +> markdown规范没有定这么死,除了主题(一级标题)和章节(二级标题)必须包含外,其他标题可以没有。 + +完整markdown示例下载:[markdown.md](https://metasign-public.oss-cn-shanghai.aliyuncs.com/docmee/markdown.md) + +生成markdown后,调用generatePptx生成PPT接口,对应outlineContentMarkdown参数。 + +### 接入示例 + +官方提供了多种语言的接入示例: + +- UI接入示例V2: +- UI接入示例V1: +- Python Api接入示例: +- Java Api接入示例: +- Go Api接入示例: +- PHP Api接入示例: + +### PPT生成方式说明 + +官方最推荐使用**版本2**来创作PPT,也是官方应用[文多多AiPPT](https://docmee.cn)中使用的方式。 + +#### 版本2(官方推荐) +1. 调用`createTask`创建任务 +2. 调用`generateContent`生成大纲内容 +3. 调用`generatePptx`生成PPT + +#### 直接生成PPT +- 调用`directGeneratePptx`直接生成PPT接口,支持流式和非流式 + +#### 实时流式生成PPT(版本1) +1. 调用`generateOutline`生成大纲 +2. 调用`generateContent`生成大纲内容同时异步生成PPT +3. 在第二步中接收实时流式json数据过程中判断是否有pptId,存在时调用`asyncPptInfo`获取进度 + +#### 同步流式生成PPT(版本1) +1. 调用`generateOutline`生成大纲 +2. 调用`generateContent`生成大纲内容 +3. 调用`generatePptx`生成PPT + +#### openai chat方式生成PPT +- 调用`/chat/completions`OpenAi-对话生成PPT接口 + +#### 通过markdown生成PPT +- 集成方生成markdown内容,然后调用`generatePptx`生成PPT + +#### MCP +- 兼容Model Context Protocol(MCP)生成PPT \ No newline at end of file diff --git a/_tmp_docmee_aippt_vue b/_tmp_docmee_aippt_vue new file mode 160000 index 0000000..c9bc73c --- /dev/null +++ b/_tmp_docmee_aippt_vue @@ -0,0 +1 @@ +Subproject commit c9bc73c815bb02ad77d84e2d8fa5f52abc892398 diff --git a/backend/.env.example b/backend/.env.example index acaa366..8d70287 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -41,6 +41,22 @@ ANTHROPIC_API_KEY=sk-ant-your-anthropic-key # 免费额度: 1000次/月 TAVILY_API_KEY=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 +# ========== Docmee PPT 生成 API ========== +# Docmee API(用于 AI PPT 生成) +# 申请地址: https://open.docmee.cn/ +# 免费额度: 根据套餐而定 +DOCMEE_API_KEY=your-docmee-api-key +DOCMEE_TRUST_ENV=false +DOCMEE_REQUEST_TIMEOUT_SECONDS=60 +DOCMEE_GENERATE_PPTX_TIMEOUT_SECONDS=600 +DOCMEE_PPTX_POLL_ATTEMPTS=8 +DOCMEE_PPTX_POLL_DELAY_SECONDS=2.0 + +# ========== Unsplash 图片搜索 API ========== +# Unsplash API(用于 PPT 自动配图) +# 申请地址: https://unsplash.com/developers +# 免费额度: 50次/小时 +UNSPLASH_ACCESS_KEY=your-unsplash-access-key # 应用配置 APP_HOST=0.0.0.0 diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 2442558..001094c 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,48 +1,49 @@ """ -Alembic 环境配置 +Alembic environment configuration. """ -from logging.config import fileConfig import asyncio +import os +import sys +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from dotenv import load_dotenv from sqlalchemy import pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config -from alembic import context -import os -import sys -# 添加项目根目录到 Python 路径 -sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +BASE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BASE_DIR)) + +# Prefer backend/.env over unrelated shell environment values. +load_dotenv(dotenv_path=BASE_DIR / ".env", override=True) -# 导入模型基类 -from app.core.database import Base -from app.models import * # noqa: E402, F401 +from app.core.database import Base # noqa: E402 +from app.models import * # noqa: E402,F401,F403 -# Alembic Config 对象 config = context.config -# 设置数据库 URL(使用异步驱动,从环境变量读取) -DATABASE_URL = os.getenv( +database_url = os.getenv( "DATABASE_URL", - "postgresql+asyncpg://postgres:postgres@localhost:5432/ai_teaching" + "postgresql+asyncpg://postgres:postgres@localhost:5432/ai_teaching", ) # 替换为异步驱动 DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://") # ConfigParser 将 % 视为插值,密码中含 %40 等需转义为 %% config.set_main_option("sqlalchemy.url", DATABASE_URL.replace("%", "%%")) -# 设置日志 if config.config_file_name is not None: fileConfig(config.config_file_name) -# 模型元数据 target_metadata = Base.metadata def run_migrations_offline() -> None: - """在离线模式下运行迁移""" - url = config.get_main_option("sqlalchemy.url") + """Run migrations in offline mode.""" + context.configure( - url=url, + url=config.get_main_option("sqlalchemy.url"), target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, @@ -60,7 +61,8 @@ def do_run_migrations(connection: Connection) -> None: async def run_async_migrations() -> None: - """在异步模式下运行迁移""" + """Run migrations in online mode.""" + connectable = async_engine_from_config( config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", @@ -74,7 +76,8 @@ async def run_async_migrations() -> None: def run_migrations_online() -> None: - """在在线模式下运行迁移""" + """Entry point for online migrations.""" + asyncio.run(run_async_migrations()) diff --git a/backend/alembic/versions/20260318_add_outline_payload_to_ppt_outlines.py b/backend/alembic/versions/20260318_add_outline_payload_to_ppt_outlines.py new file mode 100644 index 0000000..5637cea --- /dev/null +++ b/backend/alembic/versions/20260318_add_outline_payload_to_ppt_outlines.py @@ -0,0 +1,32 @@ +"""add outline_payload to ppt_outlines + +Revision ID: 20260318_add_outline_payload +Revises: add_ppt_tables_001 +Create Date: 2026-03-18 23:10:00 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision = "20260318_add_outline_payload" +down_revision = "add_ppt_tables_001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "ppt_outlines", + sa.Column( + "outline_payload", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + server_default=sa.text("'{}'::jsonb"), + ), + ) + + +def downgrade() -> None: + op.drop_column("ppt_outlines", "outline_payload") diff --git a/backend/alembic/versions/add_ppt_tables_001.py b/backend/alembic/versions/add_ppt_tables_001.py new file mode 100644 index 0000000..1a5143d --- /dev/null +++ b/backend/alembic/versions/add_ppt_tables_001.py @@ -0,0 +1,115 @@ +"""add ppt tables + +Revision ID: add_ppt_tables_001 +Revises: 1b6dd46c3766 +Create Date: 2026-03-18 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'add_ppt_tables_001' +down_revision: Union[str, None] = '1b6dd46c3766' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ppt_sessions + op.create_table( + 'ppt_sessions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('current_outline_id', sa.Integer(), nullable=True), + sa.Column('current_result_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_ppt_sessions_user_id', 'ppt_sessions', ['user_id']) + + # ppt_outlines + op.create_table( + 'ppt_outlines', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('session_id', sa.Integer(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('image_urls', postgresql.JSONB(), nullable=True), + sa.Column('template_id', sa.String(length=100), nullable=True), + sa.Column('knowledge_library_ids', postgresql.JSONB(), nullable=True), + sa.Column('is_current', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['ppt_sessions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_ppt_outlines_session_id', 'ppt_outlines', ['session_id']) + + # ppt_messages + op.create_table( + 'ppt_messages', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('session_id', sa.Integer(), nullable=False), + sa.Column('role', sa.String(length=20), nullable=False), + sa.Column('message_type', sa.String(length=20), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('metadata', postgresql.JSONB(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['ppt_sessions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_ppt_messages_session_id', 'ppt_messages', ['session_id']) + + # ppt_results + op.create_table( + 'ppt_results', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('session_id', sa.Integer(), nullable=False), + sa.Column('outline_id', sa.Integer(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('is_current', sa.Boolean(), nullable=False), + sa.Column('template_id', sa.String(length=100), nullable=True), + sa.Column('docmee_ppt_id', sa.String(length=200), nullable=True), + sa.Column('source_pptx_property', sa.Text(), nullable=True), + sa.Column('edited_pptx_property', sa.Text(), nullable=True), + sa.Column('file_url', sa.String(length=500), nullable=True), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('current_page', sa.Integer(), nullable=False), + sa.Column('total_pages', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['session_id'], ['ppt_sessions.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['outline_id'], ['ppt_outlines.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_ppt_results_session_id', 'ppt_results', ['session_id']) + + # 添加ppt_sessions的外键(延迟创建,因为依赖ppt_outlines和ppt_results) + op.create_foreign_key( + 'fk_ppt_sessions_current_outline', + 'ppt_sessions', 'ppt_outlines', + ['current_outline_id'], ['id'], + ondelete='SET NULL' + ) + op.create_foreign_key( + 'fk_ppt_sessions_current_result', + 'ppt_sessions', 'ppt_results', + ['current_result_id'], ['id'], + ondelete='SET NULL' + ) + + +def downgrade() -> None: + op.drop_constraint('fk_ppt_sessions_current_result', 'ppt_sessions', type_='foreignkey') + op.drop_constraint('fk_ppt_sessions_current_outline', 'ppt_sessions', type_='foreignkey') + op.drop_table('ppt_results') + op.drop_table('ppt_messages') + op.drop_table('ppt_outlines') + op.drop_table('ppt_sessions') diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 80c5324..6911195 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -5,6 +5,7 @@ from app.api import auth, courseware, chat, knowledge, libraries, data_analysis from app.api import html_upload, html_chat, html_export from app.api import lesson_plan, question_generate, mindmap +from app.api import ppt api_router = APIRouter() @@ -20,6 +21,7 @@ api_router.include_router(html_export.router, prefix="/html", tags=["html"]) api_router.include_router(lesson_plan.router) api_router.include_router(question_generate.router) +api_router.include_router(ppt.router) api_router.include_router(mindmap.router) __all__ = ["api_router"] diff --git a/backend/app/api/ppt.py b/backend/app/api/ppt.py new file mode 100644 index 0000000..9a5be97 --- /dev/null +++ b/backend/app/api/ppt.py @@ -0,0 +1,825 @@ +""" +PPT API 路由 + +提供会话管理、流式大纲生成、PPT生成、编辑快照、版本管理等接口。 +所有接口校验JWT,按user_id鉴权。 +""" +import json +import logging +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from fastapi.responses import StreamingResponse +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.auth import get_current_user +from app.core.database import get_db +from app.models.user import User +from app.models.ppt_session import PptSession +from app.models.ppt_outline import PptOutline +from app.models.ppt_message import PptMessage +from app.models.ppt_result import PptResult +from app.schemas.ppt import ( + PptSessionCreate, PptSessionBrief, PptSessionDetail, + PptOutlineBrief, PptResultBrief, PptResultDetail, + OutlineStreamRequest, OutlineApproveRequest, + PptGenerateRequest, PptModifyRequest, + EditSnapshotRequest, VersionSummary, PptTemplate, +) +from app.services.ppt.docmee_client import describe_exception, docmee_client +from app.services.ppt.nodes import ( + generate_outline_streaming, modify_slide_json, +) +from app.services.ppt.image_search import auto_assign_images +from app.services.ppt.outline_payload import ( + markdown_to_outline_payload, + normalize_outline_payload, + outline_payload_has_renderable_content, + payload_to_docmee_markdown, +) +from app.services.ppt.state import PptAgentState + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/ppt", tags=["ppt"]) + + +# ========== 辅助函数 ========== + +def sse_event(data: dict) -> str: + return f"data: {json.dumps(data, ensure_ascii=False)}\n\n" + + +async def _get_session_or_404( + session_id: int, user_id: int, db: AsyncSession +) -> PptSession: + result = await db.execute( + select(PptSession).where( + PptSession.id == session_id, + PptSession.user_id == user_id, + ) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="会话不存在") + return session + + +# ========== 模板 ========== + +@router.get("/templates") +async def get_templates( + page: int = 1, + size: int = 20, + user: User = Depends(get_current_user), +): + """获取Docmee模板列表""" + try: + data = await docmee_client.get_templates(page=page, size=size) + token = await docmee_client._ensure_token() + templates = [] + for t in data.get("data", []): + cover = t.get("coverUrl", "") + if cover and "?" not in cover: + cover = f"{cover}?token={token}" + templates.append(PptTemplate( + id=str(t.get("id", "")), + title=t.get("subject"), + cover_url=cover, + category=t.get("category"), + )) + return {"total": data.get("total", 0), "templates": templates} + except Exception as e: + logger.error(f"获取模板失败: {e}") + raise HTTPException(status_code=502, detail=f"获取模板失败: {e}") + + +# ========== 会话管理 ========== + +@router.post("/sessions", response_model=PptSessionBrief) +async def create_session( + body: PptSessionCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + session = PptSession(user_id=user.id, title=body.title) + db.add(session) + await db.flush() + await db.refresh(session) + return session + + +@router.get("/sessions", response_model=list[PptSessionBrief]) +async def list_sessions( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(PptSession) + .where(PptSession.user_id == user.id) + .order_by(PptSession.updated_at.desc()) + ) + return result.scalars().all() + + +@router.delete("/sessions/{session_id}") +async def delete_session( + session_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """删除PPT会话及其关联数据""" + session = await _get_session_or_404(session_id, user.id, db) + await db.delete(session) + await db.commit() + return {"detail": "已删除"} + + +@router.get("/sessions/{session_id}") +async def get_session_detail( + session_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(PptSession) + .options( + selectinload(PptSession.messages), + selectinload(PptSession.outlines), + selectinload(PptSession.results), + ) + .where(PptSession.id == session_id, PptSession.user_id == user.id) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="会话不存在") + + results_brief = [] + for r in session.results: + results_brief.append(PptResultBrief( + id=r.id, version=r.version, is_current=r.is_current, + template_id=r.template_id, docmee_ppt_id=r.docmee_ppt_id, + file_url=r.file_url, status=r.status, + current_page=r.current_page, total_pages=r.total_pages, + has_edit_snapshot=bool(r.edited_pptx_property), + created_at=r.created_at, completed_at=r.completed_at, + )) + + return PptSessionDetail( + id=session.id, title=session.title, status=session.status, + current_outline_id=session.current_outline_id, + current_result_id=session.current_result_id, + messages=session.messages, + outlines=session.outlines, + results=results_brief, + created_at=session.created_at, updated_at=session.updated_at, + ) + + +# ========== 大纲生成与审批 ========== + +@router.post("/stream/outline") +async def stream_outline( + body: OutlineStreamRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """流式生成大纲""" + session = await _get_session_or_404(body.session_id, user.id, db) + + # 保存用户消息 + user_msg = PptMessage( + session_id=session.id, role="user", + message_type="text", content=body.user_input, + ) + db.add(user_msg) + session.status = "generating_outline" + await db.flush() + + # 构建状态 + state: PptAgentState = { + "session_id": session.id, + "user_id": user.id, + "messages": [], + "user_input": body.user_input, + "selected_library_ids": body.knowledge_library_ids, + "retrieved_context": "", + "template_id": body.template_id, + "outline_markdown": "", + "outline_id": None, + "outline_approved": False, + "image_urls": {}, + "result_id": None, + "docmee_task_id": None, + "next_action": "generate_outline", + "error_message": "", + } + + # 如果有知识库,先检索 + if body.knowledge_library_ids: + from app.services.ppt.nodes import retrieve_knowledge + retrieval_result = await retrieve_knowledge(state) + state["retrieved_context"] = retrieval_result.get("retrieved_context", "") + + async def event_generator(): + try: + yield sse_event({"type": "meta", "session_id": session.id}) + + # 流式生成大纲 + full_outline = "" + async for chunk in generate_outline_streaming(state): + full_outline += chunk + yield sse_event({"type": "outline_chunk", "content": chunk}) + + # 自动配图(不阻塞) + image_urls = await auto_assign_images(full_outline) + outline_payload = markdown_to_outline_payload(full_outline, image_urls=image_urls) + + # 计算版本号 + version_result = await db.execute( + select(PptOutline) + .where(PptOutline.session_id == session.id) + .order_by(PptOutline.version.desc()) + ) + last_outline = version_result.scalars().first() + new_version = (last_outline.version + 1) if last_outline else 1 + + # 将旧大纲标记为非当前 + if last_outline: + await db.execute( + update(PptOutline) + .where(PptOutline.session_id == session.id) + .values(is_current=False) + ) + + # 保存大纲 + outline = PptOutline( + session_id=session.id, + version=new_version, + content=full_outline, + image_urls=image_urls, + outline_payload=outline_payload, + template_id=body.template_id, + knowledge_library_ids=body.knowledge_library_ids, + is_current=True, + ) + db.add(outline) + await db.flush() + await db.refresh(outline) + + # 更新会话 + session.current_outline_id = outline.id + session.status = "outline_ready" + + # 保存助手消息 + intro_msg = PptMessage( + session_id=session.id, + role="assistant", + message_type="text", + content="明白了!我已经为你生成了大纲,请查看:", + ) + db.add(intro_msg) + + ai_msg = PptMessage( + session_id=session.id, role="assistant", + message_type="outline", content=full_outline, + metadata_={ + "outline_id": outline.id, + "image_urls": image_urls, + "outline_payload": outline_payload, + }, + ) + db.add(ai_msg) + await db.flush() + + yield sse_event({ + "type": "outline_ready", + "outline_id": outline.id, + "content": full_outline, + "image_urls": image_urls, + "outline_payload": outline_payload, + }) + yield sse_event({"type": "done"}) + + except Exception as e: + logger.error(f"大纲生成失败: {e}") + session.status = "failed" + yield sse_event({"type": "error", "message": str(e)}) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@router.post("/outlines/{outline_id}/approve") +async def approve_outline( + outline_id: int, + body: OutlineApproveRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """审批大纲,可带修改后的内容""" + result = await db.execute( + select(PptOutline) + .join(PptSession, PptOutline.session_id == PptSession.id) + .where(PptOutline.id == outline_id, PptSession.user_id == user.id) + ) + outline = result.scalar_one_or_none() + if not outline: + raise HTTPException(status_code=404, detail="大纲不存在") + + next_image_urls = body.image_urls if body.image_urls is not None else outline.image_urls + + if body.content: + outline.content = body.content + if body.image_urls is not None: + outline.image_urls = body.image_urls + if body.outline_payload is not None: + if outline_payload_has_renderable_content(body.outline_payload): + outline.outline_payload = normalize_outline_payload(body.outline_payload) + outline.content = payload_to_docmee_markdown(outline.outline_payload) + else: + outline.outline_payload = markdown_to_outline_payload( + body.content or outline.content, + image_urls=next_image_urls, + ) + outline.content = payload_to_docmee_markdown(outline.outline_payload) + + session = await _get_session_or_404(outline.session_id, user.id, db) + session.status = "outline_ready" + session.current_outline_id = outline.id + await db.flush() + + return {"message": "大纲已审批", "outline_id": outline.id} + + +# ========== PPT生成 ========== + +@router.post("/stream/generate") +async def stream_generate_ppt( + body: PptGenerateRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """流式生成PPT""" + session = await _get_session_or_404(body.session_id, user.id, db) + + # 获取大纲 + outline_result = await db.execute( + select(PptOutline).where(PptOutline.id == body.outline_id) + ) + outline = outline_result.scalar_one_or_none() + if not outline: + raise HTTPException(status_code=404, detail="大纲不存在") + + template_id = body.template_id or outline.template_id + if not template_id: + raise HTTPException(status_code=400, detail="请选择模板") + + session.status = "generating_ppt" + await db.flush() + + async def event_generator(): + try: + # 创建Docmee任务 + if outline_payload_has_renderable_content(outline.outline_payload): + outline_markdown = payload_to_docmee_markdown(outline.outline_payload or {}) + else: + outline_markdown = outline.content + + if not outline_markdown.strip() or outline_markdown.strip() == f"# {(outline.outline_payload or {}).get('title', '').strip()}": + outline_markdown = outline.content + logger.info( + "Generating PPT session=%s outline=%s template=%s markdown_len=%s", + session.id, + outline.id, + template_id, + len(outline_markdown or ""), + ) + task_id = await docmee_client.create_task( + content=outline_markdown, task_type=7 + ) + logger.info("Docmee task created session=%s task_id=%s", session.id, task_id) + + # 计算版本号 + ver_result = await db.execute( + select(PptResult) + .where(PptResult.session_id == session.id) + .order_by(PptResult.version.desc()) + ) + last_result = ver_result.scalars().first() + new_version = (last_result.version + 1) if last_result else 1 + + # 将旧结果标记为非当前 + if last_result: + await db.execute( + update(PptResult) + .where(PptResult.session_id == session.id) + .values(is_current=False) + ) + + # 创建结果记录 + ppt_result = PptResult( + session_id=session.id, + outline_id=outline.id, + version=new_version, + is_current=True, + template_id=template_id, + status="generating", + ) + db.add(ppt_result) + await db.flush() + await db.refresh(ppt_result) + + yield sse_event({ + "type": "progress", + "current": 0, "total": 0, + "result_id": ppt_result.id, + }) + + # 调用Docmee生成PPT + ppt_info = await docmee_client.generate_pptx( + task_id=task_id, + template_id=template_id, + markdown=outline_markdown, + ) + logger.info( + "Docmee generatePptx returned session=%s task_id=%s keys=%s", + session.id, + task_id, + sorted((ppt_info or {}).keys()), + ) + + ppt_info = await docmee_client.finalize_ppt_info(ppt_info) + ppt_id = ppt_info.get("id", "") + pptx_property = ppt_info.get("pptxProperty") or "" + logger.info( + "Docmee finalized session=%s ppt_id=%s has_pptx=%s has_file_url=%s", + session.id, + ppt_id or "", + bool(pptx_property), + bool(ppt_info.get("fileUrl")), + ) + + if not ppt_id: + raise RuntimeError("PPT 生成失败:未获取到文多多 PPT ID") + + if not pptx_property: + raise RuntimeError("PPT 生成失败:预览数据尚未就绪,请稍后重试") + + # 更新结果 + ppt_result.docmee_ppt_id = ppt_id + ppt_result.source_pptx_property = pptx_property + ppt_result.status = "completed" + ppt_result.completed_at = datetime.now(timezone.utc) + + page_count = docmee_client.get_page_count(pptx_property) + ppt_result.total_pages = page_count + ppt_result.current_page = page_count + + file_url = ppt_info.get("fileUrl") or "" + if not file_url: + try: + file_url = await docmee_client.download_pptx(ppt_id) + except Exception: + file_url = "" + ppt_result.file_url = file_url or None + + session.current_result_id = ppt_result.id + session.status = "preview_ready" + + # 保存消息 + result_msg = PptMessage( + session_id=session.id, role="assistant", + message_type="ppt_result", + content=f"PPT已生成完成,共{ppt_result.total_pages}页", + metadata_={"result_id": ppt_result.id}, + ) + db.add(result_msg) + await db.flush() + + yield sse_event({ + "type": "result_ready", + "result_id": ppt_result.id, + "ppt_id": ppt_id, + "file_url": ppt_result.file_url or "", + "total_pages": ppt_result.total_pages, + "pptx_property": pptx_property, + }) + yield sse_event({"type": "done"}) + + except Exception as e: + logger.exception( + "PPT generation failed session=%s outline=%s error=%s", + session.id, + outline.id, + describe_exception(e), + ) + session.status = "failed" + yield sse_event({"type": "error", "message": describe_exception(e)}) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +# ========== 继续修改PPT ========== + +@router.post("/results/{result_id}/modify") +async def modify_ppt( + result_id: int, + body: PptModifyRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """直接修改当前 PPT JSON,并返回新的预览结构。""" + result = await db.execute( + select(PptResult) + .join(PptSession, PptResult.session_id == PptSession.id) + .where(PptResult.id == result_id, PptSession.user_id == user.id) + ) + ppt_result = result.scalar_one_or_none() + if not ppt_result: + raise HTTPException(status_code=404, detail="PPT结果不存在") + + session = await _get_session_or_404(ppt_result.session_id, user.id, db) + user_msg = PptMessage( + session_id=session.id, role="user", + message_type="text", content=body.instruction, + ) + db.add(user_msg) + session.status = "editing_ppt" + await db.flush() + encoded = ( + body.current_pptx_property + or ppt_result.edited_pptx_property + or ppt_result.source_pptx_property + ) + if not encoded: + raise HTTPException(status_code=400, detail="当前 PPT 预览数据不存在") + + pptx_obj = docmee_client.decompress_pptx_property(encoded) + pages = pptx_obj.get("pages") or [] + if body.slide_index >= len(pages): + raise HTTPException(status_code=400, detail="页码超出范围") + + updated_page = await modify_slide_json( + instruction=body.instruction, + pptx_obj=pptx_obj, + slide_index=body.slide_index, + ) + pages[body.slide_index] = updated_page + pptx_obj["pages"] = pages + + edited_pptx_property = docmee_client.compress_pptx_property(pptx_obj) + ppt_result.edited_pptx_property = edited_pptx_property + ppt_result.current_page = min(body.slide_index + 1, len(pages)) + ppt_result.total_pages = len(pages) + session.status = "preview_ready" + + assistant_msg = PptMessage( + session_id=session.id, + role="assistant", + message_type="text", + content=f"已根据你的要求更新第 {body.slide_index + 1} 页内容。", + metadata_={"result_id": ppt_result.id, "slide_index": body.slide_index}, + ) + db.add(assistant_msg) + await db.flush() + + return { + "result_id": ppt_result.id, + "slide_index": body.slide_index, + "pptx_property": edited_pptx_property, + "total_pages": len(pages), + "message": assistant_msg.content, + } + + +# ========== 编辑快照与结果 ========== + +@router.post("/results/{result_id}/edit-snapshot") +async def save_edit_snapshot( + result_id: int, + body: EditSnapshotRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """保存编辑后的pptx_property快照""" + result = await db.execute( + select(PptResult) + .join(PptSession, PptResult.session_id == PptSession.id) + .where(PptResult.id == result_id, PptSession.user_id == user.id) + ) + ppt_result = result.scalar_one_or_none() + if not ppt_result: + raise HTTPException(status_code=404, detail="PPT结果不存在") + ppt_result.edited_pptx_property = body.edited_pptx_property + await db.flush() + return {"message": "编辑快照已保存", "result_id": result_id} + + +@router.get("/results/{result_id}", response_model=PptResultDetail) +async def get_result_detail( + result_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取PPT结果详情""" + result = await db.execute( + select(PptResult) + .join(PptSession, PptResult.session_id == PptSession.id) + .where(PptResult.id == result_id, PptSession.user_id == user.id) + ) + ppt_result = result.scalar_one_or_none() + if not ppt_result: + raise HTTPException(status_code=404, detail="PPT结果不存在") + return PptResultDetail( + id=ppt_result.id, version=ppt_result.version, + is_current=ppt_result.is_current, template_id=ppt_result.template_id, + docmee_ppt_id=ppt_result.docmee_ppt_id, + file_url=ppt_result.file_url, status=ppt_result.status, + current_page=ppt_result.current_page, total_pages=ppt_result.total_pages, + has_edit_snapshot=bool(ppt_result.edited_pptx_property), + created_at=ppt_result.created_at, completed_at=ppt_result.completed_at, + source_pptx_property=ppt_result.source_pptx_property, + edited_pptx_property=ppt_result.edited_pptx_property, + outline_id=ppt_result.outline_id, + ) + + +@router.post("/results/{result_id}/download") +async def download_result( + result_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取PPT下载地址""" + result = await db.execute( + select(PptResult) + .join(PptSession, PptResult.session_id == PptSession.id) + .where(PptResult.id == result_id, PptSession.user_id == user.id) + ) + ppt_result = result.scalar_one_or_none() + if not ppt_result: + raise HTTPException(status_code=404, detail="PPT结果不存在") + if not ppt_result.docmee_ppt_id: + raise HTTPException(status_code=400, detail="PPT尚未生成完成") + has_latest_edits = bool(getattr(ppt_result, "edited_pptx_property", None)) + try: + if has_latest_edits: + latest_pptx = docmee_client.decompress_pptx_property( + ppt_result.edited_pptx_property + ) + if not latest_pptx: + raise RuntimeError("Failed to decode latest edited PPT data") + sync_error: Exception | None = None + try: + await docmee_client.save_pptx(ppt_result.docmee_ppt_id, latest_pptx) + except Exception as exc: + sync_error = exc + logger.warning( + "Docmee savePptx timed out or failed before download result=%s docmee_ppt_id=%s error=%s", + result_id, + ppt_result.docmee_ppt_id, + describe_exception(exc), + ) + + remote_ppt = await docmee_client.wait_until_ppt_ready( + ppt_result.docmee_ppt_id, + expected_pptx_property=latest_pptx, + ) + if not docmee_client.pptx_property_matches( + remote_ppt.get("pptxProperty"), + latest_pptx, + ): + detail = ( + describe_exception(sync_error) + if sync_error is not None + else "Remote PPT did not reach the latest edited snapshot" + ) + raise HTTPException( + status_code=502, + detail=f"Failed to sync latest edited PPT before download: {detail}", + ) + + ppt_result.source_pptx_property = ( + remote_ppt.get("pptxProperty") or ppt_result.edited_pptx_property + ) + file_url = await docmee_client.download_pptx( + ppt_result.docmee_ppt_id, + refresh=False, + ) + else: + file_url = await docmee_client.download_pptx( + ppt_result.docmee_ppt_id, + refresh=True, + ) + ppt_result.file_url = file_url + await db.flush() + return {"file_url": file_url} + except Exception as e: + if has_latest_edits: + if isinstance(e, HTTPException): + raise e + raise HTTPException( + status_code=502, + detail=( + "Failed to sync latest edited PPT before download: " + f"{describe_exception(e)}" + ), + ) + if ppt_result.file_url: + logger.warning( + "Falling back to stored PPT file_url result=%s docmee_ppt_id=%s error=%s", + result_id, + ppt_result.docmee_ppt_id, + describe_exception(e), + ) + return {"file_url": ppt_result.file_url} + raise HTTPException(status_code=502, detail=f"获取下载地址失败: {e}") + + +# ========== 版本管理 ========== + +@router.get("/sessions/{session_id}/versions", response_model=VersionSummary) +async def get_versions( + session_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取会话的大纲版本和PPT结果版本""" + await _get_session_or_404(session_id, user.id, db) + outlines_result = await db.execute( + select(PptOutline).where(PptOutline.session_id == session_id).order_by(PptOutline.version) + ) + results_result = await db.execute( + select(PptResult).where(PptResult.session_id == session_id).order_by(PptResult.version) + ) + outline_briefs = [PptOutlineBrief.model_validate(o) for o in outlines_result.scalars().all()] + result_briefs = [] + for r in results_result.scalars().all(): + result_briefs.append(PptResultBrief( + id=r.id, version=r.version, is_current=r.is_current, + template_id=r.template_id, docmee_ppt_id=r.docmee_ppt_id, + file_url=r.file_url, status=r.status, + current_page=r.current_page, total_pages=r.total_pages, + has_edit_snapshot=bool(r.edited_pptx_property), + created_at=r.created_at, completed_at=r.completed_at, + )) + return VersionSummary(outline_versions=outline_briefs, result_versions=result_briefs) + + +# ========== 文件上传 ========== + +@router.post("/sessions/{session_id}/upload") +async def upload_session_file( + session_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + file: UploadFile = File(...), +): + """上传参考文件到PPT会话,解析内容后存储""" + await _get_session_or_404(session_id, user.id, db) + + upload_dir = Path("uploads/ppt") + upload_dir.mkdir(parents=True, exist_ok=True) + suffix = Path(file.filename).suffix + dest = upload_dir / f"{uuid.uuid4()}{suffix}" + content_bytes = await file.read() + dest.write_bytes(content_bytes) + + parsed = "" + try: + from app.services.parsers.factory import ParserFactory + result = await ParserFactory.parse_file(str(dest)) + if result and result.chunks: + parsed = "\n\n".join(c.content for c in result.chunks) + except Exception as e: + logger.warning(f"Parse failed for {file.filename}: {e}") + parsed = content_bytes.decode("utf-8", errors="ignore")[:2000] + + # 将解析内容作为消息存入会话 + msg = PptMessage( + session_id=session_id, + role="user", + message_type="file", + content=f"[上传文件: {file.filename}]", + metadata_={"filename": file.filename, "file_path": str(dest), "parsed_preview": parsed[:500]}, + ) + db.add(msg) + await db.commit() + + return { + "filename": file.filename, + "file_path": str(dest), + "parsed_length": len(parsed), + } diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py index c69ffc3..b498cb9 100644 --- a/backend/app/core/__init__.py +++ b/backend/app/core/__init__.py @@ -1,10 +1,16 @@ """ -核心模块导出 +核心模块导出。 """ -# 只导出配置,其他模块按需导入避免循环依赖 -from app.core.config import settings, get_settings -__all__ = [ - "settings", - "get_settings", -] +__all__ = ["settings", "get_settings"] + + +def __getattr__(name: str): + if name in {"settings", "get_settings"}: + from app.core.config import get_settings, settings + + return { + "settings": settings, + "get_settings": get_settings, + }[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index fa797a0..840cc78 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -84,6 +84,21 @@ class Settings(BaseSettings): # 例如 Windows: C:/Windows/Fonts/msyh.ttc DATA_ANALYSIS_FONT_PATH: str = "" + # ========== Docmee PPT 生成 ========== + DOCMEE_API_KEY: str = "" + DOCMEE_BASE_URL: str = "https://docmee.cn" + DOCMEE_TRUST_ENV: bool = False + DOCMEE_REQUEST_TIMEOUT_SECONDS: int = 60 + DOCMEE_GENERATE_PPTX_TIMEOUT_SECONDS: int = 600 + DOCMEE_PPTX_POLL_ATTEMPTS: int = 8 + DOCMEE_PPTX_POLL_DELAY_SECONDS: float = 2.0 + + # ========== Unsplash 图片搜索 ========== + UNSPLASH_ACCESS_KEY: str = "" + + # ========== SiliconFlow AI 生图 ========== + SILICONFLOW_API_KEY: str = "" + class Config: env_file = ".env" env_file_encoding = "utf-8" diff --git a/backend/app/core/database.py b/backend/app/core/database.py index d46639a..3fd073f 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1,24 +1,25 @@ """ -数据库连接和会话管理 +Database connection and session management. """ -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker -from sqlalchemy.orm import DeclarativeBase -from typing import AsyncGenerator import os +from pathlib import Path +from typing import AsyncGenerator + from dotenv import load_dotenv +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase -load_dotenv() +ENV_FILE = Path(__file__).resolve().parents[2] / ".env" +load_dotenv(dotenv_path=ENV_FILE) class Base(DeclarativeBase): - """SQLAlchemy 声明基类""" - pass + """SQLAlchemy declarative base.""" -# 创建异步引擎 DATABASE_URL = os.getenv( "DATABASE_URL", - "postgresql+asyncpg://postgres:postgres@localhost:5432/ai_teaching" + "postgresql+asyncpg://postgres:postgres@localhost:5432/ai_teaching", ) engine = create_async_engine( @@ -29,7 +30,6 @@ class Base(DeclarativeBase): max_overflow=20, ) -# 创建异步会话工厂 AsyncSessionLocal = async_sessionmaker( engine, class_=AsyncSession, @@ -40,7 +40,8 @@ class Base(DeclarativeBase): async def get_db() -> AsyncGenerator[AsyncSession, None]: - """获取数据库会话的依赖函数""" + """Yield a database session for request handling.""" + async with AsyncSessionLocal() as session: try: yield session @@ -53,6 +54,7 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: async def init_db() -> None: - """初始化数据库,创建所有表""" + """Create all tables for local initialization flows.""" + async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 483c755..2b488ed 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,6 +9,10 @@ from app.models.token_blacklist import TokenBlacklist from app.models.lesson_plan import LessonPlan from app.models.lesson_plan_reference import LessonPlanReference +from app.models.ppt_session import PptSession +from app.models.ppt_outline import PptOutline +from app.models.ppt_message import PptMessage +from app.models.ppt_result import PptResult from app.models.enums import ( CoursewareType, CoursewareStatus, @@ -26,6 +30,10 @@ "TokenBlacklist", "LessonPlan", "LessonPlanReference", + "PptSession", + "PptOutline", + "PptMessage", + "PptResult", "CoursewareType", "CoursewareStatus", "ChatRole", diff --git a/backend/app/models/ppt_message.py b/backend/app/models/ppt_message.py new file mode 100644 index 0000000..35b698c --- /dev/null +++ b/backend/app/models/ppt_message.py @@ -0,0 +1,65 @@ +""" +PPT消息模型 +""" +from datetime import datetime, timezone +from sqlalchemy import String, DateTime, ForeignKey, Integer, Text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.database import Base + + +class PptMessage(Base): + """ + PPT会话消息表 + + 记录用户与系统在PPT会话中的消息 + """ + __tablename__ = "ppt_messages" + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + autoincrement=True + ) + session_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("ppt_sessions.id", ondelete="CASCADE"), + nullable=False, + index=True + ) + role: Mapped[str] = mapped_column( + String(20), + nullable=False, + comment="user / assistant / system" + ) + message_type: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="text", + comment="text / outline / ppt_result / error / system" + ) + content: Mapped[str] = mapped_column( + Text, + nullable=False + ) + metadata_: Mapped[dict | None] = mapped_column( + "metadata", + JSONB, + nullable=True, + default=None, + comment="附加元数据" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False + ) + + # 关联关系 + session: Mapped["PptSession"] = relationship( + "PptSession", + back_populates="messages" + ) + + def __repr__(self): + return f"" diff --git a/backend/app/models/ppt_outline.py b/backend/app/models/ppt_outline.py new file mode 100644 index 0000000..b9aa435 --- /dev/null +++ b/backend/app/models/ppt_outline.py @@ -0,0 +1,86 @@ +""" +PPT大纲模型 +""" +from datetime import datetime, timezone +from sqlalchemy import String, DateTime, ForeignKey, Integer, Text, Boolean +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.database import Base + + +class PptOutline(Base): + """ + PPT大纲版本表 + + 存储会话中的大纲版本,支持版本管理和审批流程 + """ + __tablename__ = "ppt_outlines" + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + autoincrement=True + ) + session_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("ppt_sessions.id", ondelete="CASCADE"), + nullable=False, + index=True + ) + version: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=1 + ) + content: Mapped[str] = mapped_column( + Text, + nullable=False + ) + image_urls: Mapped[dict] = mapped_column( + JSONB, + nullable=True, + default=dict, + comment="自动配图结果,格式: {page_index: image_url}" + ) + outline_payload: Mapped[dict] = mapped_column( + JSONB, + nullable=True, + default=dict, + comment="结构化大纲卡片真源数据" + ) + template_id: Mapped[str | None] = mapped_column( + String(100), + nullable=True, + comment="Docmee模板ID" + ) + knowledge_library_ids: Mapped[list] = mapped_column( + JSONB, + nullable=True, + default=list, + comment="关联的知识库ID列表" + ) + is_current: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False + ) + + # 关联关系 + session: Mapped["PptSession"] = relationship( + "PptSession", + back_populates="outlines", + foreign_keys=[session_id] + ) + results: Mapped[list["PptResult"]] = relationship( + "PptResult", + back_populates="outline", + cascade="all, delete-orphan" + ) + + def __repr__(self): + return f"" diff --git a/backend/app/models/ppt_result.py b/backend/app/models/ppt_result.py new file mode 100644 index 0000000..a26793a --- /dev/null +++ b/backend/app/models/ppt_result.py @@ -0,0 +1,109 @@ +""" +PPT结果模型 +""" +from datetime import datetime, timezone +from sqlalchemy import String, DateTime, ForeignKey, Integer, Text, Boolean +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.database import Base + + +class PptResult(Base): + """ + PPT结果版本表 + + 存储PPT生成结果,支持版本管理和编辑快照 + """ + __tablename__ = "ppt_results" + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + autoincrement=True + ) + session_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("ppt_sessions.id", ondelete="CASCADE"), + nullable=False, + index=True + ) + outline_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("ppt_outlines.id", ondelete="CASCADE"), + nullable=False + ) + version: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=1 + ) + is_current: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=True + ) + template_id: Mapped[str | None] = mapped_column( + String(100), + nullable=True, + comment="Docmee模板ID" + ) + docmee_ppt_id: Mapped[str | None] = mapped_column( + String(200), + nullable=True, + comment="Docmee返回的PPT ID" + ) + source_pptx_property: Mapped[str | None] = mapped_column( + Text, + nullable=True, + comment="Docmee返回的原始预览数据" + ) + edited_pptx_property: Mapped[str | None] = mapped_column( + Text, + nullable=True, + comment="前端元素编辑后保存的最新快照" + ) + file_url: Mapped[str | None] = mapped_column( + String(500), + nullable=True, + comment="PPT下载地址" + ) + status: Mapped[str] = mapped_column( + String(50), + nullable=False, + default="generating", + comment="generating / completed / failed" + ) + current_page: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + comment="当前已生成页数" + ) + total_pages: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + comment="总页数" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False + ) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True + ) + + # 关联关系 + session: Mapped["PptSession"] = relationship( + "PptSession", + back_populates="results", + foreign_keys=[session_id] + ) + outline: Mapped["PptOutline"] = relationship( + "PptOutline", + back_populates="results" + ) + + def __repr__(self): + return f"" diff --git a/backend/app/models/ppt_session.py b/backend/app/models/ppt_session.py new file mode 100644 index 0000000..7a869a1 --- /dev/null +++ b/backend/app/models/ppt_session.py @@ -0,0 +1,83 @@ +""" +PPT会话模型 +""" +from datetime import datetime, timezone +from sqlalchemy import String, DateTime, ForeignKey, Integer +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.database import Base + + +class PptSession(Base): + """ + PPT会话表 + + 表示一次完整的PPT创作会话,包含多个消息、大纲版本和PPT结果版本 + """ + __tablename__ = "ppt_sessions" + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + autoincrement=True + ) + user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True + ) + title: Mapped[str] = mapped_column( + String(255), + nullable=False, + default="新建PPT" + ) + status: Mapped[str] = mapped_column( + String(50), + nullable=False, + default="draft" + ) + current_outline_id: Mapped[int | None] = mapped_column( + Integer, + ForeignKey("ppt_outlines.id", ondelete="SET NULL"), + nullable=True + ) + current_result_id: Mapped[int | None] = mapped_column( + Integer, + ForeignKey("ppt_results.id", ondelete="SET NULL"), + nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False + ) + + # 关联关系 + user: Mapped["User"] = relationship("User", back_populates="ppt_sessions") + messages: Mapped[list["PptMessage"]] = relationship( + "PptMessage", + back_populates="session", + cascade="all, delete-orphan", + order_by="PptMessage.created_at" + ) + outlines: Mapped[list["PptOutline"]] = relationship( + "PptOutline", + back_populates="session", + cascade="all, delete-orphan", + foreign_keys="PptOutline.session_id" + ) + results: Mapped[list["PptResult"]] = relationship( + "PptResult", + back_populates="session", + cascade="all, delete-orphan", + foreign_keys="PptResult.session_id" + ) + + def __repr__(self): + return f"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 4606e84..e8e2b4b 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -58,6 +58,11 @@ class User(Base): back_populates="owner", cascade="all, delete-orphan" ) + ppt_sessions: Mapped[list["PptSession"]] = relationship( + "PptSession", + back_populates="user", + cascade="all, delete-orphan" + ) def __repr__(self): return f"" diff --git a/backend/app/schemas/ppt.py b/backend/app/schemas/ppt.py new file mode 100644 index 0000000..3b51c8c --- /dev/null +++ b/backend/app/schemas/ppt.py @@ -0,0 +1,135 @@ +""" +PPT相关请求/响应Schema +""" +from datetime import datetime +from pydantic import BaseModel, Field + + +# ========== 会话 ========== + +class PptSessionCreate(BaseModel): + title: str = Field(default="新建PPT", max_length=255) + + +class PptSessionBrief(BaseModel): + id: int + title: str + status: str + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class PptOutlineBrief(BaseModel): + id: int + version: int + content: str + image_urls: dict | None = None + outline_payload: dict | None = None + template_id: str | None = None + knowledge_library_ids: list | None = None + is_current: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class PptMessageBrief(BaseModel): + id: int + role: str + message_type: str + content: str + metadata_: dict | None = Field(None, alias="metadata_") + created_at: datetime + + model_config = {"from_attributes": True, "populate_by_name": True} + + +class PptResultBrief(BaseModel): + id: int + version: int + is_current: bool + template_id: str | None = None + docmee_ppt_id: str | None = None + file_url: str | None = None + status: str + current_page: int + total_pages: int + has_edit_snapshot: bool = False + created_at: datetime + completed_at: datetime | None = None + + model_config = {"from_attributes": True} + + +class PptResultDetail(PptResultBrief): + source_pptx_property: str | None = None + edited_pptx_property: str | None = None + outline_id: int + + +class PptSessionDetail(BaseModel): + id: int + title: str + status: str + current_outline_id: int | None = None + current_result_id: int | None = None + messages: list[PptMessageBrief] = [] + outlines: list[PptOutlineBrief] = [] + results: list[PptResultBrief] = [] + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +# ========== 大纲 ========== + +class OutlineStreamRequest(BaseModel): + session_id: int + user_input: str = Field(..., min_length=1) + knowledge_library_ids: list[int] = Field(default_factory=list) + template_id: str | None = None + + +class OutlineApproveRequest(BaseModel): + content: str | None = None + image_urls: dict | None = None + outline_payload: dict | None = None + + +# ========== PPT生成 ========== + +class PptGenerateRequest(BaseModel): + session_id: int + outline_id: int + template_id: str | None = None + + +class PptModifyRequest(BaseModel): + instruction: str = Field(..., min_length=1) + slide_index: int = Field(default=0, ge=0) + current_pptx_property: str | None = None + + +# ========== 编辑快照 ========== + +class EditSnapshotRequest(BaseModel): + edited_pptx_property: str + + +# ========== 版本 ========== + +class VersionSummary(BaseModel): + outline_versions: list[PptOutlineBrief] = [] + result_versions: list[PptResultBrief] = [] + + +# ========== 模板 ========== + +class PptTemplate(BaseModel): + id: str + title: str | None = None + cover_url: str | None = None + category: str | None = None diff --git a/backend/app/services/ppt/__init__.py b/backend/app/services/ppt/__init__.py new file mode 100644 index 0000000..5d92ffb --- /dev/null +++ b/backend/app/services/ppt/__init__.py @@ -0,0 +1 @@ +"""PPT生成服务模块""" diff --git a/backend/app/services/ppt/docmee_client.py b/backend/app/services/ppt/docmee_client.py new file mode 100644 index 0000000..92a31a2 --- /dev/null +++ b/backend/app/services/ppt/docmee_client.py @@ -0,0 +1,319 @@ +""" +Docmee API proxy client. +""" +import asyncio +import base64 +import gzip +import json +import logging +from typing import AsyncIterator + +import httpx + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +DOCMEE_OPEN_URL = "https://open.docmee.cn" + + +def describe_exception(exc: Exception) -> str: + """Return a useful exception description even when `str(exc)` is empty.""" + message = str(exc).strip() + if message: + return message + return exc.__class__.__name__ + + +def build_docmee_client_kwargs(timeout_s: float) -> dict: + timeout = httpx.Timeout( + timeout_s, + connect=min(30.0, timeout_s), + read=timeout_s, + write=min(60.0, timeout_s), + pool=min(60.0, timeout_s), + ) + return { + "timeout": timeout, + "trust_env": settings.DOCMEE_TRUST_ENV, + } + + +class DocmeeClient: + """Docmee API wrapper.""" + + def __init__(self): + self._token: str | None = None + + async def _ensure_token(self) -> str: + """Get or refresh API token.""" + if self._token: + return self._token + self._token = await self.create_api_token() + return self._token + + async def create_api_token(self, uid: str = "system") -> str: + """Create API token.""" + async with httpx.AsyncClient(**build_docmee_client_kwargs(30)) as client: + response = await client.post( + f"{DOCMEE_OPEN_URL}/api/user/createApiToken", + headers={ + "Content-Type": "application/json", + "Api-Key": settings.DOCMEE_API_KEY, + }, + json={"uid": uid, "timeOfHours": 2}, + ) + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"Docmee createApiToken failed: {data.get('message')}") + return data["data"]["token"] + + async def _request(self, method: str, path: str, timeout_s: float | None = None, **kwargs) -> dict: + """Perform an authenticated Docmee request.""" + token = await self._ensure_token() + headers = kwargs.pop("headers", {}) + headers["token"] = token + timeout_s = timeout_s or settings.DOCMEE_REQUEST_TIMEOUT_SECONDS + + async with httpx.AsyncClient(**build_docmee_client_kwargs(timeout_s)) as client: + response = await client.request(method, f"{DOCMEE_OPEN_URL}{path}", headers=headers, **kwargs) + data = response.json() + if data.get("code") != 0: + if "token" in str(data.get("message", "")).lower(): + self._token = None + token = await self._ensure_token() + headers["token"] = token + response = await client.request(method, f"{DOCMEE_OPEN_URL}{path}", headers=headers, **kwargs) + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"Docmee API error: {data.get('message')}") + else: + raise RuntimeError(f"Docmee API error: {data.get('message')}") + return data + + async def get_templates(self, page: int = 1, size: int = 20, category: str | None = None) -> dict: + """Get template list.""" + filters = {"type": 1} + if category: + filters["category"] = category + return await self._request( + "POST", + "/api/ppt/templates", + json={"page": page, "size": size, "filters": filters}, + ) + + async def create_task(self, content: str, task_type: int = 1) -> str: + """ + Create a PPT task and return task ID. + + task_type: 1=topic generation, 7=markdown outline + """ + token = await self._ensure_token() + async with httpx.AsyncClient(**build_docmee_client_kwargs(30)) as client: + response = await client.post( + f"{DOCMEE_OPEN_URL}/api/ppt/v2/createTask", + headers={"token": token}, + data={"type": str(task_type), "content": content}, + ) + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"Docmee createTask failed: {data.get('message')}") + return data["data"]["id"] + + async def generate_content_stream(self, task_id: str, **kwargs) -> AsyncIterator[dict]: + """Stream outline generation content.""" + token = await self._ensure_token() + body = {"id": task_id, "stream": True, **kwargs} + async with httpx.AsyncClient(**build_docmee_client_kwargs(120)) as client: + async with client.stream( + "POST", + f"{DOCMEE_OPEN_URL}/api/ppt/v2/generateContent", + headers={"Content-Type": "application/json", "token": token}, + json=body, + ) as response: + async for line in response.aiter_lines(): + line = line.strip() + if not line or line.startswith(":"): + continue + if line.startswith("data:"): + line = line[5:].strip() + try: + yield json.loads(line) + except json.JSONDecodeError: + continue + + async def generate_content(self, task_id: str, **kwargs) -> dict: + """Generate outline content in non-streaming mode.""" + body = {"id": task_id, "stream": False, **kwargs} + return await self._request("POST", "/api/ppt/v2/generateContent", json=body) + + async def update_content_stream(self, task_id: str, markdown: str, question: str = "") -> AsyncIterator[dict]: + """Stream outline update content.""" + token = await self._ensure_token() + body = {"id": task_id, "markdown": markdown, "stream": True} + if question: + body["question"] = question + async with httpx.AsyncClient(**build_docmee_client_kwargs(120)) as client: + async with client.stream( + "POST", + f"{DOCMEE_OPEN_URL}/api/ppt/v2/updateContent", + headers={"Content-Type": "application/json", "token": token}, + json=body, + ) as response: + async for line in response.aiter_lines(): + line = line.strip() + if not line or line.startswith(":"): + continue + if line.startswith("data:"): + line = line[5:].strip() + try: + yield json.loads(line) + except json.JSONDecodeError: + continue + + async def generate_pptx(self, task_id: str, template_id: str, markdown: str) -> dict: + """Generate PPT and return `pptInfo`.""" + data = await self._request( + "POST", + "/api/ppt/v2/generatePptx", + json={"id": task_id, "templateId": template_id, "markdown": markdown}, + timeout_s=settings.DOCMEE_GENERATE_PPTX_TIMEOUT_SECONDS, + ) + return data.get("data", {}).get("pptInfo", {}) + + async def finalize_ppt_info( + self, + ppt_info: dict, + poll_attempts: int | None = None, + poll_delay: float | None = None, + ) -> dict: + """Reload PPT info until `pptxProperty` is available or retries are exhausted.""" + merged = dict(ppt_info or {}) + ppt_id = merged.get("id") + if not ppt_id: + return merged + poll_attempts = poll_attempts if poll_attempts is not None else settings.DOCMEE_PPTX_POLL_ATTEMPTS + poll_delay = poll_delay if poll_delay is not None else settings.DOCMEE_PPTX_POLL_DELAY_SECONDS + + upload_status = (merged.get("extInfo") or {}).get("uploadStatus") + needs_reload = not merged.get("pptxProperty") or upload_status != "ready" + if not needs_reload: + return merged + + for attempt in range(max(poll_attempts, 1)): + loaded = await self.load_pptx(ppt_id) + for key, value in (loaded or {}).items(): + if value not in (None, "", [], {}): + merged[key] = value + + if merged.get("pptxProperty"): + break + + if attempt < poll_attempts - 1 and poll_delay > 0: + await asyncio.sleep(poll_delay) + + return merged + + async def load_pptx(self, ppt_id: str) -> dict: + """Load generated PPT info.""" + data = await self._request("GET", f"/api/ppt/loadPptx?id={ppt_id}", timeout_s=60) + return data.get("data", {}).get("pptInfo", {}) + + @classmethod + def pptx_property_matches(cls, pptx_property: str | dict | None, expected_pptx_property: dict | None) -> bool: + """Check whether the remote pptxProperty matches the expected latest snapshot.""" + if expected_pptx_property is None: + return True + if not pptx_property: + return False + + remote_obj = ( + pptx_property + if isinstance(pptx_property, dict) + else cls.decompress_pptx_property(pptx_property) + ) + return bool(remote_obj) and remote_obj == expected_pptx_property + + async def wait_until_ppt_ready( + self, + ppt_id: str, + expected_pptx_property: dict | None = None, + poll_attempts: int | None = None, + poll_delay: float | None = None, + ) -> dict: + """Poll PPT info until the latest snapshot is uploaded and ready.""" + poll_attempts = poll_attempts if poll_attempts is not None else settings.DOCMEE_PPTX_POLL_ATTEMPTS + poll_delay = poll_delay if poll_delay is not None else settings.DOCMEE_PPTX_POLL_DELAY_SECONDS + latest: dict = {} + + for attempt in range(max(poll_attempts, 1)): + latest = await self.load_pptx(ppt_id) + upload_status = (latest.get("extInfo") or {}).get("uploadStatus") + if upload_status == "ready" and self.pptx_property_matches( + latest.get("pptxProperty"), + expected_pptx_property, + ): + return latest + + if attempt < poll_attempts - 1 and poll_delay > 0: + await asyncio.sleep(poll_delay) + + return latest + + async def download_pptx(self, ppt_id: str, refresh: bool = False) -> str: + """Get PPT download URL.""" + data = await self._request( + "POST", + "/api/ppt/downloadPptx", + json={"id": ppt_id, "refresh": refresh}, + ) + return data.get("data", {}).get("fileUrl", "") + + async def save_pptx(self, ppt_id: str, pptx_property: dict | None = None) -> dict: + """Save edited PPT.""" + body = {"id": ppt_id, "drawPptx": True, "drawCover": True} + if pptx_property: + body["pptxProperty"] = pptx_property + return await self._request("POST", "/api/ppt/savePptx", json=body) + + @staticmethod + def decompress_pptx_property(encoded: str) -> dict: + """Decode pptxProperty: base64 -> gzip -> json.""" + try: + compressed = base64.b64decode(encoded) + decompressed = gzip.decompress(compressed) + return json.loads(decompressed) + except Exception as exc: + logger.error("Failed to decompress pptxProperty: %s", exc) + return {} + + @staticmethod + def compress_pptx_property(obj: dict) -> str: + """Encode pptxProperty: json -> gzip -> base64.""" + try: + json_bytes = json.dumps(obj, ensure_ascii=False).encode("utf-8") + compressed = gzip.compress(json_bytes) + return base64.b64encode(compressed).decode("ascii") + except Exception as exc: + logger.error("Failed to compress pptxProperty: %s", exc) + return "" + + @classmethod + def get_page_count(cls, encoded: str) -> int: + """Support both `pages` and `slides` structures from Docmee.""" + if not encoded: + return 0 + + obj = cls.decompress_pptx_property(encoded) + pages = obj.get("pages") + if isinstance(pages, list): + return len(pages) + + slides = obj.get("slides") + if isinstance(slides, list): + return len(slides) + + return 0 + + +docmee_client = DocmeeClient() diff --git a/backend/app/services/ppt/image_search.py b/backend/app/services/ppt/image_search.py new file mode 100644 index 0000000..8d5239c --- /dev/null +++ b/backend/app/services/ppt/image_search.py @@ -0,0 +1,112 @@ +""" +Automatic image matching for PPT outlines. +""" +import logging +import re + +import httpx +from openai import AsyncOpenAI + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +UNSPLASH_API = "https://api.unsplash.com" + + +def extract_page_titles(markdown: str) -> list[str]: + """Extract per-page titles from markdown outline content.""" + lines = [line.strip() for line in markdown.split("\n") if line.strip()] + level3_titles: list[str] = [] + level2_titles: list[str] = [] + + for line in lines: + match = re.match(r"^###\s+(.+)", line) + if match: + title = match.group(1).strip() + if title: + level3_titles.append(title) + continue + + match = re.match(r"^##\s+(.+)", line) + if match: + title = match.group(1).strip() + if title: + level2_titles.append(title) + + return level3_titles or level2_titles + + +async def translate_to_search_keywords(titles: list[str]) -> list[str]: + """Translate page titles into concise English search keywords.""" + if not titles: + return [] + try: + client = AsyncOpenAI( + api_key=settings.DASHSCOPE_API_KEY, + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", + ) + prompt = ( + "将以下 PPT 页面标题翻译为简短的英文图片搜索关键词(每个标题一行,只输出关键词):\n" + + "\n".join(f"- {title}" for title in titles) + ) + resp = await client.chat.completions.create( + model=settings.LLM_MODEL, + messages=[{"role": "user", "content": prompt}], + temperature=0.3, + max_tokens=500, + ) + text = resp.choices[0].message.content.strip() + return [line.strip().lstrip("- ").strip() for line in text.split("\n") if line.strip()] + except Exception as exc: + logger.warning(f"Failed to translate titles to keywords: {exc}") + return titles + + +async def search_unsplash(keyword: str) -> list[str]: + """Search Unsplash landscape images and return up to 2 URLs.""" + if not settings.UNSPLASH_ACCESS_KEY: + return [] + try: + async with httpx.AsyncClient(timeout=15) as client: + response = await client.get( + f"{UNSPLASH_API}/search/photos", + params={"query": keyword, "orientation": "landscape", "per_page": 2}, + headers={"Authorization": f"Client-ID {settings.UNSPLASH_ACCESS_KEY}"}, + ) + data = response.json() + urls = [] + for item in data.get("results", [])[:2]: + url = item.get("urls", {}).get("regular", "") + if url: + urls.append(url) + return urls + except Exception as exc: + logger.warning(f"Unsplash search failed for '{keyword}': {exc}") + return [] + + +async def auto_assign_images(markdown: str) -> dict[str, list[str]]: + """ + Match outline pages with image candidates. + + Returns: {"0": [url1, url2], "1": [url1, url2], ...} + """ + try: + titles = extract_page_titles(markdown) + if not titles: + return {} + + keywords = await translate_to_search_keywords(titles) + image_urls: dict[str, list[str]] = {} + + for index, keyword in enumerate(keywords): + urls = await search_unsplash(keyword) + if urls: + image_urls[str(index)] = urls + + logger.info("Auto image assignment: %s/%s pages got images", len(image_urls), len(titles)) + return image_urls + except Exception as exc: + logger.error(f"Auto image assignment failed: {exc}") + return {} diff --git a/backend/app/services/ppt/nodes.py b/backend/app/services/ppt/nodes.py new file mode 100644 index 0000000..a7a949d --- /dev/null +++ b/backend/app/services/ppt/nodes.py @@ -0,0 +1,341 @@ +""" +PPT LangGraph 节点实现 + +围绕业务主链路:知识检索 -> 大纲生成 -> 自动配图 -> 审批中断 -> PPT生成 +""" +import copy +import logging +import json +import re +from typing import AsyncIterator + +from langchain_core.messages import AIMessage, HumanMessage +from openai import AsyncOpenAI + +from app.core.config import settings +from app.services.ppt.state import PptAgentState +from app.services.ppt.image_search import auto_assign_images + +logger = logging.getLogger(__name__) + +TITLE_PLACEHOLDER_TYPES = {"title", "ctrtitle", "centeredtitle"} +SUBTITLE_PLACEHOLDER_TYPES = {"subtitle", "ctrsubtitle", "subsubtitle"} +BODY_PLACEHOLDER_TYPES = {"body", "content", "text", "obj"} + + +def _get_llm_client() -> AsyncOpenAI: + return AsyncOpenAI( + api_key=settings.DASHSCOPE_API_KEY, + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", + ) + + +async def retrieve_knowledge(state: PptAgentState) -> dict: + """检索知识库获取相关上下文""" + library_ids = state.get("selected_library_ids", []) + user_input = state.get("user_input", "") + + if not library_ids or not user_input: + return {"retrieved_context": ""} + + try: + from app.services.rag.hybrid_retriever import HybridRetriever + retriever = HybridRetriever(user_id=state["user_id"]) + docs = await retriever.retrieve(user_input, library_ids=library_ids, top_k=5) + context = "\n\n".join(doc.page_content for doc in docs) + return {"retrieved_context": context} + except Exception as e: + logger.warning(f"Knowledge retrieval failed: {e}") + return {"retrieved_context": ""} + + +async def generate_outline_streaming(state: PptAgentState) -> AsyncIterator[str]: + """流式生成大纲,yield每个文本块""" + user_input = state.get("user_input", "") + context = state.get("retrieved_context", "") + template_id = state.get("template_id") + + system_prompt = """你是一个专业的PPT大纲生成助手。根据用户需求生成结构化的PPT大纲。 + +要求: +1. 使用Markdown格式 +2. 一级标题(#)为PPT主题 +3. 二级标题(##)为每页标题 +4. 每页下用要点列表描述内容 +5. 页数控制在8-15页 +6. 内容专业、结构清晰""" + + if context: + system_prompt += f"\n\n参考知识库内容:\n{context}" + + if template_id: + system_prompt += f"\n\n使用模板ID: {template_id}" + + client = _get_llm_client() + stream = await client.chat.completions.create( + model=settings.LLM_MODEL, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"请为以下主题生成PPT大纲:\n{user_input}"}, + ], + temperature=0.7, + stream=True, + ) + + async for chunk in stream: + delta = chunk.choices[0].delta + if delta.content: + yield delta.content + + +async def generate_outline(state: PptAgentState) -> dict: + """非流式生成大纲(用于LangGraph节点)""" + full_text = "" + async for chunk in generate_outline_streaming(state): + full_text += chunk + + return { + "outline_markdown": full_text, + "messages": [AIMessage(content=full_text)], + "next_action": "auto_image", + } + + +async def auto_image_node(state: PptAgentState) -> dict: + """自动配图节点""" + markdown = state.get("outline_markdown", "") + image_urls = await auto_assign_images(markdown) + return { + "image_urls": image_urls, + "next_action": "approve", + } + + +async def approval_node(state: PptAgentState) -> dict: + """审批中断节点 - 等待用户确认大纲""" + return { + "outline_approved": False, + "next_action": "waiting_approval", + } + + +async def modify_outline_streaming(state: PptAgentState) -> AsyncIterator[str]: + """流式修改大纲""" + user_input = state.get("user_input", "") + current_outline = state.get("outline_markdown", "") + + client = _get_llm_client() + stream = await client.chat.completions.create( + model=settings.LLM_MODEL, + messages=[ + {"role": "system", "content": "你是PPT大纲修改助手。根据用户的修改意见,在现有大纲基础上进行调整。保持Markdown格式。"}, + {"role": "user", "content": f"当前大纲:\n{current_outline}\n\n修改要求:\n{user_input}"}, + ], + temperature=0.7, + stream=True, + ) + + async for chunk in stream: + delta = chunk.choices[0].delta + if delta.content: + yield delta.content + + +async def modify_slide_json( + instruction: str, + pptx_obj: dict, + slide_index: int, +) -> dict: + """基于当前单页 JSON 直接修改 PPT 页面结构。""" + pages = pptx_obj.get("pages") or [] + if slide_index < 0 or slide_index >= len(pages): + raise ValueError("slide_index out of range") + + current_page = pages[slide_index] + safe_edit = _extract_safe_text_edit(instruction) + if safe_edit: + updated_page = _update_page_text( + current_page, + target=safe_edit["target"], + replacement_text=safe_edit["replacement_text"], + ) + if updated_page is not None: + return updated_page + + client = _get_llm_client() + response = await client.chat.completions.create( + model=settings.LLM_MODEL, + temperature=0.2, + response_format={"type": "json_object"}, + messages=[ + { + "role": "system", + "content": ( + "你是 PPT JSON 编辑助手。" + "用户会给你一页当前 PPT 的 page JSON 和修改要求。" + "你必须只返回修改后的单页 JSON 对象,不要返回 markdown,不要解释。" + "必须保留页面现有的 id、pid、type、extInfo、children 等结构," + "只做满足要求所需的最小修改,确保结果仍然是合法 JSON。" + ), + }, + { + "role": "user", + "content": json.dumps( + { + "instruction": instruction, + "slide_index": slide_index, + "total_slides": len(pages), + "current_page": current_page, + }, + ensure_ascii=False, + ), + }, + ], + ) + + content = (response.choices[0].message.content or "").strip() + if not content: + raise RuntimeError("LLM 未返回页面 JSON") + + try: + updated_page = json.loads(content) + except json.JSONDecodeError as exc: + raise RuntimeError("LLM 返回的页面 JSON 解析失败") from exc + + if not isinstance(updated_page, dict): + raise RuntimeError("LLM 返回的页面结构无效") + + return updated_page + + +def _extract_safe_text_edit(instruction: str) -> dict | None: + text = (instruction or "").strip() + if not text: + return None + + target_specs = [ + ("subtitle", ["副标题"]), + ("title", ["主标题", "标题"]), + ("body", ["正文", "内容", "文案"]), + ] + + for target, keywords in target_specs: + if target == "title" and "副标题" in text: + continue + replacement_text = _extract_replacement_text(text, keywords) + if replacement_text: + return { + "target": target, + "replacement_text": replacement_text, + } + return None + + +def _extract_replacement_text(instruction: str, keywords: list[str]) -> str | None: + keyword_pattern = "|".join(re.escape(keyword) for keyword in keywords) + patterns = [ + rf"(?:{keyword_pattern}).*?(?:改成|改为|换成|改一下为|写成|替换成)\s*[《“\"]([^》”\"\n]+)[》”\"]", + rf"(?:{keyword_pattern}).*?(?:改成|改为|换成|改一下为|写成|替换成)\s*[::]?\s*([^\n]+)$", + ] + for pattern in patterns: + match = re.search(pattern, instruction, re.IGNORECASE) + if not match: + continue + candidate = match.group(1).strip() + candidate = re.sub(r"[。!!;;,,]+$", "", candidate).strip() + if candidate: + return candidate + return None + + +def _update_page_text(page: dict, target: str, replacement_text: str) -> dict | None: + updated_page = copy.deepcopy(page) + text_nodes = _find_text_nodes(updated_page) + if not text_nodes: + return None + + target_node = _select_text_node_for_target(text_nodes, target) + if target_node is None: + return None + + runs = _collect_text_runs(target_node) + if not runs: + return None + + runs[0]["text"] = replacement_text + for run in runs[1:]: + if "text" in run: + run["text"] = "" + return updated_page + + +def _select_text_node_for_target(text_nodes: list[dict], target: str) -> dict | None: + placeholder_types = [_get_placeholder_type(node) for node in text_nodes] + + if target == "title": + for index, placeholder_type in enumerate(placeholder_types): + if placeholder_type in TITLE_PLACEHOLDER_TYPES: + return text_nodes[index] + return text_nodes[0] if text_nodes else None + + if target == "subtitle": + for index, placeholder_type in enumerate(placeholder_types): + if placeholder_type in SUBTITLE_PLACEHOLDER_TYPES: + return text_nodes[index] + non_title_nodes = [ + node for node, placeholder_type in zip(text_nodes, placeholder_types) + if placeholder_type not in TITLE_PLACEHOLDER_TYPES + ] + return non_title_nodes[0] if non_title_nodes else None + + if target == "body": + for index, placeholder_type in enumerate(placeholder_types): + if placeholder_type in BODY_PLACEHOLDER_TYPES: + return text_nodes[index] + for node, placeholder_type in zip(text_nodes, placeholder_types): + if placeholder_type not in TITLE_PLACEHOLDER_TYPES | SUBTITLE_PLACEHOLDER_TYPES: + return node + return text_nodes[-1] if text_nodes else None + + return None + + +def _get_placeholder_type(node: dict) -> str: + placeholder_type = ( + (((node.get("extInfo") or {}).get("property") or {}).get("placeholder") or {}).get("type") + ) + if not isinstance(placeholder_type, str): + return "" + return placeholder_type.strip().lower() + + +def _find_text_nodes(node: dict) -> list[dict]: + nodes: list[dict] = [] + + def walk(current: dict) -> None: + if not isinstance(current, dict): + return + if current.get("type") in {"text", "freeform"}: + nodes.append(current) + for child in current.get("children") or []: + if isinstance(child, dict): + walk(child) + + walk(node) + return nodes + + +def _collect_text_runs(node: dict) -> list[dict]: + runs: list[dict] = [] + + def walk(current: dict) -> None: + if not isinstance(current, dict): + return + if isinstance(current.get("text"), str): + runs.append(current) + for child in current.get("children") or []: + if isinstance(child, dict): + walk(child) + + walk(node) + return runs diff --git a/backend/app/services/ppt/outline_payload.py b/backend/app/services/ppt/outline_payload.py new file mode 100644 index 0000000..6935cfc --- /dev/null +++ b/backend/app/services/ppt/outline_payload.py @@ -0,0 +1,313 @@ +""" +Structured PPT outline payload helpers. +""" +from __future__ import annotations + +from copy import deepcopy +from typing import Any + + +def build_outline_payload( + title: str, + clarification: dict[str, Any] | None, + sections: list[dict[str, Any]] | None, +) -> dict[str, Any]: + payload = { + "title": title.strip() if title else "", + "clarification": deepcopy(clarification or {}), + "sections": deepcopy(sections or []), + } + return normalize_outline_payload(payload) + + +def normalize_outline_payload(payload: dict[str, Any] | None) -> dict[str, Any]: + safe_payload = deepcopy(payload or {}) + safe_payload["title"] = (safe_payload.get("title") or "").strip() + safe_payload["clarification"] = deepcopy(safe_payload.get("clarification") or {}) + normalized_sections: list[dict[str, Any]] = [] + + for section_index, section in enumerate(safe_payload.get("sections") or [], start=1): + safe_section = deepcopy(section or {}) + normalized_pages: list[dict[str, Any]] = [] + for page_index, page in enumerate(safe_section.get("pages") or [], start=1): + safe_page = deepcopy(page or {}) + safe_page["id"] = safe_page.get("id") or f"page-{page_index}" + safe_page["title"] = safe_page.get("title") or "" + safe_page["subtitle"] = safe_page.get("subtitle") or "" + safe_page["blocks"] = deepcopy(safe_page.get("blocks") or []) + safe_page["image_candidates"] = deepcopy(safe_page.get("image_candidates") or []) + safe_page["selected_image_id"] = safe_page.get("selected_image_id") + safe_page["speaker_notes"] = _normalize_speaker_notes(safe_page) + normalized_pages.append(safe_page) + + normalized_sections.append( + { + "id": safe_section.get("id") or f"section-{section_index}", + "title": safe_section.get("title") or "", + "pages": normalized_pages, + } + ) + + safe_payload["sections"] = normalized_sections + return safe_payload + + +def markdown_to_outline_payload(markdown: str, image_urls: dict[str, Any] | None = None) -> dict[str, Any]: + image_urls = image_urls or {} + lines = [raw_line.strip() for raw_line in (markdown or "").splitlines() if raw_line.strip()] + has_level3_pages = any(line.startswith("### ") for line in lines) + page_heading_prefix = "### " if has_level3_pages else "## " + page_count = sum(1 for line in lines if line.startswith(page_heading_prefix)) + legacy_image_offset = ( + not has_level3_pages + and image_urls.get("0") is not None + and image_urls.get(str(page_count)) is not None + ) + + title = "未命名PPT" + sections: list[dict[str, Any]] = [] + current_section: dict[str, Any] | None = None + current_page: dict[str, Any] | None = None + current_block: dict[str, Any] | None = None + page_index = -1 + + for line in lines: + if line.startswith("# "): + title = line[2:].strip() or title + continue + + if line.startswith("## "): + heading = line[3:].strip() + if has_level3_pages: + current_section = { + "id": f"section-{len(sections) + 1}", + "title": heading, + "pages": [], + } + sections.append(current_section) + current_page = None + current_block = None + else: + current_section = _ensure_default_section(sections) + page_index += 1 + current_page = _create_page(page_index, heading, image_urls, page_count, legacy_image_offset) + current_section["pages"].append(current_page) + current_block = None + continue + + if line.startswith("### "): + current_section = current_section or _ensure_default_section(sections) + page_index += 1 + current_page = _create_page(page_index, line[4:].strip(), image_urls, page_count, legacy_image_offset) + current_section["pages"].append(current_page) + current_block = None + continue + + if line.startswith("#### "): + if current_page is None: + continue + current_block = { + "id": f"{current_page['id']}-block-{len(current_page['blocks']) + 1}", + "title": line[5:].strip(), + "content": [], + } + current_page["blocks"].append(current_block) + continue + + normalized_line = line[2:].strip() if line.startswith("- ") else line + if current_page is None: + continue + if current_block is None: + current_block = { + "id": f"{current_page['id']}-block-{len(current_page['blocks']) + 1}", + "title": "", + "content": [], + } + current_page["blocks"].append(current_block) + current_block["content"].append(normalized_line) + + return build_outline_payload(title, clarification=None, sections=sections) + + +def payload_to_docmee_markdown(payload: dict[str, Any]) -> str: + normalized_payload = normalize_outline_payload(payload) + title = normalized_payload.get("title", "").strip() or "未命名PPT" + lines: list[str] = [f"# {title}", ""] + + sections = normalized_payload.get("sections") or [] + for section in sections: + section_title = (section or {}).get("title", "").strip() + if section_title: + lines.extend([f"## {section_title}", ""]) + + pages = (section or {}).get("pages") or [] + for page in pages: + page_title = (page or {}).get("title", "").strip() or "未命名页面" + lines.extend([f"### {page_title}", ""]) + + subtitle = (page or {}).get("subtitle", "").strip() + if subtitle: + lines.extend([subtitle, ""]) + + blocks = (page or {}).get("blocks") or [] + for block in blocks: + block_title = (block or {}).get("title", "").strip() + if block_title: + lines.extend([f"#### {block_title}", ""]) + + content = (block or {}).get("content", "") + normalized_content = _normalize_block_content(content) + for paragraph in normalized_content: + lines.append(f"- {paragraph}") + if normalized_content: + lines.append("") + + image_markdown = build_page_image_markdown(page) + if image_markdown: + lines.extend([image_markdown, ""]) + + return "\n".join(_trim_trailing_blank_lines(lines)).strip() + + +def build_page_image_markdown(page: dict[str, Any], alt_prefix: str = "配图") -> str: + selected_image_id = (page or {}).get("selected_image_id") + if not selected_image_id: + return "" + + candidates = (page or {}).get("image_candidates") or [] + for index, candidate in enumerate(candidates, start=1): + if candidate.get("id") != selected_image_id: + continue + url = (candidate.get("url") or "").strip() + if not url: + return "" + return f"![{alt_prefix}{index}]({url})" + + return "" + + +def outline_payload_has_renderable_content(payload: dict[str, Any] | None) -> bool: + if not payload or not isinstance(payload, dict): + return False + + for section in payload.get("sections") or []: + for page in (section or {}).get("pages") or []: + if (page or {}).get("title", "").strip(): + return True + for block in (page or {}).get("blocks") or []: + if _normalize_block_content((block or {}).get("content")): + return True + return False + + +def _normalize_block_content(content: Any) -> list[str]: + if content is None: + return [] + if isinstance(content, list): + return [str(item).strip() for item in content if str(item).strip()] + + lines = [line.strip() for line in str(content).splitlines()] + return [line for line in lines if line] + + +def _trim_trailing_blank_lines(lines: list[str]) -> list[str]: + trimmed = list(lines) + while trimmed and trimmed[-1] == "": + trimmed.pop() + return trimmed + + +def _build_page_candidates( + page_index: int, + image_urls: dict[str, Any], + page_count: int, + legacy_image_offset: bool, +) -> list[dict[str, Any]]: + value = image_urls.get(str(page_index + 1)) if legacy_image_offset else image_urls.get(str(page_index)) + if value is None and image_urls.get(str(page_index + 1)) is not None and image_urls.get(str(page_count)) is not None: + value = image_urls.get(str(page_index + 1)) + if not value: + return [] + candidates = value if isinstance(value, list) else [value] + result = [] + for idx, candidate in enumerate(candidates[:2], start=1): + if not candidate: + continue + if isinstance(candidate, dict): + url = candidate.get("url") or "" + candidate_id = candidate.get("id") or f"page-{page_index + 1}-img-{idx}" + else: + url = str(candidate) + candidate_id = f"page-{page_index + 1}-img-{idx}" + if url: + result.append({"id": candidate_id, "url": url}) + return result + + +def _create_page( + page_index: int, + title: str, + image_urls: dict[str, Any], + page_count: int, + legacy_image_offset: bool, +) -> dict[str, Any]: + return { + "id": f"page-{page_index + 1}", + "title": title, + "subtitle": "", + "blocks": [], + "speaker_notes": "", + "image_candidates": _build_page_candidates(page_index, image_urls, page_count, legacy_image_offset), + "selected_image_id": None, + } + + +def _ensure_default_section(sections: list[dict[str, Any]]) -> dict[str, Any]: + if sections: + return sections[0] + section = {"id": "section-1", "title": "内容大纲", "pages": []} + sections.append(section) + return section + + +def build_speaker_notes_for_page(page_title: str, subtitle: str, blocks: list[dict[str, Any]]) -> str: + topic = (page_title or "").strip() or "本页内容" + details: list[str] = [] + + if (subtitle or "").strip(): + details.append(subtitle.strip()) + + for block in blocks or []: + block_title = (block.get("title") or "").strip() + if block_title: + details.append(block_title) + for item in _normalize_block_content(block.get("content"))[:3]: + details.append(item) + if len(details) >= 3: + break + + unique_details: list[str] = [] + for detail in details: + if detail and detail not in unique_details: + unique_details.append(detail) + + if not unique_details: + return f"这一页主要讲{topic},可以先说明核心主题,再自然过渡到下一页。" + + first_detail = unique_details[0] + if len(unique_details) == 1: + return f"这一页主要讲{topic},可以先点出{first_detail},再补充课堂里的关键含义。" + + second_detail = unique_details[1] + return f"这一页主要讲{topic},先说明{first_detail},再结合{second_detail}展开,最后用一句话做好过渡。" + + +def _normalize_speaker_notes(page: dict[str, Any]) -> str: + current_notes = (page.get("speaker_notes") or "").strip() + if current_notes: + return current_notes + return build_speaker_notes_for_page( + page.get("title") or "", + page.get("subtitle") or "", + page.get("blocks") or [], + ) diff --git a/backend/app/services/ppt/state.py b/backend/app/services/ppt/state.py new file mode 100644 index 0000000..6d22f26 --- /dev/null +++ b/backend/app/services/ppt/state.py @@ -0,0 +1,42 @@ +""" +PPT LangGraph 状态定义 +""" +from typing import TypedDict, Annotated +from langgraph.graph.message import add_messages +from langchain_core.messages import BaseMessage + + +class PptAgentState(TypedDict): + """PPT工作流状态""" + # 会话信息 + session_id: int + user_id: int + + # 消息历史 + messages: Annotated[list[BaseMessage], add_messages] + + # 用户输入 + user_input: str + + # 知识库 + selected_library_ids: list[int] + retrieved_context: str + + # 模板 + template_id: str | None + + # 大纲 + outline_markdown: str + outline_id: int | None + outline_approved: bool + + # 配图 + image_urls: dict + + # PPT结果 + result_id: int | None + docmee_task_id: str | None + + # 流程控制 + next_action: str # generate_outline / approve / generate_ppt / modify / done / error + error_message: str diff --git a/backend/app/services/ppt/workflow.py b/backend/app/services/ppt/workflow.py new file mode 100644 index 0000000..8ad184c --- /dev/null +++ b/backend/app/services/ppt/workflow.py @@ -0,0 +1,70 @@ +""" +PPT LangGraph 工作流 + +业务主链路:知识检索 -> 大纲生成 -> 自动配图 -> 审批中断 -> PPT生成 +支持审批中断恢复和继续修改触发新版本。 +""" +import logging + +from langgraph.graph import StateGraph, END +from langgraph.checkpoint.memory import MemorySaver + +from app.services.ppt.state import PptAgentState +from app.services.ppt.nodes import ( + retrieve_knowledge, + generate_outline, + auto_image_node, + approval_node, +) + +logger = logging.getLogger(__name__) + + +def _route_after_approval(state: PptAgentState) -> str: + """审批后路由:已批准则结束,否则等待""" + if state.get("outline_approved"): + return "end" + return "wait" + + +def create_ppt_graph(): + """ + 创建PPT工作流图 + + 流程: + 1. retrieve - 检索知识库 + 2. outline - 生成大纲 + 3. image - 自动配图 + 4. approval - 审批中断(等待用户确认) + """ + workflow = StateGraph(PptAgentState) + + workflow.add_node("retrieve", retrieve_knowledge) + workflow.add_node("outline", generate_outline) + workflow.add_node("image", auto_image_node) + workflow.add_node("approval", approval_node) + + workflow.set_entry_point("retrieve") + workflow.add_edge("retrieve", "outline") + workflow.add_edge("outline", "image") + workflow.add_edge("image", "approval") + workflow.add_edge("approval", END) + + memory = MemorySaver() + return workflow.compile( + checkpointer=memory, + interrupt_after=["approval"], + ) + + +# 全局单例 +_ppt_graph = None + + +def get_ppt_graph(): + """获取PPT工作流图实例""" + global _ppt_graph + if _ppt_graph is None: + _ppt_graph = create_ppt_graph() + logger.info("PPT工作流图初始化完成") + return _ppt_graph diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f4ca42b..f2dd63d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -20,6 +20,8 @@ dependencies = [ "alembic>=1.13.0", "python-dotenv>=1.0.0", "httpx>=0.26.0", + "langgraph>=0.0.20", + "langgraph-checkpoint>=0.0.1", "playwright>=1.45.0", ] diff --git a/backend/tests/test_core_imports.py b/backend/tests/test_core_imports.py new file mode 100644 index 0000000..0dc2903 --- /dev/null +++ b/backend/tests/test_core_imports.py @@ -0,0 +1,27 @@ +import os +import subprocess +import sys +from pathlib import Path +import unittest + + +class CoreImportTests(unittest.TestCase): + def test_importing_database_module_does_not_require_valid_debug_setting(self): + backend_dir = Path(__file__).resolve().parents[1] + env = os.environ.copy() + env["DEBUG"] = "release" + + result = subprocess.run( + [sys.executable, "-c", "from app.core.database import Base; print(Base.__name__)"], + cwd=backend_dir, + env=env, + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertIn("Base", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_docmee_client.py b/backend/tests/test_docmee_client.py new file mode 100644 index 0000000..6032704 --- /dev/null +++ b/backend/tests/test_docmee_client.py @@ -0,0 +1,120 @@ +import os +import unittest +from unittest.mock import AsyncMock + +import httpx + +os.environ["DEBUG"] = "false" + +from app.services.ppt.docmee_client import ( + DocmeeClient, + build_docmee_client_kwargs, + describe_exception, +) + + +class DocmeeClientTests(unittest.IsolatedAsyncioTestCase): + async def test_generate_pptx_uses_extended_timeout(self): + client = DocmeeClient() + client._request = AsyncMock(return_value={"data": {"pptInfo": {"id": "ppt-1"}}}) + + result = await client.generate_pptx("task-1", "tpl-1", "# demo") + + self.assertEqual(result["id"], "ppt-1") + client._request.assert_awaited_once_with( + "POST", + "/api/ppt/v2/generatePptx", + json={"id": "task-1", "templateId": "tpl-1", "markdown": "# demo"}, + timeout_s=600, + ) + + async def test_finalize_ppt_info_loads_missing_pptx_property(self): + client = DocmeeClient() + client.load_pptx = AsyncMock(return_value={ + "id": "ppt-1", + "pptxProperty": "encoded-pptx", + "extInfo": {"uploadStatus": "ready"}, + }) + + result = await client.finalize_ppt_info( + { + "id": "ppt-1", + "fileUrl": "https://example.com/demo.pptx", + "pptxProperty": None, + "extInfo": {"uploadStatus": "processing"}, + }, + poll_attempts=1, + poll_delay=0, + ) + + self.assertEqual(result["pptxProperty"], "encoded-pptx") + self.assertEqual(result["fileUrl"], "https://example.com/demo.pptx") + client.load_pptx.assert_awaited_once_with("ppt-1") + + async def test_finalize_ppt_info_retries_until_property_ready(self): + client = DocmeeClient() + client.load_pptx = AsyncMock(side_effect=[ + {"id": "ppt-1", "extInfo": {"uploadStatus": "processing"}}, + {"id": "ppt-1", "pptxProperty": "encoded-pptx", "extInfo": {"uploadStatus": "ready"}}, + ]) + + result = await client.finalize_ppt_info( + {"id": "ppt-1", "extInfo": {"uploadStatus": "processing"}}, + poll_attempts=2, + poll_delay=0, + ) + + self.assertEqual(result["pptxProperty"], "encoded-pptx") + self.assertEqual(client.load_pptx.await_count, 2) + + async def test_wait_until_ppt_ready_retries_until_snapshot_matches(self): + client = DocmeeClient() + expected = {"pages": [{"title": "latest"}], "width": 960, "height": 540} + client.load_pptx = AsyncMock(side_effect=[ + {"id": "ppt-1", "pptxProperty": None, "extInfo": {"uploadStatus": "processing"}}, + { + "id": "ppt-1", + "pptxProperty": DocmeeClient.compress_pptx_property(expected), + "extInfo": {"uploadStatus": "ready"}, + }, + ]) + + result = await client.wait_until_ppt_ready( + "ppt-1", + expected_pptx_property=expected, + poll_attempts=2, + poll_delay=0, + ) + + self.assertEqual(client.load_pptx.await_count, 2) + self.assertEqual(result["extInfo"]["uploadStatus"], "ready") + + def test_get_page_count_supports_pages_key(self): + encoded = DocmeeClient.compress_pptx_property({ + "pages": [{}, {}, {}], + "width": 960, + "height": 540, + }) + + self.assertEqual(DocmeeClient.get_page_count(encoded), 3) + + def test_pptx_property_matches_decoded_snapshot(self): + expected = {"pages": [{"title": "latest"}], "width": 960, "height": 540} + encoded = DocmeeClient.compress_pptx_property(expected) + + self.assertTrue(DocmeeClient.pptx_property_matches(encoded, expected)) + self.assertFalse(DocmeeClient.pptx_property_matches(encoded, {"pages": []})) + + def test_describe_exception_uses_class_name_when_message_is_empty(self): + exc = httpx.ReadTimeout("") + self.assertEqual(describe_exception(exc), "ReadTimeout") + + def test_build_docmee_client_kwargs_disables_proxy_by_default(self): + kwargs = build_docmee_client_kwargs(timeout_s=600) + + self.assertEqual(kwargs["timeout"].read, 600) + self.assertFalse(kwargs["trust_env"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_ppt_download.py b/backend/tests/test_ppt_download.py new file mode 100644 index 0000000..c2124a8 --- /dev/null +++ b/backend/tests/test_ppt_download.py @@ -0,0 +1,206 @@ +import os +import sys +import types +import unittest +from unittest.mock import AsyncMock, call, patch + +import httpx +from fastapi import HTTPException + +os.environ["DEBUG"] = "false" + +if "langgraph.graph.message" not in sys.modules: + langgraph_module = types.ModuleType("langgraph") + graph_module = types.ModuleType("langgraph.graph") + message_module = types.ModuleType("langgraph.graph.message") + message_module.add_messages = lambda messages, new_messages: messages + new_messages + graph_module.message = message_module + langgraph_module.graph = graph_module + sys.modules["langgraph"] = langgraph_module + sys.modules["langgraph.graph"] = graph_module + sys.modules["langgraph.graph.message"] = message_module + +from app.api.ppt import download_result + + +class _FakeQueryResult: + def __init__(self, value): + self._value = value + + def scalar_one_or_none(self): + return self._value + + +class PptDownloadTests(unittest.IsolatedAsyncioTestCase): + async def test_download_result_saves_latest_edited_snapshot_before_refreshing_url(self): + ppt_result = types.SimpleNamespace( + id=9, + docmee_ppt_id="ppt-9", + file_url="https://old.example.com/demo.pptx", + edited_pptx_property="encoded-edited", + source_pptx_property="encoded-source", + ) + db = types.SimpleNamespace( + execute=AsyncMock(return_value=_FakeQueryResult(ppt_result)), + flush=AsyncMock(), + ) + user = types.SimpleNamespace(id=2) + decoded_pptx = {"pages": [{"title": "latest"}]} + + with patch( + "app.api.ppt.docmee_client.decompress_pptx_property", + return_value=decoded_pptx, + ) as mocked_decompress, patch( + "app.api.ppt.docmee_client.save_pptx", + AsyncMock(return_value={"ok": True}), + ) as mocked_save, patch( + "app.api.ppt.docmee_client.wait_until_ppt_ready", + AsyncMock( + return_value={ + "pptxProperty": "encoded-edited-remote", + "extInfo": {"uploadStatus": "ready"}, + } + ), + ) as mocked_wait, patch( + "app.api.ppt.docmee_client.pptx_property_matches", + return_value=True, + ) as mocked_matches, patch( + "app.api.ppt.docmee_client.download_pptx", + AsyncMock(return_value="https://new.example.com/demo.pptx"), + ) as mocked_download: + result = await download_result(9, user=user, db=db) + + self.assertEqual(result, {"file_url": "https://new.example.com/demo.pptx"}) + mocked_decompress.assert_called_once_with("encoded-edited") + mocked_save.assert_awaited_once_with("ppt-9", decoded_pptx) + mocked_wait.assert_awaited_once_with( + "ppt-9", + expected_pptx_property=decoded_pptx, + ) + mocked_matches.assert_called_once_with("encoded-edited-remote", decoded_pptx) + mocked_download.assert_awaited_once_with("ppt-9", refresh=False) + self.assertEqual(ppt_result.source_pptx_property, "encoded-edited-remote") + self.assertEqual(ppt_result.file_url, "https://new.example.com/demo.pptx") + db.flush.assert_awaited_once() + + async def test_download_result_continues_when_save_times_out_but_remote_snapshot_matches_latest_edits(self): + ppt_result = types.SimpleNamespace( + id=9, + docmee_ppt_id="ppt-9", + file_url="https://old.example.com/demo.pptx", + edited_pptx_property="encoded-edited", + source_pptx_property="encoded-source", + ) + db = types.SimpleNamespace( + execute=AsyncMock(return_value=_FakeQueryResult(ppt_result)), + flush=AsyncMock(), + ) + user = types.SimpleNamespace(id=2) + decoded_pptx = {"pages": [{"title": "latest"}]} + + with patch( + "app.api.ppt.docmee_client.decompress_pptx_property", + return_value=decoded_pptx, + ) as mocked_decompress, patch( + "app.api.ppt.docmee_client.save_pptx", + AsyncMock(side_effect=httpx.ReadTimeout("")), + ) as mocked_save, patch( + "app.api.ppt.docmee_client.wait_until_ppt_ready", + AsyncMock( + return_value={ + "pptxProperty": "encoded-remote-latest", + "extInfo": {"uploadStatus": "ready"}, + } + ), + ) as mocked_wait, patch( + "app.api.ppt.docmee_client.pptx_property_matches", + return_value=True, + ) as mocked_matches, patch( + "app.api.ppt.docmee_client.download_pptx", + AsyncMock(return_value="https://new.example.com/demo.pptx"), + ) as mocked_download: + result = await download_result(9, user=user, db=db) + + self.assertEqual(result, {"file_url": "https://new.example.com/demo.pptx"}) + mocked_decompress.assert_called_once_with("encoded-edited") + mocked_save.assert_awaited_once_with("ppt-9", decoded_pptx) + mocked_wait.assert_awaited_once_with( + "ppt-9", + expected_pptx_property=decoded_pptx, + ) + mocked_matches.assert_called_once_with("encoded-remote-latest", decoded_pptx) + mocked_download.assert_awaited_once_with("ppt-9", refresh=False) + self.assertEqual(ppt_result.source_pptx_property, "encoded-remote-latest") + self.assertEqual(ppt_result.file_url, "https://new.example.com/demo.pptx") + db.flush.assert_awaited_once() + + async def test_download_result_falls_back_to_stored_file_url_when_refresh_fails(self): + ppt_result = types.SimpleNamespace( + id=9, + docmee_ppt_id="ppt-9", + file_url="https://stored.example.com/demo.pptx", + ) + db = types.SimpleNamespace( + execute=AsyncMock(return_value=_FakeQueryResult(ppt_result)), + flush=AsyncMock(), + ) + user = types.SimpleNamespace(id=2) + + with patch( + "app.api.ppt.docmee_client.download_pptx", + AsyncMock(side_effect=RuntimeError("refresh failed")), + ) as mocked_download: + result = await download_result(9, user=user, db=db) + + self.assertEqual(result, {"file_url": "https://stored.example.com/demo.pptx"}) + mocked_download.assert_awaited_once_with("ppt-9", refresh=True) + db.flush.assert_not_awaited() + + async def test_download_result_updates_file_url_when_refresh_succeeds(self): + ppt_result = types.SimpleNamespace( + id=9, + docmee_ppt_id="ppt-9", + file_url="https://old.example.com/demo.pptx", + ) + db = types.SimpleNamespace( + execute=AsyncMock(return_value=_FakeQueryResult(ppt_result)), + flush=AsyncMock(), + ) + user = types.SimpleNamespace(id=2) + + with patch( + "app.api.ppt.docmee_client.download_pptx", + AsyncMock(return_value="https://new.example.com/demo.pptx"), + ) as mocked_download: + result = await download_result(9, user=user, db=db) + + self.assertEqual(result, {"file_url": "https://new.example.com/demo.pptx"}) + self.assertEqual(ppt_result.file_url, "https://new.example.com/demo.pptx") + mocked_download.assert_awaited_once_with("ppt-9", refresh=True) + db.flush.assert_awaited_once() + + async def test_download_result_raises_502_when_no_refresh_or_stored_url_is_available(self): + ppt_result = types.SimpleNamespace( + id=9, + docmee_ppt_id="ppt-9", + file_url=None, + ) + db = types.SimpleNamespace( + execute=AsyncMock(return_value=_FakeQueryResult(ppt_result)), + flush=AsyncMock(), + ) + user = types.SimpleNamespace(id=2) + + with patch( + "app.api.ppt.docmee_client.download_pptx", + AsyncMock(side_effect=RuntimeError("refresh failed")), + ): + with self.assertRaises(HTTPException) as ctx: + await download_result(9, user=user, db=db) + + self.assertEqual(ctx.exception.status_code, 502) + self.assertIn("获取下载地址失败", ctx.exception.detail) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_ppt_outline_payload.py b/backend/tests/test_ppt_outline_payload.py new file mode 100644 index 0000000..7d0138b --- /dev/null +++ b/backend/tests/test_ppt_outline_payload.py @@ -0,0 +1,217 @@ +import unittest + +from app.services.ppt.outline_payload import ( + build_page_image_markdown, + markdown_to_outline_payload, + payload_to_docmee_markdown, +) + + +class OutlinePayloadTests(unittest.TestCase): + def test_markdown_to_outline_payload_adds_speaker_notes_per_page(self): + markdown = """# 中国传统文化 + +## 第 1 页:课程封面与导入 +- 主题:中国传统文化——以建筑文化为例 +- 授课对象:本科生通识课程 + +## 第 2 页:中国传统文化概览 +- 文化的定义与构成:物质文化、制度文化、精神文化 +- 本次课程切入点:为何选择“建筑”作为理解传统文化的钥匙 +""" + + payload = markdown_to_outline_payload(markdown, image_urls={}) + + pages = payload["sections"][0]["pages"] + self.assertEqual(len(pages), 2) + self.assertTrue(pages[0]["speaker_notes"]) + self.assertIn("第 1 页:课程封面与导入", pages[0]["speaker_notes"]) + self.assertTrue(pages[1]["speaker_notes"]) + self.assertIn("中国传统文化概览", pages[1]["speaker_notes"]) + + def test_payload_to_docmee_markdown_includes_selected_image_with_docmee_syntax(self): + payload = { + "title": "中国传统文化", + "sections": [ + { + "title": "章节一", + "pages": [ + { + "title": "页面一", + "subtitle": "副标题", + "blocks": [ + {"title": "段落标题一", "content": ["段落文本内容", "段落文本内容2"]}, + ], + "image_candidates": [ + {"id": "img-a", "url": "https://img.example.com/a.png"}, + {"id": "img-b", "url": "https://img.example.com/b.png"}, + ], + "selected_image_id": "img-b", + "speaker_notes": "这一页重点讲图片与内容之间的对应关系。", + } + ], + } + ], + } + + markdown = payload_to_docmee_markdown(payload) + + self.assertIn("# 中国传统文化", markdown) + self.assertIn("## 章节一", markdown) + self.assertIn("### 页面一", markdown) + self.assertIn("#### 段落标题一", markdown) + self.assertIn("- 段落文本内容", markdown) + self.assertIn("![配图2](https://img.example.com/b.png)", markdown) + self.assertNotIn("演讲备注", markdown) + self.assertNotIn("这一页重点讲图片与内容之间的对应关系。", markdown) + + def test_payload_to_docmee_markdown_omits_image_when_user_does_not_select_one(self): + payload = { + "title": "中国传统文化", + "sections": [ + { + "title": "章节一", + "pages": [ + { + "title": "页面一", + "blocks": [{"title": "段落标题一", "content": "段落文本内容"}], + "image_candidates": [ + {"id": "img-a", "url": "https://img.example.com/a.png"}, + {"id": "img-b", "url": "https://img.example.com/b.png"}, + ], + "selected_image_id": None, + } + ], + } + ], + } + + markdown = payload_to_docmee_markdown(payload) + + self.assertNotIn("![配图", markdown) + + def test_build_page_image_markdown_returns_empty_for_invalid_selection(self): + page = { + "image_candidates": [{"id": "img-a", "url": "https://img.example.com/a.png"}], + "selected_image_id": "missing", + } + + self.assertEqual(build_page_image_markdown(page), "") + + def test_markdown_to_outline_payload_supports_flat_page_markdown(self): + markdown = """# 中国传统文化 + +## 第 1 页:封面与课程信息 +- 主标题:中国传统文化 +- 适用对象:本科生 + +## 第 2 页:教学目标与课程导入 +- 核心目标:建立整体认识 +- 重点聚焦:中国建筑与瓷器文化 +""" + + payload = markdown_to_outline_payload( + markdown, + image_urls={ + "0": ["https://img.example.com/cover-a.png", "https://img.example.com/cover-b.png"], + "1": ["https://img.example.com/goal-a.png", "https://img.example.com/goal-b.png"], + }, + ) + + self.assertEqual(payload["title"], "中国传统文化") + self.assertEqual(len(payload["sections"]), 1) + self.assertEqual(payload["sections"][0]["title"], "内容大纲") + self.assertEqual(len(payload["sections"][0]["pages"]), 2) + + first_page = payload["sections"][0]["pages"][0] + self.assertEqual(first_page["title"], "第 1 页:封面与课程信息") + self.assertEqual(len(first_page["blocks"]), 1) + self.assertEqual( + first_page["blocks"][0]["content"], + ["主标题:中国传统文化", "适用对象:本科生"], + ) + self.assertEqual(len(first_page["image_candidates"]), 2) + + def test_payload_to_docmee_markdown_keeps_page_content_for_flat_payload(self): + payload = { + "title": "中国传统文化", + "sections": [ + { + "title": "内容大纲", + "pages": [ + { + "title": "第 1 页:封面与课程信息", + "blocks": [{"title": "", "content": ["主标题:中国传统文化"]}], + "image_candidates": [ + {"id": "img-a", "url": "https://img.example.com/a.png"}, + {"id": "img-b", "url": "https://img.example.com/b.png"}, + ], + "selected_image_id": "img-a", + "speaker_notes": "先介绍主题,再快速点出本页课堂目标。", + } + ], + } + ], + } + + markdown = payload_to_docmee_markdown(payload) + + self.assertIn("## 内容大纲", markdown) + self.assertIn("### 第 1 页:封面与课程信息", markdown) + self.assertIn("- 主标题:中国传统文化", markdown) + self.assertIn("![配图1](https://img.example.com/a.png)", markdown) + self.assertNotIn("先介绍主题,再快速点出本页课堂目标。", markdown) + + def test_payload_to_docmee_markdown_does_not_mutate_existing_speaker_notes(self): + payload = { + "title": "中国传统文化", + "sections": [ + { + "title": "内容大纲", + "pages": [ + { + "title": "第 1 页:课程封面与课程信息", + "subtitle": "", + "blocks": [{"title": "", "content": ["主标题:中国传统文化"]}], + "speaker_notes": "先打招呼,再抛出为什么要从建筑理解文化。", + "image_candidates": [], + "selected_image_id": None, + } + ], + } + ], + } + + _ = payload_to_docmee_markdown(payload) + + self.assertEqual( + payload["sections"][0]["pages"][0]["speaker_notes"], + "先打招呼,再抛出为什么要从建筑理解文化。", + ) + + def test_markdown_to_outline_payload_handles_legacy_shifted_image_indexes(self): + markdown = """# 中国传统文化 + +## 第 1 页:封面与课程信息 +- 主标题:中国传统文化 + +## 第 2 页:教学目标与课程导入 +- 核心目标:建立整体认识 +""" + + payload = markdown_to_outline_payload( + markdown, + image_urls={ + "0": ["https://img.example.com/title-a.png", "https://img.example.com/title-b.png"], + "1": ["https://img.example.com/page1-a.png", "https://img.example.com/page1-b.png"], + "2": ["https://img.example.com/page2-a.png", "https://img.example.com/page2-b.png"], + }, + ) + + pages = payload["sections"][0]["pages"] + self.assertEqual(pages[0]["image_candidates"][0]["url"], "https://img.example.com/page1-a.png") + self.assertEqual(pages[1]["image_candidates"][0]["url"], "https://img.example.com/page2-a.png") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_ppt_slide_editing.py b/backend/tests/test_ppt_slide_editing.py new file mode 100644 index 0000000..2255edf --- /dev/null +++ b/backend/tests/test_ppt_slide_editing.py @@ -0,0 +1,195 @@ +import os +import json +import sys +import types +import unittest +from unittest.mock import patch + +os.environ["DEBUG"] = "false" + +if "langgraph.graph.message" not in sys.modules: + langgraph_module = types.ModuleType("langgraph") + graph_module = types.ModuleType("langgraph.graph") + message_module = types.ModuleType("langgraph.graph.message") + message_module.add_messages = lambda messages, new_messages: messages + new_messages + graph_module.message = message_module + langgraph_module.graph = graph_module + sys.modules["langgraph"] = langgraph_module + sys.modules["langgraph.graph"] = graph_module + sys.modules["langgraph.graph.message"] = message_module + +from app.services.ppt.nodes import modify_slide_json + + +def _make_text_shape(shape_id: str, placeholder_type: str | None, text: str) -> dict: + property_payload = { + "anchor": [0, 0, 100, 20], + } + if placeholder_type: + property_payload["placeholder"] = {"type": placeholder_type} + + return { + "id": shape_id, + "pid": "page-1", + "type": "text", + "extInfo": {"property": property_payload}, + "children": [ + { + "id": f"{shape_id}-p-1", + "pid": shape_id, + "type": "paragraph", + "extInfo": {"property": {"lineSpacing": 100}}, + "children": [ + { + "id": f"{shape_id}-r-1", + "pid": f"{shape_id}-p-1", + "type": "run", + "text": text, + "extInfo": {"property": {"fontSize": 24}}, + } + ], + } + ], + } + + +def _make_sample_page() -> dict: + return { + "id": "page-1", + "type": "slide", + "extInfo": {"slideMasterIdx": 0, "background": {"color": "#ffffff"}}, + "children": [ + _make_text_shape("title-shape", "title", "中国文化"), + _make_text_shape("subtitle-shape", "subTitle", "传统与现代的对话"), + _make_text_shape("body-shape", "body", "这里还有副标题和正文"), + ], + } + + +def _make_fake_llm_client(page: dict): + class _FakeCompletions: + async def create(self, **kwargs): + return types.SimpleNamespace( + choices=[ + types.SimpleNamespace( + message=types.SimpleNamespace(content=json.dumps(page, ensure_ascii=False)) + ) + ] + ) + + class _FakeChat: + def __init__(self): + self.completions = _FakeCompletions() + + return types.SimpleNamespace(chat=_FakeChat()) + + +class PptSlideEditingTests(unittest.IsolatedAsyncioTestCase): + async def test_modify_slide_json_updates_title_without_rebuilding_page(self): + page = _make_sample_page() + pptx_obj = { + "width": 960, + "height": 540, + "pages": [page], + } + + with patch( + "app.services.ppt.nodes._get_llm_client", + side_effect=AssertionError("title-only edits should not call the LLM"), + ): + updated_page = await modify_slide_json( + instruction="把这一页标题改成《走进中国文化》", + pptx_obj=pptx_obj, + slide_index=0, + ) + + self.assertEqual(updated_page["id"], "page-1") + self.assertEqual(len(updated_page["children"]), 3) + self.assertEqual( + updated_page["children"][0]["children"][0]["children"][0]["text"], + "走进中国文化", + ) + self.assertEqual( + updated_page["children"][2]["children"][0]["children"][0]["text"], + "这里还有副标题和正文", + ) + + async def test_modify_slide_json_updates_subtitle_without_rebuilding_page(self): + page = _make_sample_page() + pptx_obj = {"width": 960, "height": 540, "pages": [page]} + + with patch( + "app.services.ppt.nodes._get_llm_client", + side_effect=AssertionError("subtitle-only edits should not call the LLM"), + ): + updated_page = await modify_slide_json( + instruction="把这一页副标题改成“核心概念与文化气质”", + pptx_obj=pptx_obj, + slide_index=0, + ) + + self.assertEqual( + updated_page["children"][0]["children"][0]["children"][0]["text"], + "中国文化", + ) + self.assertEqual( + updated_page["children"][1]["children"][0]["children"][0]["text"], + "核心概念与文化气质", + ) + self.assertEqual( + updated_page["children"][2]["children"][0]["children"][0]["text"], + "这里还有副标题和正文", + ) + + async def test_modify_slide_json_updates_body_without_rebuilding_page(self): + page = _make_sample_page() + pptx_obj = {"width": 960, "height": 540, "pages": [page]} + + with patch( + "app.services.ppt.nodes._get_llm_client", + side_effect=AssertionError("body-only edits should not call the LLM"), + ): + updated_page = await modify_slide_json( + instruction="把这一页正文改成“从礼乐制度、审美观念和日常生活三个角度展开”", + pptx_obj=pptx_obj, + slide_index=0, + ) + + self.assertEqual( + updated_page["children"][0]["children"][0]["children"][0]["text"], + "中国文化", + ) + self.assertEqual( + updated_page["children"][1]["children"][0]["children"][0]["text"], + "传统与现代的对话", + ) + self.assertEqual( + updated_page["children"][2]["children"][0]["children"][0]["text"], + "从礼乐制度、审美观念和日常生活三个角度展开", + ) + + async def test_modify_slide_json_falls_back_to_llm_for_unmatched_instruction(self): + page = _make_sample_page() + llm_page = _make_sample_page() + llm_page["children"][2]["children"][0]["children"][0]["text"] = "LLM fallback version" + pptx_obj = {"width": 960, "height": 540, "pages": [page]} + + with patch( + "app.services.ppt.nodes._get_llm_client", + return_value=_make_fake_llm_client(llm_page), + ) as mocked_client: + updated_page = await modify_slide_json( + instruction="把第二段精简一点并加个例子", + pptx_obj=pptx_obj, + slide_index=0, + ) + + mocked_client.assert_called_once() + self.assertEqual( + updated_page["children"][2]["children"][0]["children"][0]["text"], + "LLM fallback version", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/mockup-ppt-generation.html b/docs/mockup-ppt-generation.html new file mode 100644 index 0000000..4561a99 --- /dev/null +++ b/docs/mockup-ppt-generation.html @@ -0,0 +1,956 @@ + + + + + +PPT生成页面 - 三阶段布局原型 + + + + + + + + + +
+ +
+ + +
+ + +
+

创建你的教学PPT

+

选择模板,输入主题,AI帮你生成专业课件

+ + +
+
+ +
+
+
📎 上传文件
+
🖼️ 上传图片
+
🎤 语音输入
+
📚 选择知识库
+
+ +
+
+
+ + +
+
+
选择模板
+
+
+
+
商务风格
+
简约商务
+
+
+
教育风格
+
活泼教育
+
+
+
科技风格
+
科技蓝
+
+
+
中国风
+
水墨中国
+
+
+
活泼教育
+
法政教育
+
+
+
科技蓝
+
科技蓝
+
+
+
水墨中国
+
水墨中国
+
+
+
+ 更多模板
+
更多模板
+
+
+
+ +
+ + + + + + + +
+ +
+ +
+ + + + + diff --git a/docs/superpowers/plans/2026-03-18-ppt-generation-plan.md b/docs/superpowers/plans/2026-03-18-ppt-generation-plan.md new file mode 100644 index 0000000..8808bd8 --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-ppt-generation-plan.md @@ -0,0 +1,633 @@ +# AI PPT 生成功能实施计划(单次交付版) + +更新时间:2026-03-18 + +> 本计划用于直接指导实现。本计划对应一次性交付版本,不做一期 / 二期拆分;实现顺序可以分步推进,但最终验收必须满足规格文档中的完整能力要求。 + +关联规格: + +- [spec文档.md](/d:/123/AIsystem/docs/superpowers/specs/spec文档.md) + +## 1. 实施原则 + +### 1.1 必须遵守的技术约束 + +- 前端流式方案统一使用 `fetch + reader` +- 不使用原生 `EventSource` +- 不引入 WebSocket 替代 SSE +- 前端不直连 Docmee,统一走后端代理 +- 前端复用 [http.js](/d:/123/AIsystem/teacher-platform/src/api/http.js) 的请求模式 +- 前端入口复用 [LessonPrepPpt.vue](/d:/123/AIsystem/teacher-platform/src/views/LessonPrepPpt.vue) +- 预览复用 Docmee 官方 `ppt2svg.js` / `ppt2canvas.js` +- 数据库变更统一使用 Alembic +- LangGraph 只围绕业务主链路落地,不做不必要扩张 + +### 1.2 本次交付目标 + +完成本计划后,用户应能在当前项目中直接完成以下完整链路: + +1. 新建 PPT 会话 +2. 选择知识库与模板 +3. 输入需求并流式生成大纲 +4. 自动配图 +5. 编辑并审批大纲 +6. 生成 PPT 并看到流式进度 +7. 预览、继续修改、元素级编辑 +8. 保存版本并做版本对比 +9. 下载结果 + +## 2. 文件调整方案 + +## 2.1 后端新增文件 + +| 文件 | 作用 | +|------|------| +| `backend/app/models/ppt_session.py` | PPT 会话模型 | +| `backend/app/models/ppt_outline.py` | PPT 大纲版本模型 | +| `backend/app/models/ppt_message.py` | PPT 会话消息模型 | +| `backend/app/models/ppt_result.py` | PPT 结果版本模型 | +| `backend/app/schemas/ppt.py` | PPT 相关请求响应 schema | +| `backend/app/services/ppt/docmee_client.py` | Docmee 代理客户端 | +| `backend/app/services/ppt/image_search.py` | 自动配图服务 | +| `backend/app/services/ppt/state.py` | LangGraph 状态定义 | +| `backend/app/services/ppt/nodes.py` | LangGraph 节点实现 | +| `backend/app/services/ppt/workflow.py` | LangGraph 工作流 | +| `backend/app/services/ppt/serializer.py` | `pptx_property` 解压 / 序列化辅助 | +| `backend/app/api/ppt.py` | PPT API 路由 | +| `backend/alembic/versions/XXXX_add_ppt_tables.py` | 数据库迁移 | + +## 2.2 后端修改文件 + +| 文件 | 变更 | +|------|------| +| `backend/app/models/__init__.py` | 导出新模型 | +| `backend/app/api/__init__.py` | 注册 PPT 路由 | +| `backend/.env.example` | 新增 Docmee / Unsplash 配置 | + +## 2.3 前端新增文件 + +| 文件 | 作用 | +|------|------| +| `teacher-platform/src/api/ppt.js` | PPT API 封装 | +| `teacher-platform/src/components/ppt/PptWorkspace.vue` | PPT 页面主工作区 | +| `teacher-platform/src/components/ppt/PptSidebar.vue` | 会话与版本侧栏 | +| `teacher-platform/src/components/ppt/WelcomePanel.vue` | 欢迎页 | +| `teacher-platform/src/components/ppt/TemplateSelector.vue` | 模板选择器 | +| `teacher-platform/src/components/ppt/KnowledgeLibraryModal.vue` | 知识库选择弹窗 | +| `teacher-platform/src/components/ppt/ChatPanel.vue` | 对话页 | +| `teacher-platform/src/components/ppt/ChatMessage.vue` | 聊天气泡 | +| `teacher-platform/src/components/ppt/OutlineCard.vue` | 大纲卡片 | +| `teacher-platform/src/components/ppt/PptResultCard.vue` | 结果卡片 | +| `teacher-platform/src/components/ppt/PptPreviewPanel.vue` | 预览页 | +| `teacher-platform/src/components/ppt/PptThumbnailList.vue` | 缩略图列表 | +| `teacher-platform/src/components/ppt/PptCanvas.vue` | 大图预览 | +| `teacher-platform/src/components/ppt/PptToolbar.vue` | 顶部工具栏 | +| `teacher-platform/src/components/ppt/PptVersionCompareDialog.vue` | 版本对比弹窗 | +| `teacher-platform/src/components/ppt/ChatInput.vue` | 通用输入框 | +| `teacher-platform/src/utils/ppt2svg.js` | 复用官方渲染器 | +| `teacher-platform/src/utils/ppt2canvas.js` | 复用官方缩略图渲染器 | + +## 2.4 前端修改文件 + +| 文件 | 变更 | +|------|------| +| `teacher-platform/src/views/LessonPrepPpt.vue` | 由占位页面改造成 PPT 功能入口页 | +| `teacher-platform/package.json` | 如确有缺失则补充 `pako` / `base64-js` / Markdown 相关依赖 | + +说明: + +- 不新增新的课前准备 tab +- 不修改 [LessonPrep.vue](/d:/123/AIsystem/teacher-platform/src/views/LessonPrep.vue) 的导航结构 +- 直接替换当前 `ppt` 页签对应的实现 + +## 3. 实施步骤 + +## Phase 1:数据库与基础模型 + +**目标:** 为会话、大纲版本、消息、结果版本建立稳定数据基础。 + +- [ ] 新建 Alembic 迁移 `XXXX_add_ppt_tables.py` +- [ ] 创建 `ppt_sessions` +- [ ] 创建 `ppt_outlines` +- [ ] 创建 `ppt_messages` +- [ ] 创建 `ppt_results` +- [ ] 为 `ppt_outlines` 和 `ppt_results` 增加 `version`、`is_current` +- [ ] 为 `ppt_results` 增加: + - [ ] `source_pptx_property` + - [ ] `edited_pptx_property` + - [ ] `current_page` + - [ ] `total_pages` +- [ ] 编写对应 SQLAlchemy 模型 +- [ ] 编写 `backend/app/schemas/ppt.py` + +**验证:** + +- [ ] 运行迁移成功 +- [ ] 新模型可被 `Base.metadata` 正确识别 +- [ ] 补充基础 CRUD 测试 + +## Phase 2:Docmee 客户端与模板代理 + +**目标:** 封装 Docmee 的模板、内容生成、PPT 生成、结果加载与下载能力。 + +- [ ] 创建 `backend/app/services/ppt/docmee_client.py` +- [ ] 封装模板列表获取 +- [ ] 封装内容 / 大纲生成 +- [ ] 封装内容更新 +- [ ] 封装 PPT 生成 +- [ ] 封装结果加载 +- [ ] 封装下载地址获取 +- [ ] 封装 `pptx_property` 解压辅助 +- [ ] 所有请求统一由后端读取 `DOCMEE_API_TOKEN` + +**关键要求:** + +- [ ] 前端完全不感知 Docmee token +- [ ] 若主链路采用 V2,但渐进预览需兼容旧接口,兼容逻辑仅存在于后端客户端中 + +**验证:** + +- [ ] 新建 `tests/test_docmee_client.py` +- [ ] mock 成功 / 失败 / 超时场景 +- [ ] 手工验证模板列表与结果加载 + +## Phase 3:自动配图服务 + +**目标:** 在大纲生成完成后自动补充页面配图。 + +- [ ] 创建 `backend/app/services/ppt/image_search.py` +- [ ] 从 Markdown 大纲中提取页面标题 +- [ ] 使用 LLM 翻译关键词 +- [ ] 调用 Unsplash 搜索横版图片 +- [ ] 将结果写入 `ppt_outlines.image_urls` + +**要求:** + +- [ ] 配图失败不阻塞后续审批与生成 +- [ ] 返回空图集时流程仍可继续 + +**验证:** + +- [ ] 新建 `tests/test_image_search.py` +- [ ] 测试大纲解析 +- [ ] 测试关键词翻译 +- [ ] 测试 API 失败兜底 + +## Phase 4:LangGraph 工作流 + +**目标:** 实现服务于业务的生成、审批、修改工作流。 + +- [ ] 创建 `backend/app/services/ppt/state.py` +- [ ] 定义状态字段: + - [ ] `session_id` + - [ ] `user_id` + - [ ] `messages` + - [ ] `selected_library_ids` + - [ ] `template_id` + - [ ] `outline_markdown` + - [ ] `outline_id` + - [ ] `outline_approved` + - [ ] `image_urls` + - [ ] `result_id` + - [ ] `next_action` +- [ ] 创建 `nodes.py` +- [ ] 至少实现以下节点: + - [ ] 检索 / 工具调用 + - [ ] 大纲生成 + - [ ] 自动配图 + - [ ] 审批中断 + - [ ] PPT 生成准备 + - [ ] 修改再生成 +- [ ] 创建 `workflow.py` +- [ ] 编译工作流并支持审批中断恢复 + +**关键要求:** + +- [ ] 工作流必须支持继续修改场景 +- [ ] 工作流复杂度控制在可测试范围内 +- [ ] 不在服务层使用错误的 `async with get_db() as db` 写法 + +**验证:** + +- [ ] 新建 `tests/test_ppt_workflow.py` +- [ ] 测试初次生成 +- [ ] 测试审批恢复 +- [ ] 测试继续修改创建新版本 + +## Phase 5:后端 API 与流式输出 + +**目标:** 提供完整的会话、流式生成、编辑保存、版本查询接口。 + +- [ ] 新建 `backend/app/api/ppt.py` +- [ ] 实现 `GET /ppt/templates` +- [ ] 实现 `POST /ppt/sessions` +- [ ] 实现 `GET /ppt/sessions` +- [ ] 实现 `GET /ppt/sessions/{session_id}` +- [ ] 实现 `POST /ppt/stream/outline` +- [ ] 实现 `POST /ppt/outlines/{outline_id}/approve` +- [ ] 实现 `POST /ppt/stream/generate` +- [ ] 实现 `POST /ppt/results/{result_id}/modify` +- [ ] 实现 `POST /ppt/results/{result_id}/edit-snapshot` +- [ ] 实现 `GET /ppt/results/{result_id}` +- [ ] 实现 `POST /ppt/results/{result_id}/download` +- [ ] 实现 `GET /ppt/sessions/{session_id}/versions` + +### 5.1 流式输出约束 + +- [ ] 所有流式接口统一使用 `StreamingResponse` +- [ ] 统一输出 `data: {"type":"..."}` 的 JSON 事件 +- [ ] 不使用 `event: xxx` 自定义事件名 +- [ ] 事件格式与教案页的流式解析逻辑兼容 + +### 5.2 渐进预览要求 + +- [ ] 若 Docmee 返回页级预览数据,则输出 `page_ready` +- [ ] 若暂时无法页级返回,则至少持续输出 `progress` +- [ ] 最终必须返回完整结果并支持预览恢复 + +**验证:** + +- [ ] 使用 curl / Postman 验证 `text/event-stream` +- [ ] 验证大纲流 +- [ ] 验证 PPT 生成流 +- [ ] 验证错误事件 + +## Phase 6:前端 API 与页面主状态 + +**目标:** 在不引入额外复杂基础设施的前提下,搭建 PPT 页面业务主控。 + +- [ ] 新建 `teacher-platform/src/api/ppt.js` +- [ ] 复用 [http.js](/d:/123/AIsystem/teacher-platform/src/api/http.js) 的请求模式 +- [ ] 封装: + - [ ] 获取模板 + - [ ] 新建会话 + - [ ] 加载会话 + - [ ] 审批大纲 + - [ ] 获取结果 + - [ ] 保存编辑快照 + - [ ] 下载结果 + - [ ] 版本列表 +- [ ] 改造 [LessonPrepPpt.vue](/d:/123/AIsystem/teacher-platform/src/views/LessonPrepPpt.vue) 为 PPT 页面根组件 +- [ ] 在根组件中维护: + - [ ] 阶段状态 + - [ ] 当前会话 + - [ ] 消息列表 + - [ ] 当前大纲 + - [ ] 当前模板 + - [ ] 当前结果 + - [ ] 当前版本列表 + - [ ] 当前 `pptxObj` + - [ ] 流式状态 + +### 6.1 前端流式实现 + +- [ ] 参考 [LessonPlanPage.vue](/d:/123/AIsystem/teacher-platform/src/views/LessonPlanPage.vue) 实现通用 `streamSSE` +- [ ] 使用 `fetch + reader` +- [ ] 解析 `data:` JSON +- [ ] 根据 `type` 字段分发更新页面状态 + +**验证:** + +- [ ] 根组件能完成一次大纲流式生成 +- [ ] 根组件能完成一次 PPT 生成流式更新 + +## Phase 7:欢迎页与对话页 + +**目标:** 落地 Stage 1 与 Stage 2。 + +- [ ] 创建 `WelcomePanel.vue` +- [ ] 创建 `TemplateSelector.vue` +- [ ] 创建 `KnowledgeLibraryModal.vue` +- [ ] 创建 `ChatPanel.vue` +- [ ] 创建 `ChatMessage.vue` +- [ ] 创建 `OutlineCard.vue` +- [ ] 创建 `PptResultCard.vue` +- [ ] 创建通用 `ChatInput.vue` + +### 7.1 欢迎页要求 + +- [ ] 提供预设卡片 +- [ ] 提供文本输入 +- [ ] 提供模板选择入口 +- [ ] 提供知识库选择入口 + +### 7.2 对话页要求 + +- [ ] 展示消息历史 +- [ ] 展示流式文字 +- [ ] 展示大纲卡片 +- [ ] 支持编辑大纲 +- [ ] 支持审批大纲 +- [ ] 在已有大纲后继续追问 + +**验证:** + +- [ ] 从欢迎页输入需求后可进入对话页 +- [ ] 大纲卡片可编辑并提交 + +## Phase 8:预览页与官方渲染内核接入 + +**目标:** 落地 Stage 3 预览。 + +- [ ] 将官方 `ppt2svg.js` 复制到 `src/utils/` +- [ ] 将官方 `ppt2canvas.js` 复制到 `src/utils/` +- [ ] 处理必要依赖 +- [ ] 创建 `PptCanvas.vue` +- [ ] 创建 `PptThumbnailList.vue` +- [ ] 创建 `PptToolbar.vue` +- [ ] 创建 `PptPreviewPanel.vue` + +### 8.1 预览要求 + +- [ ] 支持缩略图切换 +- [ ] 支持大图预览 +- [ ] 支持窗口 resize +- [ ] 支持从服务端恢复预览 +- [ ] 优先加载 `edited_pptx_property` + +### 8.2 生成中预览要求 + +- [ ] 生成中显示进度 +- [ ] 有 `page_ready` 时更新已完成页 +- [ ] 无 `page_ready` 时仍显示进度与最终完整结果 + +**验证:** + +- [ ] 可正确解压 `pptx_property` +- [ ] 缩略图和大图渲染正常 +- [ ] 刷新后可恢复当前结果 + +## Phase 9:继续修改 PPT 成品 + +**目标:** 在预览阶段支持“继续修改”并生成新版本。 + +- [ ] 在 `PptPreviewPanel.vue` 中保留聊天输入入口 +- [ ] 输入修改指令后调用 `/results/{id}/modify` +- [ ] 后端基于当前大纲 / 当前结果 / 用户指令创建新版本 +- [ ] 前端在生成新版本时更新进度 +- [ ] 新版本完成后更新当前结果并保留旧版本 + +**验证:** + +- [ ] 用户可在预览阶段输入修改指令 +- [ ] 会生成新的 PPT 结果版本 +- [ ] 可在版本列表中切换回旧版本 + +## Phase 10:元素级在线编辑 + +**目标:** 支持预览中的元素级编辑并保存快照。 + +- [ ] 在 `PptCanvas.vue` 中接入官方 edit 模式 +- [ ] 暴露 `onchange` 回调 +- [ ] 在根组件中维护“已编辑未保存”状态 +- [ ] 支持保存编辑快照到后端 +- [ ] 支持刷新后恢复编辑态 + +### 10.1 本次交付最少支持 + +- [ ] 文本编辑 +- [ ] 位置拖拽 +- [ ] 缩放 +- [ ] 旋转 + +### 10.2 保存约束 + +- [ ] 保存时提交最新 `edited_pptx_property` +- [ ] 后端持久化后更新当前结果 + +**验证:** + +- [ ] 编辑文本后刷新仍可恢复 +- [ ] 拖拽、缩放、旋转后保存成功 + +## Phase 11:版本管理与版本对比 + +**目标:** 支持大纲版本与 PPT 结果版本查看、切换、对比。 + +- [ ] 在侧栏中展示会话历史 +- [ ] 在当前会话中展示大纲版本列表 +- [ ] 展示 PPT 结果版本列表 +- [ ] 创建 `PptVersionCompareDialog.vue` +- [ ] 支持选择两个版本做对比 + +### 11.1 对比范围 + +- [ ] 大纲文本内容 +- [ ] 模板与知识库差异 +- [ ] PPT 结果缩略图与元信息 +- [ ] 是否包含元素编辑快照 + +**验证:** + +- [ ] 同一会话中能看到多个版本 +- [ ] 可并排查看两个版本 + +## Phase 12:错误处理与恢复 + +**目标:** 补齐可用性。 + +- [ ] Docmee 接口失败提示 +- [ ] Unsplash 接口失败提示 +- [ ] SSE 中断提示 +- [ ] 权限错误处理 +- [ ] 会话不存在兜底 +- [ ] 生成中禁止重复提交 +- [ ] 页面刷新恢复当前会话 / 当前结果 / 当前编辑态 + +**验证:** + +- [ ] 模拟接口失败 +- [ ] 模拟流中断 +- [ ] 模拟会话恢复 + +## Phase 13:测试 + +**目标:** 关键路径可验证。 + +### 13.1 后端测试 + +- [ ] `tests/test_ppt_models.py` +- [ ] `tests/test_docmee_client.py` +- [ ] `tests/test_image_search.py` +- [ ] `tests/test_ppt_workflow.py` +- [ ] `tests/test_ppt_api.py` + +### 13.2 前端测试 + +- [ ] 流式解析函数测试 +- [ ] `pptx_property` 解压测试 +- [ ] 关键组件渲染测试 + +### 13.3 联调验证 + +- [ ] 创建会话 +- [ ] 生成大纲 +- [ ] 配图 +- [ ] 审批 +- [ ] 生成 PPT +- [ ] 预览 +- [ ] 继续修改 +- [ ] 元素编辑 +- [ ] 版本对比 +- [ ] 下载 + +## Phase 14:文档与交付整理 + +**目标:** 让功能可以被真实使用和维护。 + +- [ ] 更新 README +- [ ] 补充环境变量说明 +- [ ] 写使用说明 +- [ ] 写接口说明 +- [ ] 写部署检查项 + +## 4. 时间评估 + +本计划对应一次性交付版本,时间评估需要更现实。 + +### 建议总工期 + +- 15 到 20 个工作日 + +### 建议拆分 + +- 3 天:数据库、模型、Docmee 客户端 +- 3 天:工作流、自动配图、后端流式接口 +- 4 天:欢迎页、对话页、预览页基础 +- 3 天:继续修改、元素编辑、版本管理 +- 2 到 4 天:联调、测试、文档、修复 + +## 5. 关键风险与应对 + +### 风险 1:Docmee 渐进预览能力与 V2 主链路不完全一致 + +应对: + +- 优先按 V2 实现业务主链路 +- 必要时在后端内部做兼容适配 +- 前端不感知具体 Docmee 细节 + +### 风险 2:元素级编辑后导出链路边界不清晰 + +应对: + +- 本次先确保编辑快照可保存、可恢复、可继续修改参考 +- 下载默认保留最近生成结果下载能力 +- 在界面中清晰提示编辑态与下载稿关系 + +### 风险 3:版本对比功能范围失控 + +应对: + +- 本次版本对比以并排查看为主 +- 不做像素级 diff +- 重点保证“能切换、能看差异、能回退” + +## 6. Claude 实施提示 + +若将本计划交给 Claude / Codex / 其他 agentic 工具实施,必须遵守: + +- 优先复用现有项目代码模式 +- 不引入新的前端请求体系 +- 不用原生 `EventSource` +- 不新增 WebSocket 方案 +- 不让前端直连 Docmee +- 不把当前交付拆成一期 / 二期 +- 不因为实现难度而删除: + - 自动配图 + - 继续修改 PPT 成品 + - 元素级在线编辑 + - 版本对比 + +### 6.1 可直接发送给 Claude 的实施约束摘要 + +下面这段可以直接作为实现任务说明发送给 Claude。建议与规格文档、计划文档一起提供。 + +```text +请基于以下约束,直接在当前项目中实现 AI PPT 功能,不要重新发明一套新架构,也不要擅自缩 scope: + +1. 交付目标 +- 本次是单次交付版本,不拆一期 / 二期。 +- 目标是代码写完后,该功能在当前项目中可以直接跑通主链路。 +- 必须保留这些能力:自动配图、继续修改 PPT 成品、元素级在线编辑、版本管理与版本对比、完整会话恢复、预览、下载。 + +2. 前端技术约束 +- PPT 页面入口直接复用 `teacher-platform/src/views/LessonPrepPpt.vue`。 +- 不新增 tab,不改造 `LessonPrep.vue` 的页面导航结构。 +- 前端请求层复用 `teacher-platform/src/api/http.js` 的模式,不要新起一套 axios 或其他请求体系。 +- 流式方案必须复用教案页 `teacher-platform/src/views/LessonPlanPage.vue` 的 `fetch + reader` 模式。 +- 不使用原生 `EventSource`。 +- 不新增 WebSocket 替代 SSE,但仍然必须保留流式生成体验。 +- SSE 统一使用 `text/event-stream`,前端解析 `data: {...}` JSON,并根据 `type` 分发事件。 + +3. Docmee 接入约束 +- 前端不能直连 Docmee。 +- 前端不能持有 Docmee token。 +- 所有 Docmee 请求统一由后端代理。 +- 业务主链路优先按 Docmee V2 设计。 +- 如果渐进预览必须兼容旧接口,这个兼容逻辑只能放在后端,前端不感知 V1 / V2 差异。 + +4. PPT 预览与编辑约束 +- 预览必须优先复用 Docmee 官方渲染能力:`ppt2svg.js`、`ppt2canvas.js`。 +- 需要支持缩略图列表、大图预览、生成中进度更新、结果恢复。 +- 元素级编辑至少支持:文本编辑、位置移动、缩放、旋转。 +- 元素编辑保存为编辑快照,不要因为导出链路暂时复杂就删掉这个能力。 +- 预览阶段的“继续修改 PPT”与“元素级在线编辑”是两条并行能力,都要保留。 + +5. 后端实现约束 +- 数据库变更必须走 Alembic。 +- 新增 PPT 会话、大纲版本、消息记录、PPT 结果版本等核心模型。 +- LangGraph 需要保留,但只围绕业务主链路实现:知识检索、生成大纲、自动配图、审批中断、生成 PPT、继续修改再生成。 +- 不要为了“图编排很完整”而过度设计。 +- 服务层注意不要写错误的 `async with get_db() as db` 这种模式。 + +6. 版本能力约束 +- 大纲版本要保留。 +- PPT 结果版本要保留。 +- 版本对比必须可用,但采用务实方案: + - 支持用户选两个版本并排查看 + - 支持看大纲文本差异、模板差异、缩略图差异、生成时间、是否有编辑快照 + - 不要求做像素级 diff + +7. 明确不要做的事 +- 不要把功能拆成一期 / 二期再交付。 +- 不要删除自动配图。 +- 不要删除继续修改 PPT 成品。 +- 不要删除元素级在线编辑。 +- 不要删除版本对比。 +- 不要把 SSE 改成 WebSocket。 +- 不要让前端直接调用 `docmee.cn`。 +- 不要为了省事跳过 Alembic。 + +8. 实现优先级 +- 先打通后端数据模型、Docmee 代理、流式接口。 +- 再打通前端页面主状态、欢迎页、对话页、流式生成。 +- 再接入官方预览内核。 +- 再补继续修改、元素级编辑、版本管理与版本对比。 +- 最后做错误处理、恢复能力、测试和文档。 + +9. 完成标准 +- 用户可以新建 PPT 会话。 +- 用户可以选择知识库和模板。 +- 用户可以流式生成并审批大纲。 +- 用户可以看到自动配图结果。 +- 用户可以流式看到 PPT 生成进度。 +- 用户可以在预览页浏览缩略图和大图。 +- 用户可以继续通过聊天修改 PPT,并生成新版本。 +- 用户可以直接编辑预览中的文本 / 位置 / 缩放 / 旋转,并保存快照。 +- 用户可以查看并对比版本。 +- 用户刷新页面后可以恢复最近状态。 +- 用户可以下载结果。 + +如果实现过程中发现 Docmee 某个能力和预期不完全一致,不要直接删功能,优先通过后端兼容、降级展示、补充状态说明来保住整体链路。 +``` + +## 7. 完成定义 + +只有以下事项全部达成,才算计划完成: + +- 后端接口可用 +- 前端完整链路可跑通 +- 所有本次交付功能均已上线到当前项目 +- 核心测试通过 +- 文档补齐 diff --git a/docs/superpowers/plans/2026-03-18-ppt-outline-card-implementation.md b/docs/superpowers/plans/2026-03-18-ppt-outline-card-implementation.md new file mode 100644 index 0000000..41286cf --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-ppt-outline-card-implementation.md @@ -0,0 +1,130 @@ +# PPT Outline Card Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace raw markdown outline chat rendering with a persistent structured outline card flow that supports gentle one-by-one clarification, per-page image choice, structured editing, and markdown conversion for Docmee generation. + +**Architecture:** Treat `outline_payload` JSON as the single source of truth for outline cards. The backend will generate/store/return both legacy markdown and structured payload during migration, and the frontend will render/edit the structured payload while converting it back to Docmee-compliant markdown only at approval/generation time. + +**Tech Stack:** FastAPI, SQLAlchemy, PostgreSQL JSONB, Vue 3 Composition API, existing SSE/fetch reader flow, Node `node:test` + +--- + +## Chunk 1: Backend Outline Payload Foundation + +### Task 1: Extend outline persistence and schema + +**Files:** +- Modify: `backend/app/models/ppt_outline.py` +- Modify: `backend/app/schemas/ppt.py` +- Create: `backend/alembic/versions/_add_outline_payload_to_ppt_outlines.py` + +- [ ] **Step 1: Write the failing backend test for serializing `outline_payload`** +- [ ] **Step 2: Run the backend test to verify it fails** +- [ ] **Step 3: Add `outline_payload` field and schema exposure** +- [ ] **Step 4: Run the backend test to verify it passes** + +### Task 2: Add payload/markdown conversion helpers + +**Files:** +- Create: `backend/app/services/ppt/outline_payload.py` +- Create: `backend/tests/test_ppt_outline_payload.py` + +- [ ] **Step 1: Write failing tests for markdown conversion and image insertion syntax** +- [ ] **Step 2: Run the backend test to verify it fails** +- [ ] **Step 3: Implement minimal payload conversion helpers** +- [ ] **Step 4: Run the backend test to verify it passes** + +## Chunk 2: Gentle Clarification and Outline Generation + +### Task 3: Replace rigid clarification prompts with gentle single-question flow + +**Files:** +- Modify: `teacher-platform/src/views/LessonPrepPpt.vue` +- Create: `teacher-platform/src/utils/pptOutlineFlow.js` +- Create: `teacher-platform/tests/ppt-outline-flow.test.mjs` + +- [ ] **Step 1: Write failing tests for one-question-at-a-time clarification sequencing** +- [ ] **Step 2: Run the frontend test to verify it fails** +- [ ] **Step 3: Implement the minimal clarification helper logic** +- [ ] **Step 4: Run the frontend test to verify it passes** + +### Task 4: Persist assistant lead-in message plus structured outline message + +**Files:** +- Modify: `backend/app/api/ppt.py` +- Modify: `backend/app/services/ppt/nodes.py` +- Modify: `teacher-platform/src/api/ppt.js` + +- [ ] **Step 1: Write failing backend/frontend tests for `outline_ready` payload shape** +- [ ] **Step 2: Run tests to verify they fail** +- [ ] **Step 3: Emit assistant text message then structured outline card payload** +- [ ] **Step 4: Run tests to verify they pass** + +## Chunk 3: Structured Outline Card UI + +### Task 5: Replace markdown outline rendering with structured card rendering + +**Files:** +- Modify: `teacher-platform/src/components/ppt/OutlineCard.vue` +- Modify: `teacher-platform/src/components/ppt/ChatMessage.vue` +- Modify: `teacher-platform/src/components/ppt/ChatPanel.vue` +- Create: `teacher-platform/src/utils/pptOutlineCard.js` +- Modify: `teacher-platform/tests/ppt-outline-flow.test.mjs` + +- [ ] **Step 1: Write failing tests for card mapping and selected-image behavior** +- [ ] **Step 2: Run the frontend test to verify it fails** +- [ ] **Step 3: Implement structured card view/edit state with per-page 2-image choice** +- [ ] **Step 4: Run the frontend test to verify it passes** + +### Task 6: Save edits and image selection back to the current outline + +**Files:** +- Modify: `backend/app/api/ppt.py` +- Modify: `backend/app/schemas/ppt.py` +- Modify: `teacher-platform/src/views/LessonPrepPpt.vue` +- Modify: `teacher-platform/src/api/ppt.js` + +- [ ] **Step 1: Write failing tests for approving/saving outline payload mutations** +- [ ] **Step 2: Run tests to verify they fail** +- [ ] **Step 3: Implement payload save/update handling for text edits and image selection** +- [ ] **Step 4: Run tests to verify they pass** + +## Chunk 4: Generation and Rehydration + +### Task 7: Generate Docmee markdown from payload at approval/generation time + +**Files:** +- Modify: `backend/app/api/ppt.py` +- Modify: `backend/app/services/ppt/outline_payload.py` +- Modify: `backend/tests/test_ppt_outline_payload.py` + +- [ ] **Step 1: Write failing tests for Docmee markdown output with selected images** +- [ ] **Step 2: Run the backend test to verify it fails** +- [ ] **Step 3: Use payload-derived markdown in the generation path** +- [ ] **Step 4: Run the backend test to verify it passes** + +### Task 8: Rehydrate saved sessions into the same outline card UI + +**Files:** +- Modify: `backend/app/api/ppt.py` +- Modify: `teacher-platform/src/views/LessonPrepPpt.vue` +- Modify: `teacher-platform/src/components/ppt/ChatMessage.vue` + +- [ ] **Step 1: Write failing tests for session reload card reconstruction** +- [ ] **Step 2: Run tests to verify they fail** +- [ ] **Step 3: Return and map saved `outline_payload` for current/history outlines** +- [ ] **Step 4: Run tests to verify they pass** + +## Chunk 5: Final Verification + +### Task 9: Run focused verification + +**Files:** +- Test: `backend/tests/test_ppt_outline_payload.py` +- Test: `teacher-platform/tests/ppt-outline-flow.test.mjs` + +- [ ] **Step 1: Run `pytest backend/tests/test_ppt_outline_payload.py -q`** +- [ ] **Step 2: Run `node --test teacher-platform/tests/ppt-outline-flow.test.mjs`** +- [ ] **Step 3: Run `npm.cmd run build` in `teacher-platform`** +- [ ] **Step 4: Confirm outputs before reporting completion** diff --git a/docs/superpowers/plans/2026-03-18-ppt-preview-polish.md b/docs/superpowers/plans/2026-03-18-ppt-preview-polish.md new file mode 100644 index 0000000..0ff9bac --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-ppt-preview-polish.md @@ -0,0 +1,102 @@ +# PPT Preview Polish Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Improve PPT preview responsiveness and finish the missing fullscreen/PDF/zoom interaction details in the PPT workspace. + +**Architecture:** Keep the existing `LessonPrepPpt.vue` + `PptCanvas.vue` split, but move fragile preview behavior into small pure helpers so we can cover them with `node:test` regression tests. Optimize redraws by separating main-slide rendering from thumbnail rendering and only refreshing the affected thumbnail after edit persistence. + +**Tech Stack:** Vue 3 Composition API, html2pdf.js, existing Docmee `Ppt2Svg` / `Ppt2Canvas` renderers, Node `node:test` + +--- + +## Chunk 1: Testable Preview Helpers + +### Task 1: Add pure preview helper coverage + +**Files:** +- Create: `teacher-platform/src/utils/pptPreview.js` +- Create: `teacher-platform/tests/ppt-preview.test.mjs` + +- [ ] **Step 1: Write the failing test** +- [ ] **Step 2: Run test to verify it fails** +- [ ] **Step 3: Write minimal helper implementation** +- [ ] **Step 4: Run test to verify it passes** + +### Task 2: Cover zoom edge cases and toast timing helpers + +**Files:** +- Modify: `teacher-platform/tests/ppt-preview.test.mjs` +- Modify: `teacher-platform/src/utils/pptPreview.js` + +- [ ] **Step 1: Write the failing test** +- [ ] **Step 2: Run test to verify it fails** +- [ ] **Step 3: Write minimal helper implementation** +- [ ] **Step 4: Run test to verify it passes** + +## Chunk 2: Preview Rendering and Interaction + +### Task 3: Refactor main canvas redraw behavior + +**Files:** +- Modify: `teacher-platform/src/components/ppt/PptCanvas.vue` +- Modify: `teacher-platform/src/utils/pptPreview.js` +- Test: `teacher-platform/tests/ppt-preview.test.mjs` + +- [ ] **Step 1: Write the failing test for redraw-related helper behavior** +- [ ] **Step 2: Run test to verify it fails** +- [ ] **Step 3: Update `PptCanvas.vue` to use the helper-backed zoom and transient toast behavior** +- [ ] **Step 4: Run test to verify it passes** + +### Task 4: Refresh only the changed thumbnail + +**Files:** +- Modify: `teacher-platform/src/components/ppt/PptThumbnailList.vue` +- Modify: `teacher-platform/src/views/LessonPrepPpt.vue` +- Test: `teacher-platform/tests/ppt-preview.test.mjs` + +- [ ] **Step 1: Write the failing test for changed slide detection** +- [ ] **Step 2: Run test to verify it fails** +- [ ] **Step 3: Implement selective thumbnail refresh plumbing** +- [ ] **Step 4: Run test to verify it passes** + +## Chunk 3: Fullscreen, PDF, and Layout Polish + +### Task 5: Implement preview-only fullscreen mode + +**Files:** +- Modify: `teacher-platform/src/views/LessonPrepPpt.vue` + +- [ ] **Step 1: Add fullscreen state and keyboard/escape handling** +- [ ] **Step 2: Update template and styles to let the preview panel fill the screen** +- [ ] **Step 3: Verify behavior with a production build** + +### Task 6: Implement PDF export for all slides + +**Files:** +- Modify: `teacher-platform/src/views/LessonPrepPpt.vue` +- Modify: `teacher-platform/src/components/ppt/PptCanvas.vue` + +- [ ] **Step 1: Add export flow using the existing preview DOM/html2pdf.js** +- [ ] **Step 2: Ensure export renders all slides in order and hides transient UI** +- [ ] **Step 3: Verify behavior with a production build** + +### Task 7: Adjust scrollbar/footer placement + +**Files:** +- Modify: `teacher-platform/src/views/LessonPrepPpt.vue` +- Modify: `teacher-platform/src/components/ppt/PptCanvas.vue` + +- [ ] **Step 1: Move the horizontal scroll region flush with the footer boundary** +- [ ] **Step 2: Verify 100% zoom layout and note area spacing visually via build preview** + +## Chunk 4: Final Verification + +### Task 8: Run regression tests and build + +**Files:** +- Test: `teacher-platform/tests/ppt-preview.test.mjs` + +- [ ] **Step 1: Run `node --test teacher-platform/tests/ppt-preview.test.mjs`** +- [ ] **Step 2: Run `npm run build` in `teacher-platform`** +- [ ] **Step 3: Confirm outputs before reporting completion** diff --git a/docs/superpowers/plans/2026-03-19-ppt-safe-text-editing.md b/docs/superpowers/plans/2026-03-19-ppt-safe-text-editing.md new file mode 100644 index 0000000..04b0c9f --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-ppt-safe-text-editing.md @@ -0,0 +1,50 @@ +# PPT Safe Text Editing Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make common PPT text edits update existing page text safely without letting the LLM rebuild the whole slide JSON. + +**Architecture:** Extend backend slide editing with a deterministic text-targeting path for title, subtitle, and body-like content. Keep the existing LLM JSON rewrite as a fallback only when the instruction cannot be matched safely. + +**Tech Stack:** FastAPI backend service helpers, Python `unittest`, existing Docmee page JSON structure + +--- + +## Chunk 1: Safe Target Detection + +### Task 1: Add regression tests for safe text updates and fallback + +**Files:** +- Modify: `backend/tests/test_ppt_slide_editing.py` +- Test: `backend/tests/test_ppt_slide_editing.py` + +- [ ] **Step 1: Write failing tests for subtitle, body, and fallback-to-LLM behavior** +- [ ] **Step 2: Run `python -m unittest tests.test_ppt_slide_editing` in `backend` to verify the new assertions fail** +- [ ] **Step 3: Keep the title regression test green while adding the new failures** +- [ ] **Step 4: Re-run `python -m unittest tests.test_ppt_slide_editing` and confirm only the new behavior is failing** + +## Chunk 2: Minimal Safe Editing Implementation + +### Task 2: Expand deterministic slide text editing + +**Files:** +- Modify: `backend/app/services/ppt/nodes.py` +- Test: `backend/tests/test_ppt_slide_editing.py` + +- [ ] **Step 1: Add instruction parsing for subtitle/body style edits** +- [ ] **Step 2: Add text-node targeting helpers that preserve page structure** +- [ ] **Step 3: Keep unmatched instructions on the existing LLM fallback path** +- [ ] **Step 4: Run `python -m unittest tests.test_ppt_slide_editing` and confirm all tests pass** + +## Chunk 3: Focused Verification + +### Task 3: Run related backend regression tests + +**Files:** +- Test: `backend/tests/test_ppt_slide_editing.py` +- Test: `backend/tests/test_ppt_outline_payload.py` +- Test: `backend/tests/test_docmee_client.py` +- Test: `backend/tests/test_core_imports.py` + +- [ ] **Step 1: Run `python -m unittest tests.test_ppt_slide_editing tests.test_ppt_outline_payload tests.test_docmee_client tests.test_core_imports` in `backend`** +- [ ] **Step 2: Confirm the safe editing change does not break outline/docmee support** diff --git a/docs/superpowers/plans/2026-03-19-ppt-speaker-notes-implementation.md b/docs/superpowers/plans/2026-03-19-ppt-speaker-notes-implementation.md new file mode 100644 index 0000000..178067a --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-ppt-speaker-notes-implementation.md @@ -0,0 +1,255 @@ +# PPT Speaker Notes Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Generate, persist, edit, and display per-page `speaker_notes` for PPT outlines so the preview panel always shows the current slide's speaking notes. + +**Architecture:** Treat `speaker_notes` as part of `outline_payload.sections[].pages[]`, generated during structured outline creation and saved through the existing outline approval flow. The frontend will render and edit the notes inside the outline card, then resolve preview notes from the current outline payload based on `activeSlideIndex` instead of reading a nonexistent global result field. + +**Tech Stack:** FastAPI, SQLAlchemy JSONB persistence via `ppt_outlines.outline_payload`, Vue 3 Composition API, Node `node:test`, Python `unittest` + +--- + +## Chunk 1: Backend Outline Payload Notes + +### Task 1: Add failing backend tests for page-level speaker notes + +**Files:** +- Modify: `backend/tests/test_ppt_outline_payload.py` +- Test: `backend/tests/test_ppt_outline_payload.py` + +- [ ] **Step 1: Write the failing tests** + +```python +def test_markdown_to_outline_payload_adds_speaker_notes_per_page(self): + payload = markdown_to_outline_payload(markdown, image_urls={}) + self.assertTrue(payload["sections"][0]["pages"][0]["speaker_notes"]) + +def test_payload_to_docmee_markdown_ignores_speaker_notes(self): + markdown = payload_to_docmee_markdown(payload_with_notes) + self.assertNotIn("演讲备注", markdown) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m unittest tests.test_ppt_outline_payload` +Expected: FAIL because `speaker_notes` is missing and markdown export behavior is unverified + +- [ ] **Step 3: Write minimal implementation** + +```python +def build_speaker_notes_for_page(page_title, blocks): + return f"本页重点讲 {page_title} ..." +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m unittest tests.test_ppt_outline_payload` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/tests/test_ppt_outline_payload.py backend/app/services/ppt/outline_payload.py +git commit -m "feat: add speaker notes to outline payload" +``` + +### Task 2: Generate and preserve speaker notes in backend payload helpers + +**Files:** +- Modify: `backend/app/services/ppt/outline_payload.py` +- Modify: `backend/app/api/ppt.py` +- Test: `backend/tests/test_ppt_outline_payload.py` + +- [ ] **Step 1: Write the failing test for approval/save round-trip** + +```python +def test_outline_payload_round_trip_keeps_speaker_notes(self): + payload = {..., "speaker_notes": "note text"} + markdown = payload_to_docmee_markdown(payload) + self.assertEqual(payload["sections"][0]["pages"][0]["speaker_notes"], "note text") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m unittest tests.test_ppt_outline_payload` +Expected: FAIL if helper rewrites or drops `speaker_notes` + +- [ ] **Step 3: Write minimal implementation** + +```python +page["speaker_notes"] = page.get("speaker_notes") or build_speaker_notes_for_page(...) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m unittest tests.test_ppt_outline_payload` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/ppt/outline_payload.py backend/app/api/ppt.py backend/tests/test_ppt_outline_payload.py +git commit -m "feat: preserve speaker notes in outline approval flow" +``` + +## Chunk 2: Frontend Outline Card Editing + +### Task 3: Add failing frontend tests for outline-card speaker notes editing + +**Files:** +- Modify: `teacher-platform/tests/ppt-outline-card.test.mjs` +- Modify: `teacher-platform/src/components/ppt/OutlineCard.vue` +- Test: `teacher-platform/tests/ppt-outline-card.test.mjs` + +- [ ] **Step 1: Write the failing tests** + +```javascript +test('outline payload keeps speaker notes when cloning and editing', () => { + const payload = cloneOutlinePayload(source) + assert.equal(payload.sections[0].pages[0].speaker_notes, 'note text') +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test teacher-platform/tests/ppt-outline-card.test.mjs` +Expected: FAIL because the card utility/component does not expose notes yet + +- [ ] **Step 3: Write minimal implementation** + +```vue + - - - - - - - - \ No newline at end of file + diff --git a/teacher-platform/tests/ppt-chat-layout.test.mjs b/teacher-platform/tests/ppt-chat-layout.test.mjs new file mode 100644 index 0000000..fe44bca --- /dev/null +++ b/teacher-platform/tests/ppt-chat-layout.test.mjs @@ -0,0 +1,17 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' + +const root = path.resolve(import.meta.dirname, '..', 'src', 'components', 'ppt') +const chatMessage = fs.readFileSync(path.join(root, 'ChatMessage.vue'), 'utf8') +const outlineCard = fs.readFileSync(path.join(root, 'OutlineCard.vue'), 'utf8') + +test('chat messages use wider max widths', () => { + assert.match(chatMessage, /max-width:\s*82%/) + assert.match(chatMessage, /max-width:\s*min\(1180px,\s*100%\)/) +}) + +test('outline card uses wider layout width', () => { + assert.match(outlineCard, /width:\s*min\(1180px,\s*100%\)/) +}) diff --git a/teacher-platform/tests/ppt-outline-card.test.mjs b/teacher-platform/tests/ppt-outline-card.test.mjs new file mode 100644 index 0000000..2b6fe88 --- /dev/null +++ b/teacher-platform/tests/ppt-outline-card.test.mjs @@ -0,0 +1,110 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + cloneOutlinePayload, + hasRenderableOutlinePayload, + markdownToOutlinePayload, + payloadToMarkdown, +} from '../src/utils/pptOutlineCard.js' + +test('markdownToOutlinePayload parses flat page outlines into page cards with image candidates', () => { + const payload = markdownToOutlinePayload( + `# 中国传统文化 + +## 第 1 页:封面与课程信息 +- 主标题:中国传统文化 +- 适用对象:本科生 + +## 第 2 页:教学目标与课程导入 +- 核心目标:建立整体认识 +`, + { + 0: ['https://img.example.com/cover-a.png', 'https://img.example.com/cover-b.png'], + 1: ['https://img.example.com/goal-a.png', 'https://img.example.com/goal-b.png'], + }, + ) + + assert.equal(payload.title, '中国传统文化') + assert.equal(hasRenderableOutlinePayload(payload), true) + assert.equal(payload.sections.length, 1) + assert.equal(payload.sections[0].pages.length, 2) + assert.equal(payload.sections[0].pages[0].image_candidates.length, 2) + assert.equal(typeof payload.sections[0].pages[0].speaker_notes, 'string') + assert.equal(payload.sections[0].pages[0].speaker_notes.length > 0, true) +}) + +test('payloadToMarkdown keeps selected image and page content', () => { + const markdown = payloadToMarkdown({ + title: '中国传统文化', + sections: [ + { + title: '内容大纲', + pages: [ + { + title: '第 1 页:封面与课程信息', + blocks: [{ title: '', content: ['主标题:中国传统文化'] }], + image_candidates: [ + { id: 'img-a', url: 'https://img.example.com/a.png' }, + { id: 'img-b', url: 'https://img.example.com/b.png' }, + ], + selected_image_id: 'img-b', + }, + ], + }, + ], + }) + + assert.match(markdown, /### 第 1 页:封面与课程信息/) + assert.match(markdown, /- 主标题:中国传统文化/) + assert.match(markdown, /!\[配图2\]\(https:\/\/img\.example\.com\/b\.png\)/) + assert.doesNotMatch(markdown, /先介绍主题,再快速点出本页课堂目标/) +}) + +test('cloneOutlinePayload keeps speaker notes when editing payload', () => { + const source = { + title: '中国传统文化', + sections: [ + { + title: '内容大纲', + pages: [ + { + title: '第 1 页:课程封面与导入', + subtitle: '', + blocks: [{ title: '', content: ['主题:中国传统文化'] }], + speaker_notes: '先介绍主题,再快速点出本页课堂目标。', + image_candidates: [], + selected_image_id: null, + }, + ], + }, + ], + } + + const payload = cloneOutlinePayload(source) + payload.sections[0].pages[0].speaker_notes = '修改后的备注' + + assert.equal(source.sections[0].pages[0].speaker_notes, '先介绍主题,再快速点出本页课堂目标。') + assert.equal(payload.sections[0].pages[0].speaker_notes, '修改后的备注') +}) + +test('markdownToOutlinePayload realigns legacy shifted image indexes', () => { + const payload = markdownToOutlinePayload( + `# 中国传统文化 + +## 第 1 页:封面与课程信息 +- 主标题:中国传统文化 + +## 第 2 页:教学目标与课程导入 +- 核心目标:建立整体认识 +`, + { + 0: ['https://img.example.com/title-a.png', 'https://img.example.com/title-b.png'], + 1: ['https://img.example.com/page1-a.png', 'https://img.example.com/page1-b.png'], + 2: ['https://img.example.com/page2-a.png', 'https://img.example.com/page2-b.png'], + }, + ) + + assert.equal(payload.sections[0].pages[0].image_candidates[0].url, 'https://img.example.com/page1-a.png') + assert.equal(payload.sections[0].pages[1].image_candidates[0].url, 'https://img.example.com/page2-a.png') +}) diff --git a/teacher-platform/tests/ppt-outline-flow.test.mjs b/teacher-platform/tests/ppt-outline-flow.test.mjs new file mode 100644 index 0000000..7477da2 --- /dev/null +++ b/teacher-platform/tests/ppt-outline-flow.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + CLARIFICATION_STEPS, + buildClarificationRequest, + getClarificationQuestion, +} from '../src/utils/pptOutlineFlow.js' + +test('clarification flow asks one gentle question at a time', () => { + assert.equal(CLARIFICATION_STEPS.length, 5) + + const question = getClarificationQuestion('中国传统文化', 0) + assert.match(question, /中国传统文化/) + assert.doesNotMatch(question, /1\.|2\.|3\./) +}) + +test('clarification request includes confirmed teaching intent', () => { + const prompt = buildClarificationRequest('中国传统文化', { + audience: '高中生', + goal: '理解传统文化核心价值', + duration: '20-25页', + focus: '哲学与传统节日', + style: '课堂讲授', + }) + + assert.match(prompt, /PPT主题:中国传统文化/) + assert.match(prompt, /受众:高中生/) + assert.match(prompt, /教学目标:理解传统文化核心价值/) + assert.match(prompt, /重点内容:哲学与传统节日/) +}) diff --git a/teacher-platform/tests/ppt-preview.test.mjs b/teacher-platform/tests/ppt-preview.test.mjs new file mode 100644 index 0000000..80cc014 --- /dev/null +++ b/teacher-platform/tests/ppt-preview.test.mjs @@ -0,0 +1,175 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' + +import { + ZOOM_MAX_PERCENT, + ZOOM_MIN_PERCENT, + ZOOM_STEP_PERCENT, + ZOOM_TOAST_DURATION_MS, + getPdfExportLayerPosition, + getNextZoomPercent, + getPptDownloadUrl, + getThumbnailRenderIndices, + openPendingPptDownloadWindow, + triggerPptDownload, +} from '../src/utils/pptPreview.js' +import { resolveSpeakerNotes } from '../src/utils/pptOutlineCard.js' + +const lessonPrepPptPath = path.resolve(import.meta.dirname, '..', 'src', 'views', 'LessonPrepPpt.vue') +const lessonPrepPptSource = fs.readFileSync(lessonPrepPptPath, 'utf8') + +test('zoom step snaps cleanly to 100 percent', () => { + assert.equal(getNextZoomPercent(110, 1), 100) + assert.equal(getNextZoomPercent(90, -1), 100) +}) + +test('zoom stays within configured bounds', () => { + assert.equal(getNextZoomPercent(ZOOM_MAX_PERCENT, -1), ZOOM_MAX_PERCENT) + assert.equal(getNextZoomPercent(ZOOM_MIN_PERCENT, 1), ZOOM_MIN_PERCENT) +}) + +test('thumbnail refresh renders all slides by default', () => { + assert.deepEqual(getThumbnailRenderIndices({ slideCount: 4 }), [0, 1, 2, 3]) +}) + +test('thumbnail refresh can target only one dirty slide', () => { + assert.deepEqual( + getThumbnailRenderIndices({ slideCount: 5, dirtySlideIndex: 2 }), + [2], + ) +}) + +test('thumbnail refresh falls back to full rerender when index is invalid', () => { + assert.deepEqual( + getThumbnailRenderIndices({ slideCount: 3, dirtySlideIndex: 5 }), + [0, 1, 2], + ) +}) + +test('zoom toast hides after two seconds', () => { + assert.equal(ZOOM_STEP_PERCENT, 10) + assert.equal(ZOOM_TOAST_DURATION_MS, 2000) +}) + +test('preview notes follow active slide index', () => { + const payload = { + title: '中国传统文化', + sections: [ + { + title: '内容大纲', + pages: [ + { title: '第 1 页', speaker_notes: 'page-1 note' }, + { title: '第 2 页', speaker_notes: 'page-2 note' }, + ], + }, + ], + } + + assert.equal(resolveSpeakerNotes(payload, 1), 'page-2 note') +}) + +test('preview notes fall back to empty string when missing', () => { + assert.equal(resolveSpeakerNotes(null, 0), '') +}) + +test('pdf export layer stays offscreen before capture', () => { + assert.deepEqual(getPdfExportLayerPosition(false), { + left: '-99999px', + top: '0px', + zIndex: '-1', + }) +}) + +test('pdf export layer stays offscreen during capture because canvases are exported directly', () => { + assert.deepEqual(getPdfExportLayerPosition(true), { + left: '-99999px', + top: '0px', + zIndex: '-1', + }) +}) + +test('ppt download pre-opens a controllable window handle', () => { + const popup = { close() {} } + const calls = [] + + const result = openPendingPptDownloadWindow({ + openWindow(url, target, features) { + calls.push({ url, target, features }) + return popup + }, + }) + + assert.equal(result, popup) + assert.deepEqual(calls, [{ + url: 'about:blank', + target: '_blank', + features: undefined, + }]) +}) + +test('ppt download reuses pre-opened window when available', () => { + const popup = { location: { href: '' } } + const ok = triggerPptDownload('https://example.com/demo.pptx', { + presetWindow: popup, + }) + + assert.equal(ok, true) + assert.equal(popup.location.href, 'https://example.com/demo.pptx') +}) + +test('ppt download falls back to anchor click when popup is unavailable', () => { + const events = [] + const anchor = { + href: '', + target: '', + rel: '', + click() { + events.push('clicked') + }, + } + + const ok = triggerPptDownload('https://example.com/demo.pptx', { + openWindow() { + return null + }, + createAnchor() { + return anchor + }, + appendToBody(node) { + events.push(`append:${node === anchor}`) + }, + removeNode() { + events.push('removed') + }, + }) + + assert.equal(ok, true) + assert.equal(anchor.href, 'https://example.com/demo.pptx') + assert.deepEqual(events, ['append:true', 'clicked', 'removed']) +}) + +test('ppt download falls back to cached file url when latest request returns empty', () => { + assert.equal( + getPptDownloadUrl('', 'https://example.com/cached.pptx'), + 'https://example.com/cached.pptx', + ) + assert.equal( + getPptDownloadUrl('https://example.com/latest.pptx', 'https://example.com/cached.pptx'), + 'https://example.com/latest.pptx', + ) + assert.equal(getPptDownloadUrl(' ', ' '), '') +}) + +test('ppt download refreshes the current session result before requesting backend download', () => { + assert.match(lessonPrepPptSource, /getSessionDetail\(currentSessionId\.value\)/) + assert.match(lessonPrepPptSource, /latestSessionDetail\.results\?\.find\(r => r\.is_current\)/) + assert.match(lessonPrepPptSource, /await loadResultDetail\(latestResult\.id\)/) +}) + +test('pdf export uses canvas renderer instead of svg export layer', () => { + assert.match(lessonPrepPptSource, /import\s+\{\s*Ppt2Canvas\s*\}\s+from\s+'..\/utils\/docmee\/ppt2canvas\.js'/) + assert.match(lessonPrepPptSource, /import\(\s*'jspdf'\s*\)/) + assert.match(lessonPrepPptSource, /class="pdf-export-canvas"/) +})