基于React + FastAPI + LangChain + 通义千问的智能医疗问答系统
📌 文章摘要:
本文详细介绍了如何在前端通过 Fetch 实现与 FastAPI 后端的 流式响应通信,并支持图文多模态数据上传。通过构建 multipart/form-data
请求,配合 ReadableStream
实时读取 AI 回复内容,实现类似 ChatGPT 的对话效果。同时提供完整的接口调用代码和 UI 展示逻辑,帮助开发者快速搭建自然流畅的 AI 医疗助手交互界面。
正常提问
经过RAG处理提问
项目简介
AI医疗助手是一个结合了最新人工智能技术的医疗问答系统,旨在为用户提供准确、专业的医疗咨询服务。系统采用前后端分离架构,前端使用React构建友好的用户界面,后端使用FastAPI提供高性能的API服务,并结合LangChain框架和通义千问大语言模型提供智能问答能力。本项目是本人用于学习LangChain框架的练手项目,后续会继续完善。
核心功能
- 🩺 医疗问答:针对用户的医疗问题提供专业解答
- 💬 实时对话:流式响应,打字机效果,提升用户体验
- 🧠 上下文记忆:支持多轮对话,理解上下文信息
- 📚 知识检索:基于RAG技术,从医疗知识库中检索相关信息
- 🎨 美观界面:现代化的聊天UI,支持Markdown渲染
- 🔄 对话记忆:自动保存对话历史,支持会话恢复
技术栈
前端
- 框架:React 19 + TypeScript
- 状态管理:React Hooks
- 样式:CSS Modules
- 网络请求:Fetch API(支持流式响应)
- 组件:
- 自定义聊天界面
- Markdown渲染 (react-markdown)
- 弹窗组件
- 消息提示 (react-hot-toast)
后端
- 框架:FastAPI (Python)
- AI框架:LangChain 0.3.0
- 大语言模型:通义千问 (qwen-turbo/qwen-plus/qwen-max)
- 向量数据库:Chroma
- 文本嵌入:DashScope Embeddings
- 流式响应:SSE (Server-Sent Events)
- 文档处理:LangChain Text Splitters
系统架构
✅ 2. 核心功能组件代码展示
App.css
/* 基础布局样式 */.App {text-align: center;display: flex;flex-direction: column;height: 100vh;font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;background-color: #f8fbfd;
}/* 头部样式 */.App-header {background-color: #1e88e5;background-image: linear-gradient(135deg, #1e88e5 0%, #0d47a1 100%);min-height: 80px;display: flex;flex-direction: column;align-items: center;justify-content: center;color: white;box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);padding: 12px 20px;position: relative;
}.App-header::after {content: '';position: absolute;bottom: 0;left: 0;right: 0;height: 4px;background: linear-gradient(90deg, #29b6f6, #4fc3f7, #81d4fa);
}.logo-container {display: flex;align-items: center;gap: 10px;
}.logo-icon {font-size: 28px;
}.App-header h1 {margin: 0;font-size: 1.8rem;font-weight: 600;letter-spacing: 0.5px;
}.header-subtitle {font-size: 0.9rem;opacity: 0.9;margin-top: 4px;font-weight: 400;
}/* 主体内容样式 */main {flex-grow: 1;display: flex;flex-direction: column;max-width: 1100px;width: 100%;margin: 0 auto;padding: 0;position: relative;
}main>div {flex-grow: 1;height: 100%;border-radius: 12px;margin: 15px;overflow: hidden;box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);background-color: white;border: 1px solid #e0e6ed;
}/* 底部样式 */.App-footer {background-color: #f1f5f9;color: #64748b;font-size: 0.8rem;padding: 12px;text-align: center;border-top: 1px solid #e2e8f0;
}/* 响应式设计 */@media (max-width: 768px) {.App-header {padding: 10px;min-height: 60px;}.App-header h1 {font-size: 1.4rem;}.logo-icon {font-size: 24px;}main>div {margin: 8px;border-radius: 8px;}.header-subtitle {font-size: 0.8rem;}
}@media (max-width: 480px) {.App-header h1 {font-size: 1.2rem;}.logo-icon {font-size: 20px;}main>div {margin: 4px;border-radius: 6px;}.header-subtitle {display: none;}
}
MessageList.css
.message-list {display: flex;flex-direction: column;padding: 1rem;overflow-y: auto;flex: 1;max-height: calc(100vh - 170px);
}.message {margin-bottom: 1rem;display: flex;flex-direction: column;max-width: 80%;
}.user-message {align-self: flex-end;
}.assistant-message {align-self: flex-start;
}.message-content {padding: 0.8rem;border-radius: 8px;box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}.user-message .message-content {background-color: #e3f2fd;color: #0d47a1;
}.assistant-message .message-content {background-color: #f5f5f5;color: #333;
}.message-header {display: flex;justify-content: space-between;margin-bottom: 0.5rem;font-size: 0.8rem;color: #666;
}.message-role {font-weight: bold;
}.message-time {font-size: 0.7rem;
}.message-text {white-space: pre-wrap;word-break: break-word;line-height: 1.5;
}.empty-messages {display: flex;flex-direction: column;align-items: center;justify-content: center;height: 100%;color: #9e9e9e;text-align: center;padding: 2rem;
}.empty-messages p {font-size: 1.1rem;margin-bottom: 1rem;
}
ChatInput.css
.chat-input-form {padding: 1rem;border-top: 1px solid #eaeaea;background-color: #fff;
}.input-container {display: flex;position: relative;
}.message-input {flex: 1;min-height: 60px;max-height: 200px;padding: 12px;border: 1px solid #ccc;border-radius: 8px;resize: vertical;font-family: inherit;font-size: 1rem;outline: none;transition: border-color 0.3s;
}.message-input:focus {border-color: #2196f3;
}.message-input:disabled {background-color: #f9f9f9;cursor: not-allowed;
}.send-button {margin-left: 10px;padding: 0 20px;height: 40px;align-self: flex-end;background-color: #2196f3;color: white;border: none;border-radius: 8px;cursor: pointer;font-weight: bold;transition: background-color 0.3s;
}.send-button:hover:not(:disabled) {background-color: #0d8bf2;
}.send-button:disabled {background-color: #cccccc;cursor: not-allowed;
}.input-help-text {margin-top: 0.5rem;font-size: 0.75rem;color: #757575;text-align: right;
}
ChatInterface.css
.chat-container {display: flex;flex-direction: column;/* height: 100vh; */width: 100%;max-height: 86vh;background-color: #f8fbfd;position: relative;overflow: hidden;
}/* 聊天历史区域 */.messages-container {flex: 1;overflow-y: auto;padding: 2rem;padding-bottom: 2rem;display: flex;flex-direction: column;gap: 1rem;scrollbar-width: thin;scrollbar-color: rgba(0, 0, 0, 0.2) transparent;scroll-behavior: smooth;background-image: linear-gradient(rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.9) 100%), url('data:image/svg+xml;utf8,<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M10 10h10v10H10zM30 10h10v10H30zM50 10h10v10H50zM70 10h10v10H70zM20 20h10v10H20zM40 20h10v10H40zM60 20h10v10H60zM80 20h10v10H80zM10 30h10v10H10zM30 30h10v10H30zM50 30h10v10H50zM70 30h10v10H70z" fill="%23E3F2FD" fill-opacity="0.1"/></svg>');
}/* 美化滚动条 */.messages-container::-webkit-scrollbar {width: 6px;
}.messages-container::-webkit-scrollbar-track {background: transparent;
}.messages-container::-webkit-scrollbar-thumb {background-color: rgba(0, 0, 0, 0.2);border-radius: 3px;
}.messages-container::-webkit-scrollbar-thumb:hover {background: #a1c4e4;
}/* 消息气泡容器 */.message {max-width: 85%;display: flex;flex-direction: column;position: relative;animation: fadeIn 0.3s ease-out;
}@keyframes fadeIn {from {opacity: 0;transform: translateY(10px);}to {opacity: 1;transform: translateY(0);}
}.message.user {align-self: flex-end;
}.message.assistant {align-self: flex-start;
}.message.system {align-self: center;max-width: 90%;margin: 8px 0;
}/* 消息气泡 */.message-bubble {padding: 14px 16px;border-radius: 18px;word-break: break-word;line-height: 1.5;position: relative;font-size: 15px;letter-spacing: 0.2px;transition: all 0.2s ease;text-align: left;
}/* 用户消息气泡 */.user .message-bubble {background-color: #0d6efd;color: white;border-bottom-right-radius: 4px;box-shadow: 0 2px 8px rgba(13, 110, 253, 0.2);
}/* 助手消息气泡 */.assistant .message-bubble {background-color: #e9f3ff;color: #0a2642;border-bottom-left-radius: 4px;box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);border-left: 3px solid #4dabf7;
}.assistant .message-bubble::before {content: '🩺';position: absolute;left: -30px;top: 2px;font-size: 16px;color: #4dabf7;background: white;width: 24px;height: 24px;border-radius: 50%;display: flex;align-items: center;justify-content: center;box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}/* 系统消息气泡 */.system .message-bubble {background-color: #fff3cd;color: #856404;border-radius: 10px;border-left: 3px solid #ffc107;box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}/* 消息信息 */.message-info {font-size: 12px;color: #8e8e93;margin-top: 4px;padding: 0 8px;display: flex;align-items: center;
}.user .message-info {justify-content: flex-end;
}/* 输入区域 */.input-container {flex-shrink: 0;padding: 1rem;background-color: white;border-top: 1px solid #e0e6ed;display: flex;gap: 10px;z-index: 100;position: sticky;bottom: 0;left: 0;right: 0;width: 100%;/* box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05); */
}.input-container::before {content: '';position: absolute;top: -20px;left: 0;right: 0;height: 20px;background: linear-gradient(to bottom, rgba(248, 251, 253, 0), rgba(248, 251, 253, 1));pointer-events: none;
}.input-container input {flex: 1;border: 1px solid #d1e3f8;padding: 12px 16px;border-radius: 24px;background-color: #f8fbfd;margin-right: 10px;font-size: 15px;outline: none;transition: all 0.2s ease;color: #0a2642;
}.input-container input:focus {border-color: #4dabf7;box-shadow: 0 0 0 3px rgba(77, 171, 247, 0.2);background-color: white;
}.input-container input::placeholder {color: #99b2cc;
}.input-container button {background-color: #0d6efd;color: white;border: none;border-radius: 24px;padding: 0 24px;font-weight: 600;cursor: pointer;transition: all 0.2s ease;display: flex;align-items: center;justify-content: center;height: 42px;
}.input-container button:hover:not(:disabled) {background-color: #0b5ed7;transform: translateY(-1px);box-shadow: 0 2px 5px rgba(11, 94, 215, 0.3);
}.input-container button:active:not(:disabled) {transform: translateY(0);box-shadow: none;
}.input-container button:disabled {background-color: #b9d7ff;cursor: not-allowed;
}/* 打字指示器动画 */.typing-indicator {display: inline-block;position: relative;width: 60px;height: 24px;
}.typing-indicator::before {content: "";position: absolute;width: 10px;height: 10px;border-radius: 50%;background-color: #4dabf7;left: 0;animation: typing-dot 1.4s infinite ease-in-out both;animation-delay: -0.32s;
}.typing-indicator::after {content: "";position: absolute;width: 10px;height: 10px;border-radius: 50%;background-color: #4dabf7;right: 0;animation: typing-dot 1.4s infinite ease-in-out both;animation-delay: 0s;
}.typing-indicator span {position: absolute;top: 0;width: 10px;height: 10px;border-radius: 50%;background-color: #4dabf7;left: 22px;animation: typing-dot 1.4s infinite ease-in-out both;animation-delay: -0.16s;
}@keyframes typing-dot {0%,80%,100% {transform: scale(0.7);opacity: 0.6;}40% {transform: scale(1);opacity: 1;}
}/* 响应式设计 */@media (max-width: 768px) {.message-bubble {padding: 12px 14px;font-size: 14px;}.assistant .message-bubble::before {display: none;}.input-container {padding: 12px;}.input-container input {padding: 10px 14px;}.input-container button {padding: 0 16px;height: 38px;}
}@media (max-width: 480px) {.messages-container {padding: 15px 10px;}.message {max-width: 90%;}.message-bubble {padding: 10px 12px;font-size: 14px;}.input-container input {padding: 10px 12px;}.input-container button {padding: 0 15px;font-size: 14px;}
}.chat-header {padding: 1rem;background-color: #2196f3;color: white;display: flex;justify-content: space-between;align-items: center;box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);flex-shrink: 0;z-index: 10;
}.chat-header h1 {margin: 0;font-size: 1.5rem;font-weight: 500;
}.conversation-id {font-size: 0.8rem;opacity: 0.8;
}.error-message {padding: 0.8rem;margin: 0.5rem 1rem;background-color: #ffebee;color: #c62828;border-radius: 4px;font-size: 0.9rem;
}/* Markdown样式 */.markdown-content {margin: 0;line-height: 1.5;text-align: left;
}.markdown-content p,
.markdown-content ul,
.markdown-content ol,
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {text-align: left;
}.markdown-content p {margin: 0 0 0.8em;
}.markdown-content p:last-child {margin-bottom: 0;
}.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {margin-top: 1em;margin-bottom: 0.5em;font-weight: 600;line-height: 1.25;color: #003366;
}.markdown-content h1 {font-size: 1.5em;
}.markdown-content h2 {font-size: 1.3em;
}.markdown-content h3 {font-size: 1.1em;
}.markdown-content ul,
.markdown-content ol {margin-top: 0;margin-bottom: 1em;padding-left: 2em;
}.markdown-content li {margin: 0.3em 0;
}.markdown-content a {color: #0d6efd;text-decoration: none;
}.markdown-content a:hover {text-decoration: underline;
}.markdown-content blockquote {margin: 0.8em 0;padding: 0 1em;color: #0d47a1;border-left: 3px solid #90caf9;background-color: rgba(144, 202, 249, 0.1);
}.markdown-content code {font-family: monospace;padding: 0.2em 0.4em;margin: 0;font-size: 85%;border-radius: 3px;background-color: rgba(0, 0, 0, 0.05);color: #d32f2f;
}.markdown-content pre {margin: 0.8em 0;padding: 0.8em;overflow: auto;background-color: #f1f5f9;border-radius: 4px;
}.markdown-content pre code {padding: 0;background-color: transparent;color: #0a2642;
}.markdown-content table {border-collapse: collapse;width: 100%;margin: 1em 0;
}.markdown-content table th,
.markdown-content table td {padding: 6px 12px;border: 1px solid #e0e6ed;text-align: left;
}.markdown-content table th {background-color: rgba(77, 171, 247, 0.1);font-weight: 600;
}.markdown-content table tr:nth-child(even) {background-color: rgba(244, 247, 250, 0.7);
}.markdown-content img {max-width: 100%;height: auto;border-radius: 4px;margin: 0.5em 0;
}.markdown-content hr {height: 1px;margin: 1em 0;background-color: #e0e6ed;border: none;
}/* 医疗相关样式 */.markdown-content .highlight-warning {background-color: #fff3cd;padding: 8px 12px;border-radius: 4px;border-left: 3px solid #ffc107;margin: 0.8em 0;
}.markdown-content .highlight-info {background-color: #e9f3ff;padding: 8px 12px;border-radius: 4px;border-left: 3px solid #4dabf7;margin: 0.8em 0;
}/* 医疗专业术语 */.markdown-content .medical-term {border-bottom: 1px dashed #4dabf7;
}/* 响应式样式 */@media (max-width: 768px) {.markdown-content h1 {font-size: 1.3em;}.markdown-content h2 {font-size: 1.2em;}.markdown-content h3 {font-size: 1.1em;}.markdown-content pre {padding: 0.6em;}.markdown-content blockquote {padding: 0 0.8em;}
}/* 添加头部样式 */.header {display: flex;justify-content: space-between;align-items: center;padding: 15px 20px;background-color: #f0f8ff;border-bottom: 1px solid #e0e6ed;
}.title {font-size: 1.5rem;font-weight: 600;color: #003366;
}.actions {display: flex;gap: 12px;
}/* 流式响应开关样式 */.stream-toggle {display: flex;align-items: center;
}.toggle-label {display: flex;align-items: center;cursor: pointer;font-size: 0.9rem;color: #444;
}.toggle-label input[type="checkbox"] {margin-right: 6px;width: 16px;height: 16px;cursor: pointer;
}.toggle-label input[type="checkbox"]:disabled {opacity: 0.5;cursor: not-allowed;
}.toggle-label input[type="checkbox"]:checked+span {color: #0078d7;font-weight: 500;
}/* 消息图片样式 */.message-image-container {margin-bottom: 8px;max-width: 100%;
}.message-image {max-width: 100%;max-height: 300px;border-radius: 8px;cursor: pointer;
}/* 图片预览区域 */.image-preview-container {margin: 0 16px;padding: 8px;position: relative;display: inline-block;max-width: 150px;margin-bottom: 8px;
}.image-preview {width: 100%;max-height: 150px;object-fit: contain;border-radius: 8px;border: 1px solid #e1e1e1;
}.clear-image-button {position: absolute;top: 0;right: 0;width: 24px;height: 24px;border-radius: 50%;background-color: rgba(0, 0, 0, 0.5);color: white;border: none;font-size: 16px;display: flex;align-items: center;justify-content: center;cursor: pointer;
}.clear-image-button:hover {background-color: rgba(0, 0, 0, 0.7);
}/* 输入区域样式 */.input-container {display: flex;padding: 12px 16px;border-top: 1px solid #e1e1e1;background-color: #f9f9f9;align-items: center;
}.input-container input[type="text"] {flex: 1;padding: 10px 16px;border: 1px solid #d1d1d1;border-radius: 20px;font-size: 16px;outline: none;transition: border 0.3s;
}.input-container input[type="text"]:focus {border-color: #4a89dc;
}.input-container input[type="text"].with-image {border-color: #4a89dc;background-color: #f0f7ff;
}/* 图片上传按钮 */.image-upload-button {width: 38px;height: 38px;border-radius: 50%;background-color: #f0f0f0;border: 1px solid #d1d1d1;margin-right: 10px;cursor: pointer;display: flex;align-items: center;justify-content: center;padding: 0;
}.image-upload-button svg {fill: #5a5a5a;width: 20px;height: 20px;
}.image-upload-button:hover {background-color: #e3e3e3;
}.image-upload-button:disabled {opacity: 0.5;cursor: not-allowed;
}/* 发送按钮 */.send-button {margin-left: 10px;padding: 10px 20px;background-color: #4a89dc;color: white;border: none;border-radius: 20px;font-weight: 500;cursor: pointer;transition: background-color 0.3s;
}.send-button:hover {background-color: #3a79d2;
}.send-button:disabled {opacity: 0.5;cursor: not-allowed;
}/* 流式响应开关 */.stream-toggle {display: flex;align-items: center;margin-right: 10px;
}.toggle-label {display: flex;align-items: center;cursor: pointer;font-size: 14px;color: #666;
}.toggle-label input {margin-right: 6px;
}/* 响应式设计调整 */@media (max-width: 600px) {.image-upload-button {width: 34px;height: 34px;}.image-upload-button svg {width: 18px;height: 18px;}.send-button {padding: 8px 12px;font-size: 14px;}
}
✅ 3. API 接口调用代码(Fetch 流式响应)
在本项目中,我们通过 FastAPI 搭建了一个支持多模态聊天(图文)和图生图的 AI 医疗助手系统。前端使用 Fetch 实现与后端的流式响应通信,实现了更自然的人机交互体验。
本文将聚焦于前端调用后端接口的 流式响应实现方式,并结合项目的接口说明,展示完整的调用代码和注意事项。
🧠 为什么选择流式响应?
传统的 HTTP 请求是在服务端处理完所有数据后一次性返回,而 流式响应(Streaming Response) 能够实现像 ChatGPT 一样 “字一个一个地出来”,提高用户体验。
流式的技术基础:
-
服务端使用
yield
或StreamingResponse
分块发送数据; -
客户端使用
Fetch + ReadableStream
实现逐块接收并展示内容。
🛠️ 后端接口回顾
POST /api/chat/multimodal
Content-Type: multipart/form-data
Form fields:
- conversation_id: string
- text: string
- image: file (optional)Response: 流式返回 AI 回复内容
📜 前端 Fetch 调用示例(流式读取)
const controller = new AbortController(); // 用于中止请求
const signal = controller.signal;async function chatWithAI({ conversationId, inputText, imageFile }) {const formData = new FormData();formData.append("conversation_id", conversationId);formData.append("text", inputText);if (imageFile) {formData.append("image", imageFile);}const response = await fetch("http://localhost:8000/api/chat/multimodal", {method: "POST",body: formData,signal,});if (!response.ok) {throw new Error("请求失败: " + response.statusText);}const reader = response.body.getReader();const decoder = new TextDecoder("utf-8");let resultText = "";while (true) {const { value, done } = await reader.read();if (done) break;const chunk = decoder.decode(value, { stream: true });resultText += chunk;// 你可以在此处逐步展示文本(如更新 chat UI)appendToChatUI(chunk);}return resultText;
}
📦 UI 使用场景示例
<input type="file" id="imageInput" />
<input type="text" id="textInput" placeholder="请输入咨询内容" />
<button onclick="handleSubmit()">发送</button>
<div id="chatBox"></div><script>async function handleSubmit() {const imageInput = document.getElementById("imageInput").files[0];const text = document.getElementById("textInput").value;const chatBox = document.getElementById("chatBox");chatBox.innerHTML += "<div class='user-msg'>" + text + "</div>";try {await chatWithAI({conversationId: "user-session-123",inputText: text,imageFile: imageInput});} catch (err) {console.error("请求失败", err);}}function appendToChatUI(textChunk) {let botMsg = document.querySelector(".bot-msg:last-child");if (!botMsg || botMsg.getAttribute("finished")) {botMsg = document.createElement("div");botMsg.className = "bot-msg";document.getElementById("chatBox").appendChild(botMsg);}botMsg.innerText += textChunk;}
</script>