我用 React + FastAPI + Gemini 做了一个企业级 PPT 生成器,踩了无数坑,现在开源了

238 阅读15分钟

logo.png

缘起:一个基金经理的吐槽

先聊聊为什么做这个项目。

我的一个朋友是基金经理,有天他跟我抱怨:"我脑子里有完整的投资逻辑,手里有详实的 Excel 数据,手机里还有刚开完的会议录音。但要把这些变成一份能上投决会的 PPT,我得花整整两天。"

作为开发者,我的第一反应是:这不就是一个 多模态输入 → 结构化输出 的问题吗?

然后我就开始做了。几个月后的今天,这个项目在金融、咨询等行业实际跑了一段时间,踩了无数坑,现在决定开源出来。

先看效果:

login.png

1-workbench.png

2-generating.png

GitHub 地址在文末,先聊聊技术实现。


整体架构

┌─────────────────────────────────────────────────┐
│                   Frontend (React 18)            │
│  输入面板 ← 对话式交互 → 预览面板(实时渲染)      │
└──────────────────────┬──────────────────────────┘
                       │ HTTP/REST
┌──────────────────────▼──────────────────────────┐
│               Backend (FastAPI 异步架构)          │
│                                                  │
│  ┌──────────┐  ┌──────────┐  ┌───────────────┐  │
│  │ ASR 模块  │  │ 文本推理  │  │  图片生成模块  │  │
│  │ (讯飞API) │  │(Gemini)  │  │  (Gemini)     │  │
│  └──────────┘  └──────────┘  └───────────────┘  │
│                                                  │
│  ┌──────────┐  ┌──────────┐  ┌───────────────┐  │
│  │ 文档抽取  │  │ 母版分析  │  │  素材嵌入引擎  │  │
│  │(PDF/Word │  │(Style    │  │  (图片+表格)   │  │
│  │ /Excel)  │  │ Transfer)│  │               │  │
│  └──────────┘  └──────────┘  └───────────────┘  │
│                                                  │
│  Session Manager → 状态机驱动的多轮对话流程        │
└─────────────────────────────────────────────────┘

技术栈:

  • 前端:React 18,单文件 App.js(约 2300 行),纯函数组件 + Hooks
  • 后端:FastAPI 异步架构,模块化设计(9 个模块 + 主入口共约 2800 行)
  • AI:Google Gemini API(gemini-3-pro-preview 文本推理 + gemini-3-pro-image-preview 图片生成)
  • 语音:科大讯飞非实时转写 API(支持说话人分离)
  • 文档处理:PyMuPDF + python-docx + python-pptx + openpyxl

看起来不复杂,但魔鬼在细节里。下面聊聊几个核心技术难点。


难点一:多轮对话的状态管理

PPT 生成不是一个一次性的任务,而是一个多阶段、可回退的工作流

输入想法 → 生成大纲 → 迭代大纲 → 生成设计方案 → 迭代设计 → 逐页生成图片 → 单页微调

每个阶段都支持用户用自然语言修改,比如"第3页加个风险提示"、"配色再稳重一些"。

我用了一个状态机来管理整个流程:

# modules/session.py
class SessionStage:
    INPUT = "input"              # 用户输入想法
    OUTLINE = "outline"          # 生成大纲
    OUTLINE_REFINE = "outline_refine"  # 大纲迭代
    STYLE = "style"              # 生成设计风格
    STYLE_REFINE = "style_refine"      # 风格迭代
    GENERATE = "generate"        # 生成图片
    COMPLETE = "complete"        # 完成

每个 Session 维护完整的上下文,数据结构如下:

def get_session(session_id: str) -> dict:
    if session_id not in sessions:
        sessions[session_id] = {
            "stage": SessionStage.INPUT,
            "user_input": "",
            "outline_text": "", "outline_json": [],
            "style_text": "", "style_json": [],
            "generated_images": [],
            "reference_image_path": None,
            # 用户设置
            "page_count": None,           # 页数限制
            "page_instructions": "",       # 逐页说明
            "design_principles": DEFAULT_DESIGN_PRINCIPLES,
            # 多模态输入
            "audio_transcript": "",        # 录音转写文本
            "support_docs_text": "",       # 支持性文档文本
            "support_docs_files": [],      # 文档列表
            "page_materials": {},          # 页面素材 {page_index: [材料列表]}
        }
    return sessions[session_id]

