当前位置: 首页 > news >正文

基于 Python + LangChain + React 的 AI 流式对话与历史存储实战(拓展图片上传)

一、图片上传功能扩展

在流式对话的基础上,我们新增了图片上传与识别功能。用户可以在输入框中上传图片,配合文字描述一起发送,AI 会基于图片内容进行分析回答。

1.1 功能效果

  • 输入框内部右侧显示上传图标,点击选择图片(支持多选)
  • 上传后在输入框上方显示缩略图预览,支持逐张删除
  • 发送后图片显示在用户消息气泡中,AI 流式输出图片分析结果
  • 切换对话后,历史图片消息也能正常还原显示

1.2 整体数据流

用户点击上传图标 ↓ FileReader.readAsDataURL() → base64 编码 ↓ 存入 pendingImages 状态(预览区显示缩略图) ↓ 用户点击发送 ↓ pendingImages + inputValue → 构造带 images 字段的消息 ↓ chatService.streamChat(text, sessionId, useRag, callbacks, images) ↓ HTTP POST body: { message, images: ["data:image/...;base64,..."] } ↓ 后端收到 images → 切换 qwen-vl-plus 视觉模型 → 构造多模态 HumanMessage ↓ SSE 流式返回 AI 对图片的分析结果

1.3 输入框组件实现(ChatInput.jsx)

这是图片上传功能的核心,所有上传、预览、发送逻辑都在这个组件中。

状态定义

在原有基础上新增pendingImages状态,存储待发送的图片列表:

