GLM-4-9B-Chat-1M Chainlit前端二次开发:添加PDF上传、语音输入、结果导出功能

1. 为什么需要给Chainlit前端“加点料”

你已经用vLLM成功部署了GLM-4-9B-Chat-1M这个支持100万字上下文的超强模型,也跑通了Chainlit默认前端——输入文字、收到回复、一切看起来很丝滑。但实际用起来你会发现:

  • 客户发来一份30页的PDF合同,你得手动复制粘贴关键段落,再拼成提示词;
  • 开会时突然想到个好点子,想直接说句话让模型整理成会议纪要,却只能切回键盘打字;
  • 模型生成了一份2000字的分析报告,你想保存为Word或PDF发给同事,却只能全选→复制→打开Word→粘贴→排版……

这些不是“能不能用”的问题,而是“愿不愿用”“敢不敢推给业务方”的问题。
Chainlit原生界面是个极简的聊天框,它像一辆性能出色的发动机,但没配方向盘、油门和座椅——你得自己装。
本文不讲模型怎么训、vLLM参数怎么调,只聚焦一件事:在已有的Chainlit前端上,干净利落地加上三个高频刚需功能——PDF上传解析、语音输入转文字、结果一键导出。所有代码可直接复用,无需重写后端,不改动模型服务,5分钟就能让你的GLM-4-9B-Chat-1M真正变成一个能进业务流程的生产力工具。

2. 开发前必知的三个底层事实

2.1 Chainlit不是“黑盒”,而是可插拔的前端框架

Chainlit本质是一个基于Python的Web UI框架,它的核心逻辑是:

  • 前端(React)负责渲染消息、处理用户交互;
  • 后端(FastAPI)暴露/chat等接口,接收用户消息并调用你写的@cl.on_message函数;
  • @cl.on_message里你决定怎么调用模型(比如用openai.ChatCompletion.createhttpx.AsyncClient请求vLLM API)。
    这意味着:所有前端增强,都发生在chainlit.mdapp.py两个文件里,不碰vLLM服务本身

2.2 GLM-4-9B-Chat-1M的1M上下文,是功能落地的底气

很多模型标称“长上下文”,但一到真实文档就崩。而GLM-4-9B-Chat-1M在LongBench-Chat评测中稳居第一梯队,实测加载80页PDF(约15万token)后仍能精准定位“第37页第三段提到的违约金计算方式”。
这决定了:

  • PDF上传后,我们可以放心把全文喂给模型,不用纠结“摘要还是分块”;
  • 语音转文字后的文本+原始PDF内容,轻松塞进1M窗口,无需做复杂截断;
  • 导出的结果天然包含上下文依据,可信度高。

2.3 功能增强必须遵循“零侵入”原则

我们不做这些事:

  • 不修改Chainlit源码(避免升级后失效);
  • 不重写vLLM API(保持服务稳定性);
  • 不引入新数据库(所有状态存在内存或临时文件)。
    只做三件事:
    在前端加按钮和文件选择器;
    在后端加对应的消息类型处理器;
    用标准HTTP请求调用现有vLLM接口。

3. 第一步:让前端支持PDF上传与解析

