基于 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 状态,存储待发送的图片列表:

javascript 复制代码
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
图片上传处理
javascript 复制代码
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,清空后才能重复选择
删除已上传图片
javascript 复制代码
const removeImage = (id) => {
  setPendingImages((prev) => prev.filter((img) => img.id !== id));
};
发送逻辑(改动部分)

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

javascript 复制代码
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 结构
javascript 复制代码
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 上:

css 复制代码
.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 图片预览样式

css 复制代码
.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 字段,在文字上方显示图片:

javascript 复制代码
{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..."]}),加载历史时需要解析还原:

javascript 复制代码
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 参数,传给后端:

javascript 复制代码
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

python 复制代码
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)
python 复制代码
@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

相关推荐
打小就很皮...4 小时前
基于 Python + LangChain + React 的 AI 流式对话与历史存储实战
人工智能·langchain·flask·react·sse
AI技术控7 小时前
NeuroH-TGL 论文解读:面向脑疾病诊断的神经异质性引导时序图学习方法
人工智能·语言模型·自然语言处理·langchain·nlp
完成大叔8 小时前
从脚本到Agent:工具模式下的智能价值
人工智能·langchain
糖果店的幽灵8 小时前
Part 2: Models(模型)
microsoft·langchain
Fanxt_Ja10 小时前
Langchain+Tavily对接大模型实现联网搜索
python·langchain·deepseek·tavily
Richown10 小时前
后端架构:事件驱动架构设计与实现
区块链·react
爱编程的小新☆11 小时前
JAVA实现Manus智能体
java·react·cot·智能体·spring ai·manus·agent loop
_山海11 小时前
用langchain 通过text-embedding-3-small生成embedding
python·langchain·llm
踏着七彩祥云的小丑12 小时前
AI——LangChain 三大核心概念
人工智能·ai·langchain