Files
gangyan/scripts/pptist-ai-backend.py
liuguancen 108022cebd feat: 品牌升级(知冶→战知) + 应用工具广场重构 + 新增工具集成
品牌升级:
- 全站品牌从"知冶"更名为"战知"
- 更换 favicon、侧边栏 logo、登录页 logo
- 更新登录页标语和首页欢迎语

应用广场重构:
- 从后端数据库驱动改为前端静态配置,按分类 tab 展示
- 新增工具卡片 UI,支持 logo 图片和 emoji 图标

新增工具部署:
- Stirling PDF (端口18080) - PDF 处理工具箱
- Excalidraw (端口18081) - 手绘风格白板,集成 AI 绘图
- TrWebOCR (端口18083) - 中文离线 OCR
- LibreTranslate (端口18084) - 中英翻译引擎
- PPTist (端口18085) - 在线 PPT 编辑器
- PPTist AI 后端 (端口18086) - 对接 deepseek-v3 生成大纲/PPT/写作
- Excalidraw AI 代理 (端口18082) - 对接 deepseek-v3 生成 Mermaid 图

其他:
- 智能场景仅保留"选题推荐"
- vite 代理配置增加 /pdf/ 和 /draw/ 路由

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:24:51 +08:00

240 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
PPTist AI 后端服务
接收 PPTist 的 AIPPT 请求,调用 deepseek-v3 生成大纲和PPT内容
端口: 18086
"""
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
import httpx
import json
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
LLM_API = "http://10.102.24.75:3000/v1/chat/completions"
LLM_KEY = "sk-BlQIGRrotbVDWE5mXCPBFjVWIvJ83hldzz67xInNwzVo7pPb"
LLM_MODEL = "deepseek-v3"
OUTLINE_SYSTEM_PROMPT = """你是一个专业的PPT大纲生成助手。用户会给你一个主题你需要生成一份内容丰富、结构清晰的PPT大纲。
要求:
1. 使用Markdown格式用 # 表示演示文稿标题,## 表示章节标题,### 表示该章节下的内容页标题
2. 必须包含6个主要章节##
3. 每个章节下必须有2-3个内容页###
4. 每个内容页标题要具体、有信息量,不要太笼统
5. 内容要专业、逻辑清晰、层层递进
6. 直接输出Markdown内容不要有多余的解释
示例格式:
# 人工智能技术发展
## 人工智能概述
### 定义与发展历史
### 核心技术与分支
## 机器学习基础
### 监督学习与无监督学习
### 深度学习与神经网络
### 常用算法对比
..."""
AIPPT_SYSTEM_PROMPT = """你是一个专业的PPT内容生成助手。用户会给你一份PPT大纲你需要根据大纲生成完整、丰富、详实的PPT内容。
你必须严格按照以下JSON数组格式输出不要输出任何其他内容。
结构规则:
1. 第一页cover类型包含演示标题和副标题描述
2. 第二页contents类型列出所有章节名称
3. 每个章节先一个transition过渡页然后每个###子内容对应一个content页
4. 每个content页必须包含4个items每个item有title和text
5. text内容必须详实具体每条20-40字包含实质性的数据、观点或分析不要空泛
6. 最后一页end类型
输出格式JSON数组每个元素是一页slide
[
{"type": "cover", "data": {"title": "演示标题", "text": "一句话描述演示主题和价值"}},
{"type": "contents", "data": {"items": ["章节1名称", "章节2名称", "章节3名称", "章节4名称", "章节5名称", "章节6名称"]}},
{"type": "transition", "data": {"title": "章节标题", "text": "本章将介绍什么内容,一句话概述"}},
{"type": "content", "data": {"title": "内容页标题", "items": [
{"title": "要点1标题", "text": "要点1的详细说明包含具体信息、数据或分析"},
{"title": "要点2标题", "text": "要点2的详细说明"},
{"title": "要点3标题", "text": "要点3的详细说明"},
{"title": "要点4标题", "text": "要点4的详细说明"}
]}},
{"type": "end"}
]
重要:
- 生成的总页数应在20-25页之间
- 只输出JSON数组不要markdown代码块不要任何解释
- 确保JSON格式完全正确可被直接解析"""
async def stream_llm(messages, temperature=0.7, max_tokens=4096):
"""流式调用LLM并逐块返回文本"""
async with httpx.AsyncClient(timeout=120) as client:
async with client.stream(
"POST",
LLM_API,
json={
"model": LLM_MODEL,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True,
},
headers={"Authorization": f"Bearer {LLM_KEY}"},
) as resp:
async for line in resp.aiter_lines():
if line.startswith("data: "):
data = line[6:]
if data.strip() == "[DONE]":
break
try:
chunk = json.loads(data)
delta = chunk["choices"][0].get("delta", {})
content = delta.get("content", "")
if content:
yield content
except (json.JSONDecodeError, KeyError, IndexError):
continue
@app.post("/tools/aippt_outline")
async def aippt_outline(request: Request):
data = await request.json()
content = data.get("content", "")
language = data.get("language", "中文")
print(f"[AIPPT] Outline request: {content}, language: {language}")
user_prompt = f"请用{language}为以下主题生成PPT大纲\n\n{content}"
async def generate():
async for text in stream_llm(
[
{"role": "system", "content": OUTLINE_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.7,
max_tokens=2048,
):
yield text
return StreamingResponse(generate(), media_type="text/event-stream")
@app.post("/tools/aippt")
async def aippt(request: Request):
data = await request.json()
content = data.get("content", "")
language = data.get("language", "中文")
style = data.get("style", "通用")
print(f"[AIPPT] Generate request, style: {style}, language: {language}")
user_prompt = f"请根据以下大纲生成PPT内容风格为「{style}」,语言为{language}\n\n{content}"
async def generate():
"""流式收集LLM输出按JSON对象级别缓冲每检测到一个完整对象就立即发送"""
buf = ""
brace_depth = 0
in_string = False
escape_next = False
found_array = False # 是否已经跳过了数组开头的 [
try:
async for chunk in stream_llm(
[
{"role": "system", "content": AIPPT_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.7,
max_tokens=8192,
):
for ch in chunk:
# 跳过数组开头的 [ 和结尾的 ] 以及逗号分隔符
if not found_array:
if ch == '[':
found_array = True
continue
if brace_depth == 0 and ch in (' ', '\n', '\r', '\t', ',', ']'):
continue
buf += ch
if escape_next:
escape_next = False
continue
if ch == '\\' and in_string:
escape_next = True
continue
if ch == '"' and not escape_next:
in_string = not in_string
continue
if in_string:
continue
if ch == '{':
brace_depth += 1
elif ch == '}':
brace_depth -= 1
if brace_depth == 0:
# 得到一个完整的JSON对象
try:
obj = json.loads(buf)
yield json.dumps(obj, ensure_ascii=False) + "\n"
print(f"[AIPPT] Sent slide: {obj.get('type', '?')}")
except json.JSONDecodeError as e:
print(f"[AIPPT] JSON parse error: {e}, buf: {buf[:100]}")
buf = ""
except Exception as e:
print(f"[AIPPT] Error: {e}")
yield json.dumps({"type": "cover", "data": {"title": "生成失败", "text": str(e)[:100]}}) + "\n"
yield json.dumps({"type": "end"}) + "\n"
return StreamingResponse(generate(), media_type="text/event-stream")
WRITING_COMMANDS = {
"rewrite": "请改写以下文本,保持原意但使用不同的表达方式:",
"expand": "请扩写以下文本,补充更多细节和内容:",
"abbreviate": "请缩写以下文本,保留核心要点,使其更简洁:",
"polish": "请润色以下文本,使其更流畅、专业:",
"translate": "请将以下文本翻译为英文:",
}
@app.post("/tools/ai_writing")
async def ai_writing(request: Request):
data = await request.json()
content = data.get("content", "")
command = data.get("command", "rewrite")
print(f"[AIPPT] Writing request, command: {command}")
instruction = WRITING_COMMANDS.get(command, WRITING_COMMANDS["rewrite"])
user_prompt = f"{instruction}\n\n{content}"
async def generate():
async for text in stream_llm(
[
{"role": "system", "content": "你是一个专业的文字助手,帮助用户改写、扩写、缩写、润色或翻译文本。直接输出结果,不要有额外解释。"},
{"role": "user", "content": user_prompt},
],
temperature=0.7,
max_tokens=2048,
):
yield text
return StreamingResponse(generate(), media_type="text/event-stream")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=18086)