关键设计:有一个统一的 /api/chat 端点,根据当前状态自动路由到对应阶段的处理逻辑,前端不需要关心当前处于哪个阶段:

# server.py - 统一对话入口
@app.post("/api/chat")
async def chat(request: UserInputRequest):
    session = get_session(request.session_id)
    stage = session["stage"]

    if stage == SessionStage.INPUT:
        return await generate_outline(request)
    elif stage in [SessionStage.OUTLINE, SessionStage.OUTLINE_REFINE]:
        # 支持自然语言确认:"确认"、"ok"、"满意" 等关键词自动推进
        refine_req = RefineRequest(session_id=request.session_id, feedback=request.content)
        result = await refine_outline(refine_req)
        if result.get("confirmed"):
            return await generate_style(request)
        return result
    elif stage in [SessionStage.STYLE, SessionStage.STYLE_REFINE]:
        # 同理,"生成"、"开始" 等关键词触发图片生成
        ...
    elif stage == SessionStage.COMPLETE:
        # 完成后支持 "修改第X页" 指令
        if "修改第" in request.content:
            match = re.search(r'修改第\s*(\d+)\s*页', request.content)
            ...

踩坑记录:大纲确认的阶段判断,最初是硬编码 if feedback == "确认",后来发现用户会说"ok"、"可以"、"没问题"、"通过"等各种表达。改成关键词列表匹配后体验好了很多。


难点二:Prompt Engineering —— 从"能用"到"好用"

这是整个项目花时间最多的地方。所有提示词模板集中在 modules/prompts.py 里管理。

大纲生成的 Prompt 设计

最初的 Prompt 很简单:"根据以下内容生成 PPT 大纲"。结果生成的大纲要么太泛("第一页:概述"),要么结构混乱。

最终的方案是结构化约束 + Few-shot 示例

# modules/prompts.py
OUTLINE_PROMPT_TEMPLATE = """请根据用户输入的PPT整体的思路,梳理出每一页的核心要点。

{page_constraint}
{page_instructions}

用户的想法:
{user_input}

【示例结果格式】

第1页:核心策略总览
页面标题:2026年核心策略:防风险 + 多交易
核心要点:
关键词一:防风险
    核心目标:防范尾部风险。
    应对措施:系统性迭代风控系统。
关键词二:多交易
    核心目标:获取绝对收益。
    应对措施:开发系统化的交易信号与策略。

请按照上述格式输出每一页的大纲。同时请输出JSON格式便于程序解析:
```json
{
    "pages": [{"page": 1, "theme": "...", "title": "...", "content": "..."}]
}

关键在于 Few-shot 示例用的是真实的金融场景(投资策略总览),而不是泛泛的"关于XX的介绍"。因为模型会模仿示例的深度和专业度。

图片生成的 Prompt 设计

这是最难的部分。Gemini 的图片生成能力很强,但"生成一张 PPT 页面"和"生成一张好看的图片"是完全不同的事情。

做法是先用文本模型生成每页的"设计规格说明书",再把说明书喂给图片模型:

# modules/prompts.py - 设计风格生成提示词
STYLE_GENERATION_PROMPT = """请帮我根据如下的PPT大纲,为每一页生成详细的设计方案和绘图提示词。

【配色方案规范】
{color_scheme_spec}

【字体规范】
{font_scheme_spec}

【设计原则】
{design_principles}

【PPT大纲】
{outline}

请为每一页生成:
1. 设计理念说明
2. 详细的图片生成提示词(Prompt),用于Gemini图片生成API