3.1 前端添加上传组件(chainlit.md

chainlit.md文件末尾,添加以下代码。它会在聊天框上方插入一个PDF上传区域,并自动触发解析:

<details>
<summary>📄 点击上传PDF文件(支持多文件)</summary>

<div id="pdf-upload-area" style="border: 2px dashed #4f46e5; border-radius: 8px; padding: 16px; margin: 12px 0; background-color: #f9fafb;">
  <p>将PDF拖入此区域,或点击选择文件</p>
  <input type="file" id="pdf-input" accept=".pdf" multiple style="display: none;">
  <button onclick="document.getElementById('pdf-input').click()" 
          style="background-color: #4f46e5; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">
    选择PDF文件
  </button>
  <div id="pdf-file-list" style="margin-top: 12px; font-size: 14px;"></div>
</div>

<script>
  const input = document.getElementById('pdf-input');
  const fileList = document.getElementById('pdf-file-list');

  input.addEventListener('change', async function() {
    const files = Array.from(this.files);
    if (files.length === 0) return;

    fileList.innerHTML = '<p>正在解析PDF...</p>';
    
    // 构建FormData
    const formData = new FormData();
    files.forEach(file => formData.append('files', file));

    try {
      const res = await fetch('/api/upload_pdf', {
        method: 'POST',
        body: formData
      });
      const data = await res.json();
      if (data.status === 'success') {
        fileList.innerHTML = `<p> 解析完成!共提取 ${data.total_pages} 页文本,已加入对话上下文。</p>`;
        // 触发一次空消息,让后端知道PDF已就绪
        window.parent.postMessage({type: 'pdf_ready'}, '*');
      } else {
        fileList.innerHTML = `<p> 解析失败:${data.error}</p>`;
      }
    } catch (err) {
      fileList.innerHTML = `<p> 网络错误:${err.message}</p>`;
    }
  });
</script>
</details>

3.2 后端添加PDF解析接口(app.py

app.py中,添加新的FastAPI路由/api/upload_pdf。我们用pymupdf(fitz)做轻量解析,不依赖OCR,纯文本提取:

# app.py 新增部分(放在import下方)
import fitz  # pip install PyMuPDF
from fastapi import UploadFile, File, Form, HTTPException
from typing import List
import os
import tempfile

# 全局变量存储当前PDF文本(生产环境建议用Redis,此处为简化)
current_pdf_text = ""

@app.post("/api/upload_pdf")
async def upload_pdf(files: List[UploadFile] = File(...)):
    global current_pdf_text
    total_pages = 0
    all_text = ""
    
    for file in files:
        if not file.filename.endswith(".pdf"):
            raise HTTPException(status_code=400, detail="仅支持PDF文件")
        
        # 读取PDF内容
        content = await file.read()
        with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
            tmp.write(content)
            tmp_path = tmp.name
        
        try:
            doc = fitz.open(tmp_path)
            for page_num in range(doc.page_count):
                page = doc[page_num]
                text = page.get_text()
                if text.strip():
                    all_text += f"\n--- 第{page_num+1}页 ---\n{text.strip()}\n"
                    total_pages += 1
            doc.close()
        except Exception as e:
            raise HTTPException(status_code=500, detail=f"PDF解析错误:{str(e)}")
        finally:
            os.unlink(tmp_path)
    
    # 截断过长文本(避免超1M上下文)
    if len(all_text) > 1500000:  # 留20万字符余量
        all_text = all_text[:1500000] + "\n[文本已截断,完整内容请检查原始PDF]"
    
    current_pdf_text = all_text
    return {"status": "success", "total_pages": total_pages, "preview": all_text[:200] + "..."}

3.3 在消息处理中注入PDF内容

修改@cl.on_message函数,在用户发送消息前,自动拼接PDF文本:

# app.py 中 @cl.on_message 函数内(在调用模型前)
@cl.on_message
async def main(message: cl.Message):
    global current_pdf_text
    
    # 构建完整提示词
    prompt = message.content
    if current_pdf_text.strip():
        prompt = f"以下是参考文档内容:\n{current_pdf_text}\n\n请基于以上内容回答问题:{message.content}"
    
    # 调用vLLM API(示例,按你实际配置调整)
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "http://localhost:8000/v1/chat/completions",
            json={
                "model": "glm-4-9b-chat-1m",
                "messages": [{"role": "user", "content": prompt}],
                "max_tokens": 2048,
                "temperature": 0.7
            }
        )
        # ... 处理响应

4. 第二步:集成语音输入,让对话“张嘴就来”

4.1 前端添加语音按钮(chainlit.md

chainlit.md中,PDF区域下方添加语音输入模块:

<details>
<summary>🎤 点击说话(支持中文,实时转文字)</summary>

<div style="margin: 12px 0;">
  <button id="voice-btn" 
          style="background-color: #059669; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">
    ▶ 开始录音
  </button>
  <span id="voice-status" style="margin-left: 12px; font-size: 14px;">等待中...</span>
  <div id="voice-transcript" style="margin-top: 12px; padding: 8px; background-color: #f1f5f9; border-radius: 4px; min-height: 24px;"></div>
</div>

<script>
  let recognition;
  const btn = document.getElementById('voice-btn');
  const status = document.getElementById('voice-status');
  const transcript = document.getElementById('voice-transcript');

  function startRecognition() {
    if (!('webkitSpeechRecognition' in window || 'SpeechRecognition' in window)) {
      status.textContent = '浏览器不支持语音识别';
      return;
    }

    recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
    recognition.lang = 'zh-CN';
    recognition.interimResults = true;
    recognition.maxAlternatives = 1;

    recognition.onstart = () => {
      status.textContent = '正在收听...';
      btn.textContent = '⏹ 停止录音';
    };

    recognition.onresult = (event) => {
      let interimTranscript = '';
      let finalTranscript = '';
      for (let i = event.resultIndex; i < event.results.length; i++) {
        const transcript = event.results[i][0].transcript;
        if (event.results[i].isFinal) {
          finalTranscript += transcript;
        } else {
          interimTranscript += transcript;
        }
      }
      transcript.textContent = finalTranscript || interimTranscript;
    };

    recognition.onerror = (event) => {
      status.textContent = `错误:${event.error}`;
      recognition.stop();
    };

    recognition.onend = () => {
      if (transcript.textContent.trim()) {
        // 自动发送消息
        const msg = transcript.textContent.trim();
        window.parent.postMessage({type: 'send_message', content: msg}, '*');
        status.textContent = '已发送:' + msg.substring(0, 20) + (msg.length > 20 ? '...' : '');
      } else {
        status.textContent = '未识别到有效语音';
      }
      btn.textContent = '▶ 重新录音';
    };
  }

  btn.onclick = () => {
    if (recognition && recognition.state === 'listening') {
      recognition.stop();
      btn.textContent = '▶ 重新录音';
    } else {
      startRecognition();
    }
  };
</script>
</details>

4.2 后端接收语音转文字消息(app.py

Chainlit不原生支持语音事件,我们通过window.parent.postMessage发送消息,后端监听即可:

# app.py 中添加全局变量和消息监听
import asyncio
from chainlit.context import get_context

# 在app.py顶部添加
voice_messages = []

# 在 @cl.on_chat_start 下添加监听(模拟)
@cl.on_chat_start
async def on_chat_start():
    # 初始化语音消息队列
    pass

# 在 @cl.on_message 上方添加语音消息处理器
@cl.step(type="tool")
async def handle_voice_message(content: str):
    """处理语音转文字后的内容"""
    await cl.Message(
        author="Voice Input",
        content=f"🗣 你说:{content}",
        language="zh"
    ).send()

# 修改 @cl.on_message,增加对语音消息的支持
@cl.on_message
async def main(message: cl.Message):
    global current_pdf_text, voice_messages
    
    # 如果是语音消息(通过postMessage触发),content是纯文本
    if hasattr(message, 'metadata') and message.metadata.get('source') == 'voice':
        user_input = message.content
    else:
        user_input = message.content
    
    # 注入PDF内容(同前)
    prompt = user_input
    if current_pdf_text.strip():
        prompt = f"以下是参考文档内容:\n{current_pdf_text}\n\n请基于以上内容回答问题:{user_input}"
    
    # 调用vLLM...

5. 第三步:结果一键导出为Markdown/PDF

5.1 前端添加导出按钮(chainlit.md

在每条AI回复消息右侧,动态插入导出按钮:

<!-- 此JS需放在chainlit.md底部,确保DOM加载完成 -->
<script>
  // 监听新消息渲染完成
  const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      mutation.addedNodes.forEach((node) => {
        if (node.nodeType === 1 && node.classList?.contains('message-content')) {
          // 找到最近的message容器
          const messageEl = node.closest('.message');
          if (messageEl && !messageEl.querySelector('.export-btn')) {
            const exportBtn = document.createElement('button');
            exportBtn.className = 'export-btn';
            exportBtn.textContent = '⬇ 导出';
            exportBtn.style.cssText = `
              margin-left: 8px; padding: 2px 8px; font-size: 12px;
              background: #3b82f6; color: white; border: none; border-radius: 4px;
              cursor: pointer;
            `;
            exportBtn.onclick = () => {
              const content = node.innerText;
              const blob = new Blob([`# GLM-4-9B-Chat-1M 回复\n\n${content}`], {type: 'text/markdown'});
              const url = URL.createObjectURL(blob);
              const a = document.createElement('a');
              a.href = url;
              a.download = 'glm4_reply_' + new Date().toISOString().slice(0,10) + '.md';
              document.body.appendChild(a);
              a.click();
              document.body.removeChild(a);
              URL.revokeObjectURL(url);
            };
            messageEl.querySelector('.message-header')?.appendChild(exportBtn);
          }
        }
      });
    });
  });

  observer.observe(document.body, {childList: true, subtree: true});