export default function ChatInput() { const [inputValue, setInputValue] = useState(''); const [pendingImages, setPendingImages] = useState([]); // [{id, base64, name}] const textareaRef = useRef(null); const fileInputRef = useRef(null); // 隐藏的文件选择框引用 const abortRef = useRef(null);
  • pendingImages是一个数组,每项包含id(唯一标识,用于删除)、base64(data URL 格式)、name(文件名)
  • fileInputRef指向隐藏的<input type="file">,点击图标时触发它的 click
图片上传处理
const handleImageUpload = (e) => { const files = Array.from(e.target.files || []); files.forEach((file) => { // 校验文件类型 if (!file.type.startsWith('image/')) { message.warning(`${file.name} 不是图片文件`); return; } // 校验文件大小(限制 10MB) if (file.size > 10 * 1024 * 1024) { message.warning(`${file.name} 超过 10MB 限制`); return; } // 使用 FileReader 读取为 base64 Data URL const reader = new FileReader(); reader.onload = (ev) => { setPendingImages((prev) => [ ...prev, { id: Date.now() + Math.random(), // 唯一 ID base64: ev.target.result, // data:image/png;base64,... name: file.name, }, ]); }; reader.readAsDataURL(file); }); // 重置 input,支持重复选择同一文件 if (fileInputRef.current) fileInputRef.current.value = ''; };

关键点:

  • FileReader.readAsDataURL()将图片文件转为data:image/xxx;base64,...格式的字符串
  • 这个 base64 字符串可以直接作为<img src>显示预览,也可以直接发给支持视觉的 LLM
  • 重置input.value是因为浏览器在选了同一文件后不会触发onChange,清空后才能重复选择
删除已上传图片
const removeImage = (id) => { setPendingImages((prev) => prev.filter((img) => img.id !== id)); };
发送逻辑(改动部分)

发送时把pendingImages中的 base64 一并传给后端:

const handleSend = useCallback(() => { const text = inputValue.trim(); const images = pendingImages.map((img) => img.base64); if ((!text && images.length === 0) || isStreaming) return; const displayText = text || '请描述这些图片的内容'; // 清空输入和预览 setInputValue(''); setPendingImages([]); // 添加用户消息(带 images 字段) addMessage({ id: Date.now(), role: 'user', content: displayText, images: images.length > 0 ? images : undefined, createdAt: new Date().toISOString(), }); startStreaming(); // 调用流式接口,传入 images 数组 abortRef.current = chatService.streamChat( displayText, convId, useRag, { onToken: (token) => appendStreamContent(token), onDone: (sessionId) => { /* ... */ }, onError: (err) => { /* ... */ }, }, images, // 新增:图片 base64 数组 ); }, [inputValue, pendingImages, /* ... */]);
JSX 结构
return ( <div className={styles.inputArea}> {/* 图片预览区 —— 输入框上方 */} {pendingImages.length > 0 && ( <div className={styles.imagePreviewBar}> {pendingImages.map((img) => ( <div key={img.id} className={styles.imagePreviewItem}> <img src={img.base64} alt={img.name} /> <div className={styles.imageRemove} onClick={() => removeImage(img.id)}> <CloseOutlined /> </div> </div> ))} </div> )} <div className={styles.inputWrapper}> {/* 隐藏的文件选择 */} <input ref={fileInputRef} type="file" accept="image/*" multiple style={{ display: 'none' }} onChange={handleImageUpload} /> {/* 输入框容器(含内部图标) */} <div className={styles.textareaWrap}> <PictureOutlined className={styles.innerUploadIcon} onClick={() => !isStreaming && fileInputRef.current?.click()} /> <textarea ref={textareaRef} className={styles.textarea} value={inputValue} onChange={handleChange} onKeyDown={handleKeyDown} placeholder={pendingImages.length > 0 ? "添加描述后发送..." : "输入消息,Enter 发送,Shift+Enter 换行..."} rows={1} /> </div> {/* 发送/停止按钮 */} {isStreaming ? <Button className={styles.sendBtn} danger icon={<StopOutlined />} onClick={handleStop} /> : <Button className={styles.sendBtn} type="primary" icon={<SendOutlined />} onClick={handleSend} disabled={!inputValue.trim() && pendingImages.length === 0} /> } </div> </div> );

1.4 上传图标定位(CSS 关键实现)

图标在输入框内部右侧,使用position: absolute叠加在textarea上:

.textareaWrap { flex: 1; position: relative; // 定位容器 display: flex; align-items: flex-end; } .innerUploadIcon { position: absolute; right: 10px; // 固定在右侧 bottom: 12px; // 底部对齐 font-size: 20px; color: #999; cursor: pointer; z-index: 1; transition: color 0.2s; &:hover { color: #1677ff; } } .textarea { width: 100%; padding: 10px 38px 10px 14px; // 右侧留 38px 给图标 // ...其他样式 }

1.5 图片预览样式

.imagePreviewBar { max-width: 800px; margin: 0 auto 10px; display: flex; gap: 8px; flex-wrap: wrap; } .imagePreviewItem { position: relative; width: 72px; height: 72px; border-radius: 8px; overflow: hidden; border: 1px solid #e8e8e8; img { width: 100%; height: 100%; object-fit: cover; // 裁剪填充 } } .imageRemove { position: absolute; top: 2px; right: 2px; width: 20px; height: 20px; background: rgba(0, 0, 0, 0.5); border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; color: #fff; font-size: 10px; &:hover { background: rgba(0, 0, 0, 0.75); } }

1.6 消息气泡中渲染图片(ChatMessages.jsx)

在用户消息气泡中,如果有images字段,在文字上方显示图片:

{msg.images && msg.images.length > 0 && ( <div className={styles.messageImages}> {msg.images.map((src, idx) => ( <img key={idx} src={src} alt={`upload-${idx}`} className={styles.messageImage} /> ))} </div> )} {msg.role === 'assistant' ? <ReactMarkdown>{msg.content}</ReactMarkdown> : msg.content }

1.7 历史消息图片还原(Chat.jsx)

带图片的用户消息在后端以 JSON 格式存储({"text":"...", "images":["base64..."]}),加载历史时需要解析还原:

chatService.getMessages(currentConversationId) .then((msgs) => { setMessages(msgs.map((m) => { let content = m.content; let images = undefined; // 检测是否为带图片的 JSON 消息 if (m.role === 'user' && m.content.startsWith('{')) { try { const parsed = JSON.parse(m.content); if (parsed.text !== undefined && parsed.images) { content = parsed.text; images = parsed.images; } } catch { /* 非 JSON,按普通文本处理 */ } } return { id: m.id, role: m.role, content, images, createdAt: m.createdAt }; })); })

1.8 API 层改动(chatService.js)

streamChat方法新增images参数,传给后端:

streamChat(message, sessionId, useRag, callbacks, images = []) { // ... fetch(`${BASE_URL}/api/chat/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, session_id: sessionId || null, use_rag: useRag, images, // base64 图片数组 }), signal: controller.signal, }) // ... }

1.9 后端(server.py)

def get_chat_model(vision=False): """创建聊天模型,vision=True 时使用支持图片的视觉模型""" return ChatOpenAI( model="qwen-vl-plus" if vision else "qwen-plus", openai_api_key=os.getenv("DASHSCOPE_API_KEY"), openai_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1", ) def _build_image_message(text, images): """构建多模态消息(文字 + 图片)""" content = [{"type": "text", "text": text}] for img in images: content.append({ "type": "image_url", "image_url": {"url": img}, }) return HumanMessage(content=content)
@app.route("/api/chat/stream", methods=["POST"]) def chat_stream(): """SSE 流式聊天接口""" data = request.json user_message = data.get("message", "").strip() session_id = data.get("session_id") use_rag = data.get("use_rag", False) images = data.get("images", []) # base64 图片列表 if not user_message and not images: return jsonify({"error": "消息不能为空"}), 400 # 如果只有图片没有文字,给一个默认提示 if not user_message and images: user_message = "请描述这些图片的内容" def generate(): full_reply = "" conv_id = None try: conv_id = get_or_create_conversation(session_id, use_rag=use_rag) # 构建用于展示的 content(文字 + 图片 URL 标记) display_content = user_message if images: display_content = json.dumps({"text": user_message, "images": images}, ensure_ascii=False) save_message(conv_id, "user", display_content) existing = get_messages(conv_id) if len(existing) <= 1: title = user_message[:20] + ("..." if len(user_message) > 20 else "") update_title(conv_id, title) if use_rag: result = rag_query(user_message) reply = result["reply"] for char in reply: full_reply += char yield f"data: {json.dumps({'token': char}, ensure_ascii=False)}\n\n".encode("utf-8") else: # 构建消息历史 history = [SystemMessage(content=SYSTEM_PROMPT)] for msg in existing: if msg["role"] == "user": # 尝试解析带图片的消息 try: parsed = json.loads(msg["content"]) if isinstance(parsed, dict) and parsed.get("images"): history.append(_build_image_message(parsed.get("text", ""), parsed["images"])) else: history.append(HumanMessage(content=msg["content"])) except (json.JSONDecodeError, TypeError): history.append(HumanMessage(content=msg["content"])) elif msg["role"] == "assistant": history.append(AIMessage(content=msg["content"])) # 当前消息:如果有图片则用多模态格式 if images: history.append(_build_image_message(user_message, images)) else: history.append(HumanMessage(content=user_message)) llm = get_chat_model(vision=bool(images)) for chunk in llm.stream(history): token = chunk.content if token: full_reply += token yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n".encode("utf-8") if full_reply: save_message(conv_id, "assistant", full_reply) yield f"data: {json.dumps({'done': True, 'session_id': conv_id}, ensure_ascii=False)}\n\n".encode("utf-8") except Exception as e: import traceback traceback.print_exc() yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n".encode("utf-8") resp = Response( stream_with_context(generate()), mimetype="text/event-stream", ) resp.headers["Cache-Control"] = "no-cache" resp.headers["X-Accel-Buffering"] = "no" resp.headers["Connection"] = "keep-alive" return resp

2.0 小结

图片上传功能的前端核心实现可以归纳为以下几点:

环节技术方案
图片读取FileReader.readAsDataURL()转 base64 Data URL
预览显示base64 直接作为<img src>
输入框内图标position: absolute叠加在textarea右侧,padding-right留空间
发送给后端base64 字符串数组放入 POST body 的images字段
消息中显示消息对象带images字段,渲染时在文字上方展示图片
历史还原后端 JSON 存储的文字 + 图片,加载时JSON.parse拆分还原
数据库适配content列从TEXT(64KB) 改为MEDIUMTEXT(16MB),因为 base64 很大

注意⚠:图片上传可通过上传到OSS实现存储、解析等操作,这样属于是规范的功能开发!!!
可见https://blog.csdn.net/qq_70172010/article/details/157650348?spm=1001.2014.3001.5501

http://www.jsqmd.com/news/869314/

相关文章:

  • 龙讯新产品
  • 2026庆阳市最新黄金 白银 铂金 彩金回收收门店实力排行榜及联系方式推荐 - 大熊猫898989
  • Arm Development Studio静默安装与自动化部署指南
  • spring boot 11
  • 从钻孔记录到三维模型:Grapher与Surfer应用实践
  • 小程序上线需要的资质证书汇总
  • 2026曲靖市最新黄金 白银 铂金 彩金回收收门店实力排行榜及联系方式推荐 - 大熊猫898989
  • 人员定位系统技术方案:主流定位技术对比与选型到架构方案
  • UHF-RFID多普勒运动检测技术解析与应用
  • 为什么高级的棕色,永远是大自然原创,而非工厂复刻?
  • SMMU事务属性转换机制与调试实践
  • Arm Development Studio中手动注册Arm Compiler for Embedded指南
  • 2026淮南市最新黄金 白银 铂金 彩金回收收门店实力排行榜及联系方式推荐 - 大熊猫898989
  • 2026衢州市最新黄金 白银 铂金 彩金回收收门店实力排行榜及联系方式推荐 - 大熊猫898989
  • Python安装与环境变量配置
  • 已存在9年的 Linux Kernel 漏洞可导致执行 root 命令
  • 元器件选型太难?解锁硬件工程师参数高效对比技巧
  • 随机短文分享
  • 推荐具备DPIA协同验证能力的代码审计服务公司:如何甄选真正的技术合规整合者
  • 2026黄冈市最新黄金 白银 铂金 彩金回收收门店实力排行榜及联系方式推荐 - 大熊猫898989
  • 从6个月到2周:EOR名义雇主如何重塑企业全球化用工的时间与成本逻辑
  • 关于人工智能应用工程师认证的价值分析与职业发展建议
  • Keil MDK中FlexNet许可证错误-7,10015和-4的解决方案
  • 2026泉州市最新黄金 白银 铂金 彩金回收收门店实力排行榜及联系方式推荐 - 大熊猫898989
  • 2026贵港市最新黄金 白银 铂金 彩金回收收门店实力排行榜及联系方式推荐 - 大熊猫898989
  • 2026黄山市最新黄金 白银 铂金 彩金回收收门店实力排行榜及联系方式推荐 - 大熊猫898989
  • 2026年AI编程工具综合对比:主流工具横评
  • 2026贵阳市最新黄金 白银 铂金 彩金回收收门店实力排行榜及联系方式推荐 - 大熊猫898989
  • 2026日照市最新黄金 白银 铂金 彩金回收收门店实力排行榜及联系方式推荐 - 大熊猫898989
  • AI模型运行时鲁棒性与公平性监测技术解析