GLM-4-9B-Chat-1M Chainlit前端二次开发:添加PDF上传、语音输入、结果导出功能
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.create或httpx.AsyncClient请求vLLM API)。
这意味着:所有前端增强,都发生在chainlit.md和app.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('<', '<').replace('>', '>')}</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 本地测试流程
-
安装依赖(在Chainlit项目目录):
pip install PyMuPDF weasyprint cairocffi -
启动服务:
chainlit run app.py -w -
验证三功能:
- 打开
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星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。
更多推荐

所有评论(0)