输出JSON格式:
{
    "pages": [
        {
            "page": 1,
            "theme": "页面主题",
            "design_concept": "设计理念说明",
            "prompt": "详细的图片生成提示词"
        }
    ]
}
"""

这里还内置了具体的参考 Prompt 示例(类似 few-shot),告诉模型"好的 PPT Prompt 长什么样",包含精确到色号的配色指令和布局描述。

踩坑记录:Gemini 生成图片中的中文文字经常出错(多字、少字、乱码)。解决方案是在 Prompt 里反复强调文字内容,并要求生成 4K 分辨率 以提高文字清晰度:

# modules/gemini_api.py
"generationConfig": {
    "responseModalities": ["TEXT", "IMAGE"],
    "imageConfig": {
        "aspectRatio": "16:9",
        "imageSize": "4K"
    }
}

这个问题目前没有完美解决,是 AI 图片生成的通病。


难点三:多模态输入 —— 不只是打字

这是和普通 Text-to-PPT 工具的核心区别。真实的企业场景中,用户的输入来源是多样的。

3.1 会议录音 → PPT

集成了科大讯飞非实时转写 API,支持说话人分离金融领域优化

# modules/asr.py
class XfyunASR:
    def upload(self, num_speaker=None):
        param_dict = {
            'appId': self.appid,
            'signa': self.signa,
            'ts': self.ts,
            'fileSize': file_len,
            'fileName': file_name,
            'duration': '200',
            'roleType': 1,    # 开启角色分离
            'pd': 'finance',  # 金融领域优化
        }
        if num_speaker is not None:
            param_dict['roleNum'] = num_speaker  # 支持用户指定说话人数
        ...

转写结果会区分说话人:

说话人1:我认为Q3的业绩超预期,主要受益于...
说话人2:但风险在于海外市场的不确定性...

然后在大纲生成时,录音转写内容和用户输入一起合并传入:

# server.py - 多源输入合并
combined_input = f"【用户输入的想法】\n{request.content}"
if audio_transcript:
    combined_input += f"\n\n【会议录音转写内容】\n{audio_transcript}"
if support_docs_text:
    combined_input += f"\n\n【支持性文档内容】\n{support_docs_text}"

3.2 支持性文档上传

支持 PDF / Word / PPT / Excel / TXT 五种格式,统一的文档抽取引擎:

# modules/doc_extract.py - 统一入口
def extract_text_from_document(file_path: str, filename: str) -> str:
    ext = Path(filename).suffix.lower()
    if ext == '.pdf':
        return extract_text_from_pdf(file_path)    # PyMuPDF
    elif ext in ['.docx', '.doc']:
        return extract_text_from_docx(file_path)    # python-docx(含表格抽取)
    elif ext in ['.pptx', '.ppt']:
        return extract_text_from_pptx(file_path)    # python-pptx
    elif ext in ['.xlsx', '.xls']:
        return extract_text_from_xlsx(file_path)     # openpyxl
    elif ext == '.txt':
        return open(file_path, 'r', encoding='utf-8').read()

Word 文档的处理特别做了表格抽取(不只是段落文本),因为很多研报的核心数据在表格里:

def extract_text_from_docx(file_path):
    doc = DocxDocument(file_path)
    text_parts = []
    for para in doc.paragraphs:
        if para.text.strip():
            text_parts.append(para.text)
    # 关键:也抽取表格中的文本
    for table in doc.tables:
        for row in table.rows:
            row_text = [cell.text.strip() for cell in row.cells if cell.text.strip()]
            if row_text:
                text_parts.append(" | ".join(row_text))
    return "\n".join(text_parts)

3.3 逐页素材嵌入 —— 表格和图片直接上页面

这是我花了很大精力做的功能。用户可以给指定页面上传素材(图片截图、Excel 表格、甚至直接粘贴表格文本),AI 会在生成该页时直接参考这些素材。

# server.py - 页面素材上传
@app.post("/api/page-material/upload")
async def upload_page_material(
    session_id: str = Form(...),
    page_index: int = Form(...),     # 指定第几页
    file: UploadFile = File(...),
    description: str = Form(default="")  # 用户对素材的说明
):
    # 支持图片和Excel两类
    if file_ext in image_extensions:
        material_type = "image"
    else:
        material_type = "table"
        table_text = extract_table_from_file(str(material_path), file.filename)  # 抽取表格内容

图片素材会作为 inline_data 直接传入 Gemini 的多模态接口,表格数据则转化为文本嵌入 Prompt。生成图片时,引擎会自动拼装:

# modules/gemini_api.py - 素材嵌入逻辑
if images_added > 0:
    material_prompts.append(f"""
