)
一、图片上传功能扩展在流式对话的基础上我们新增了图片上传与识别功能。用户可以在输入框中上传图片配合文字描述一起发送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唯一标识用于删除、base64data URL 格式、name文件名fileInputRef指向隐藏的input typefile点击图标时触发它的 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} typefile acceptimage/* 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 发送ShiftEnter 换行...} rows{1} / /div {/* 发送/停止按钮 */} {isStreaming ? Button className{styles.sendBtn} danger icon{StopOutlined /} onClick{handleStop} / : Button className{styles.sendBtn} typeprimary 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.jsstreamChat方法新增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.pydef get_chat_model(visionFalse): 创建聊天模型visionTrue 时使用支持图片的视觉模型 return ChatOpenAI( modelqwen-vl-plus if vision else qwen-plus, openai_api_keyos.getenv(DASHSCOPE_API_KEY), openai_api_basehttps://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(contentcontent)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_raguse_rag) # 构建用于展示的 content文字 图片 URL 标记 display_content user_message if images: display_content json.dumps({text: user_message, images: images}, ensure_asciiFalse) 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 fdata: {json.dumps({token: char}, ensure_asciiFalse)}\n\n.encode(utf-8) else: # 构建消息历史 history [SystemMessage(contentSYSTEM_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(contentmsg[content])) except (json.JSONDecodeError, TypeError): history.append(HumanMessage(contentmsg[content])) elif msg[role] assistant: history.append(AIMessage(contentmsg[content])) # 当前消息如果有图片则用多模态格式 if images: history.append(_build_image_message(user_message, images)) else: history.append(HumanMessage(contentuser_message)) llm get_chat_model(visionbool(images)) for chunk in llm.stream(history): token chunk.content if token: full_reply token yield fdata: {json.dumps({token: token}, ensure_asciiFalse)}\n\n.encode(utf-8) if full_reply: save_message(conv_id, assistant, full_reply) yield fdata: {json.dumps({done: True, session_id: conv_id}, ensure_asciiFalse)}\n\n.encode(utf-8) except Exception as e: import traceback traceback.print_exc() yield fdata: {json.dumps({error: str(e)}, ensure_asciiFalse)}\n\n.encode(utf-8) resp Response( stream_with_context(generate()), mimetypetext/event-stream, ) resp.headers[Cache-Control] no-cache resp.headers[X-Accel-Buffering] no resp.headers[Connection] keep-alive return resp2.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?spm1001.2014.3001.5501