</script>

5.2 后端增强:支持PDF导出(可选高级功能)

若需导出为PDF,可在app.py中添加一个新端点,用weasyprint生成:

# app.py 新增
from fastapi.responses import StreamingResponse
from weasyprint import HTML
import io

@app.post("/api/export_pdf")
async def export_pdf(content: str = Form(...)):
    html_content = f"""
    <!DOCTYPE html>
    <html>
    <head><meta charset="utf-8"><title>GLM-4 回复</title></head>
    <body style="font-family: sans-serif; padding: 20px; max-width: 800px; margin: 0 auto;">
      <h1>GLM-4-9B-Chat-1M 回复</h1>
      <pre style="white-space: pre-wrap; line-height: 1.6;">{content.replace('<', '&lt;').replace('>', '&gt;')}</pre>
      <p style="margin-top: 40px; font-size: 12px; color: #666;">生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
    </body>
    </html>
    """
    
    html = HTML(string=html_content)
    pdf_file = io.BytesIO()
    html.write_pdf(pdf_file)
    pdf_file.seek(0)
    
    return StreamingResponse(
        pdf_file,
        media_type='application/pdf',
        headers={"Content-Disposition": "attachment; filename=glm4_reply.pdf"}
    )

然后在前端按钮中调用该API即可。