【用户上传的图片素材 - 最高优先级】
附件中包含用户上传的 {images_added} 个图片素材。
请务必:
1. 将这些图片素材直接复制/嵌入到生成的PPT页面中
2. 保持素材的原始内容、比例和清晰度
3. 不要对图片素材进行总结、重新绘制或简化""")

if table_texts:
    material_prompts.append(f"""
【用户上传的表格数据 - 最高优先级】
请将表格数据完整呈现在PPT页面中,可以转换为美观的图表或数据可视化。
{combined_table_text}""")

效果可以看下面这张截图,表格数据被自动转成了专业的视觉呈现:

3-result-table.png


难点四:品牌风格的一致性 —— 两阶段风格迁移

企业用户最在意的是:生成的 PPT 不能一眼看出是 AI 做的,要符合公司的视觉规范。

我设计了三种模式:普通参考母版模式微调模式

母版模式(重点)

用户上传公司 PPT 母版截图,后端先用 Gemini 视觉模型 分析:

# modules/gemini_api.py - 母版分析
def analyze_template_design(image_path: str) -> dict:
    analysis_prompt = """你是一个专业的 PPT 设计分析师。
请分析这张PPT母版图片,返回 JSON:
{
    "colors": { "background": "#...", "primary": "#...", "secondary": "#...", ... },
    "fonts": { "title_style": "...", "body_style": "...", ... },
    "layout": { "title_position": "...", "has_header": true, ... },
    "background": { "type": "纯色/渐变/图片", "has_decorations": true, ... },
    "style_summary": "整体风格总结"
}"""
    
    # 关键:强制 JSON 输出模式
    payload = {
        ...
        "generationConfig": {
            "response_mime_type": "application/json"
        }
    }

分析结果会以精确的色号和布局参数注入到每一页的生成 Prompt 中:

full_prompt += f"""
【母版分析结果 - 必须严格执行】
■ 配色方案(必须使用这些精确色值):
  - 背景色: {colors.get('background')}
  - 主色调: {colors.get('primary')}
  - 辅助色: {colors.get('secondary')}
■ 布局结构:
  - 标题位置: {layout.get('title_position')}
  - 有顶部栏: {'是' if layout.get('has_header') else '否'}
■ 整体风格: {style_summary}
请严格按照以上规范生成,确保看起来像是同一套PPT模板的不同页面。"""

微调模式

生成完成后,点击任意一页输入修改意见(比如"把标题改成红色"),系统会把当前已生成的图片作为参考传入,只做局部修改:

# server.py - 微调并重新生成
@app.post("/api/page/refine-and-regenerate")
async def refine_page_and_regenerate(request: RefinePageRequest):
    # 获取当前已生成的图片作为微调基准
    current_image_path = generated_images[request.page_index]["image_path"]
    
    # 先用文本模型修改设计方案
    prompt = REFINE_PAGE_PROMPT.format(
        page_num=page_num,
        current_prompt=current_page.get("prompt"),
        user_feedback=request.feedback
    )
    response_text = await generate_text(prompt)
    
    # 再用图片模型基于当前图片微调
    refine_prompt = f"""【微调模式】请基于参考图片进行微调,