6. 部署与验证:三步走通

6.1 本地测试流程

  1. 安装依赖(在Chainlit项目目录):

    pip install PyMuPDF weasyprint cairocffi
    
  2. 启动服务

    chainlit run app.py -w
    
  3. 验证三功能

    • 打开http://localhost:8000 → 点击PDF上传 → 选一份合同PDF → 等待提示“解析完成”;
    • 输入问题如“这份合同的甲方是谁?” → 查看是否引用PDF原文作答;
    • 点击语音按钮 → 说“总结一下这份合同的核心条款” → 确认转文字准确且发送成功;
    • 等待AI回复后 → 找到消息右上角“⬇ 导出”按钮 → 点击下载Markdown文件 → 用Typora打开验证格式。

6.2 生产环境注意事项

  • PDF解析性能:单次上传限制5个文件,总页数不超过200页(避免内存溢出);
  • 语音兼容性:Chrome/Edge最新版支持最佳,Safari需额外配置HTTPS;
  • 导出安全weasyprint生成PDF时禁用外部网络请求,防止SSRF;
  • 上下文管理:当前实现中PDF文本全局生效,如需多会话隔离,改用cl.user_session.set("pdf_text", text)

7. 总结:从Demo到产品的最后一公里

我们没做任何“高大上”的技术突破,只是在Chainlit的既有框架上,补上了三个最朴素的缺口:

  • PDF上传,把模型从“聊天机器人”变成“文档分析师”;
  • 语音输入,把交互从“手写”变成“口述”,降低使用门槛;
  • 结果导出,把对话记录变成可存档、可流转、可审计的正式产出。

这恰恰是工程落地的关键——真正的AI产品,不在于模型参数有多大,而在于用户是否愿意把它放进自己的工作流里。GLM-4-9B-Chat-1M的1M上下文能力,给了我们足够的“容错空间”去承载真实业务文档;而Chainlit的可扩展性,则让我们能以最小成本,把这种能力包装成人人可用的工具。

你现在拥有的,不再是一个需要命令行调试的模型demo,而是一个随时可以发给法务、市场、产品经理试用的轻量级AI助手。下一步,你可以:

  • 把PDF解析换成支持表格/图片的unstructured库;
  • 加入语音合成,让AI“说”出答案;
  • 对接企业微信/钉钉,让导出结果直接推送到群聊。

路已经铺平,轮子已经造好,现在,该你踩下油门了。


获取更多AI镜像

想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