用户的修改意见是:{request.feedback}
仅修改用户提到的部分,其他元素保持与参考图一致。"""
    
    success = await generate_ppt_image(
        prompt=refine_prompt,
        reference_image_path=current_image_path,
        reference_type="refine"  # 微调模式标记
    )

难点五:图片压缩和输出优化

Gemini 返回的原始图片是 PNG 格式,单张可能 5-10MB,10 页 PPT 打包下来 50MB+,在线预览也很慢。

解决方案是生成后自动压缩为 JPEG:

# modules/gemini_api.py - 图片压缩
image_bytes = base64.b64decode(image_data)
img = Image.open(io.BytesIO(image_bytes))

# RGBA → RGB(处理透明通道)
if img.mode == 'RGBA':
    background = Image.new('RGB', img.size, (255, 255, 255))
    background.paste(img, mask=img.split()[3])
    img = background

# 保存为JPEG,质量85%(约500KB-1MB/张)
output_path_jpg = str(output_path).replace('.png', '.jpg')
img.save(output_path_jpg, 'JPEG', quality=85, optimize=True)

同时支持两种下载格式:

  • ZIP 打包 —— 所有页面图片
  • PDF 合并 —— 用 Pillow 直接拼接图片生成 PDF,无需额外依赖
# server.py - PDF导出
@app.get("/api/download/{session_id}/pdf")
async def download_ppt_pdf(session_id: str):
    first_image = valid_images[0]
    first_image.save(pdf_path, "PDF", save_all=True, 
                     append_images=valid_images[1:], resolution=150.0)

难点六:部署和超时的血泪史

核心问题:AI 图片生成很慢。单页 60-120 秒,10 页 PPT 总耗时可能超过 15 分钟。

Nginx 超时配置

默认的 60 秒 proxy_read_timeout 完全不够用:

location /api/ {
    proxy_pass http://127.0.0.1:8000/api/;
    proxy_connect_timeout 3600s;
    proxy_send_timeout 3600s;
    proxy_read_timeout 3600s;
    # 关键:禁用缓冲,让前端能实时感知进度
    proxy_buffering off;
}

踩坑记录:改了配置但不生效,排查了半天发现是 Nginx 没有正确 reload。后来发现要用 sudo nginx -T | grep proxy_read_timeout 来确认实际加载的配置,而不是看配置文件内容。

异步架构避免阻塞

FastAPI 是异步框架,但 requests.post() 是同步阻塞的。如果直接在 async def 里调用,会阻塞整个事件循环。

解决方案是用 asyncio.to_thread 把同步 HTTP 请求丢到线程池:

# modules/gemini_api.py
async def generate_text(prompt: str, thinking_level: str = "high"):
    return await asyncio.to_thread(_generate_text_sync, prompt, thinking_level)

async def generate_ppt_image(prompt, output_path, ...):
    return await asyncio.to_thread(_generate_ppt_image_sync, prompt, output_path, ...)

这样多个用户可以并行生成,不会互相阻塞。

重试和容错

AI API 不稳定是常态。所有外部调用都内置了重试逻辑,并且会把重试信息返回给前端:

for attempt in range(1, MAX_RETRIES + 1):
    try:
        response = requests.post(url, json=payload, headers=headers, timeout=180)
        if response.status_code == 200:
            if attempt > 1:
                retry_info = f"⚠️ 接口不稳定,已在第{attempt}次重试后成功"
            return True, retry_info
    except requests.exceptions.Timeout:
        last_error = "请求超时"
    except requests.exceptions.ConnectionError:
        last_error = "网络连接错误"
    
    if attempt < MAX_RETRIES:
        time.sleep(RETRY_DELAY)  # 等待后重试

return False, f"❌ 失败(已重试{MAX_RETRIES}次): {last_error}"

一些细节功能

前端可编辑大纲

大纲不仅可以通过对话修改,还支持前端直接编辑后同步到后端:

@app.post("/api/outline/update")
async def update_outline(request: OutlineUpdateRequest):
    session["outline_json"] = request.outline_json
    # 重新生成大纲文本以保持一致
    outline_text = "\n\n".join([
        f"【第{i+1}页】{page.get('title')}\n{page.get('content')}"
        for i, page in enumerate(request.outline_json)
    ])
    session["outline_text"] = outline_text

自定义配色和字体方案

不止内置的"商务简约"、"酷炫技术"风格,还支持完全自定义配色和字体:

# modules/prompts.py
def build_color_scheme_spec(color_scheme: dict) -> str:
    return f"""• 主色调 (Primary): {primary} —— 大标题、背景色块
• 辅助色 (Secondary): {secondary} —— 关键数据、图表高亮
• 强调色 (Accent): {accent} —— 警示、特别强调
【重要】请严格使用上述配色,不要使用其他颜色!"""

页码位置可配

page_number_position = template_settings.get("page_number_position", "bottom-center")
if page_number_position == "none":
    page_number_instruction = "页面不需要显示页码。"
elif page_number_position == "bottom-left":
    page_number_instruction = "左下角需要显示ppt页码。"
# ...

访问计数器 + 邀请码系统

内置了简单的访问统计和邀请码登录管理,登录记录持久化到 CSV,支持下载审计。对于想部署给团队内部用的场景很实用。


项目结构

slidebot-ai/
├── server.py              # FastAPI 后端主入口 (~1200行)
├── modules/               # 模块化后端
│   ├── config.py          # 配置常量(API Key、路径、默认配色)
│   ├── prompts.py         # 所有提示词模板(大纲、设计、微调)
│   ├── models.py          # Pydantic 数据模型
│   ├── session.py         # 会话状态管理
│   ├── gemini_api.py      # Gemini API 封装(文本/图片/母版分析)
│   ├── asr.py             # 科大讯飞语音转写
│   ├── doc_extract.py     # 文档文本抽取(PDF/Word/PPT/Excel/TXT)
│   ├── invite_codes.py    # 邀请码和登录管理
│   └── visit_counter.py   # 访问计数
├── frontend/
│   └── src/App.js         # React 单文件前端 (~2300行)
├── .env.example           # 环境变量模板
├── requirements.txt       # Python 依赖
└── invite_codes.json      # 邀请码配置

和初版的单文件 server.py 不同,2.0 做了模块化拆分:配置、提示词、API调用、会话管理各自独立,方便阅读和二开。


部署方式

# 1. 克隆代码
git clone https://github.com/tonyqinatcmu/SlideBot-AI.git
cd SlideBot-AI

# 2. 配置环境变量
cp .env.example .env
# 编辑 .env,填入 GEMINI_API_KEY(必需)和讯飞 API Key(可选)

# 3. 安装后端依赖
pip install -r requirements.txt

# 4. 构建前端
cd frontend && npm install && npm run build && cd ..

# 5. 启动
python server.py
# 访问 http://localhost:8001

只有 GEMINI_API_KEY 是必填的。讯飞 API Key 不填的话,录音转写功能会自动禁用,其他功能正常使用。


效果展示

不同行业、不同风格的生成效果:

4-result-tech-style.png

5-result-clean-style.png

6-result-architecture-style.png

result-4.png


已知问题和后续计划

诚实地说,目前还有一些未解决的问题:

  1. 中文文字渲染:Gemini 生成图片中的中文偶尔会出错,这是 AI 图片生成模型的通病
  2. 生成速度:单页 60-120 秒,10 页 PPT 需要 10-20 分钟
  3. 并发支持:Session 存在内存中,不支持多实例部署

后续计划:

  • 接入更多 AI 模型(OpenAI、Claude)
  • 支持 PPTX 格式导出(目前是图片格式)
  • 增加模版市场
  • 支持实时协作

写在最后

这个项目最大的收获不是代码本身,而是一个认知:

AI 生成的内容永远不会一次到位,真正的价值在于让迭代成本趋近于零。

做一版大纲要 30 秒,改一版也是 30 秒。做一页 PPT 要 1 分钟,改一页也是 1 分钟。当修改的成本足够低,用户就不再纠结"第一版够不够好",而是放心大胆地说"先来一版看看"。

GitHubgithub.com/tonyqinatcm…

如果觉得有帮助,欢迎 Star。有问题欢迎评论区聊,有 Bug 欢迎提 Issue。