-
这个项目旨在构建一个简单的网页聊天界面,用户可以在前端输入问题,前端将问题发送给后端服务器。后端服务器作为一个代理,接收到请求后,调用真正的 DeepSeek AI 聊天 API,并将 DeepSeek 返回的流式响应(一个字一个字地返回)再通过 Server-Sent Events (SSE) 技术流式传输回前端,最终在用户的聊天界面上实时展示出来,模拟了类似 ChatGPT 或 DeepSeek 官网的打字效果。
该项目的后端配套项目地址: https://github.com/Suzumiya-Tiger/ds-AIServer deepseek api 在后端项目中自行配置自己的 api key 即可。
这是一个使用 React 和 TypeScript 构建的单页面应用 (SPA)。
- 通常使用像 Vite 或 Create React App 这样的构建工具。
- 构建过程包括:将 TypeScript (.tsx) 编译成 JavaScript (.js),处理 CSS (
.App.css),打包所有代码和资源文件,生成最终可以在浏览器中运行的静态文件 (HTML, JS, CSS)。 - 开发时,通常会有一个开发服务器提供热模块替换 (HMR) 功能,方便快速查看代码更改效果。
- 组件化: 使用 React 函数组件 (
App) 来构建用户界面,将 UI 拆分成可管理的部分。 - 状态管理: 使用 React Hooks (
useState,useRef,useEffect) 来管理组件状态(如消息列表、用户输入、加载状态、流式响应内容)和处理副作用(如自动滚动)。 - 异步通信: 使用
fetchAPI 与后端进行异步通信,发送用户请求并接收响应。 - 流式响应处理: 核心在于处理后端通过 SSE 发送的流式数据。前端需要能够接收、解析这些数据片段,并实时更新 UI,而不是等待整个响应完成后再显示。
- 用户体验:
- 实时显示 AI 的回复过程,提供即时反馈。
- 自动滚动聊天记录到底部,方便查看最新消息。
- 在 AI 思考时禁用输入框和发送按钮,防止重复提交。
messages: 存储整个聊天记录(用户和已完成的助手消息)的数组 (Message[])。currentInput: 保存当前用户在输入框中输入的文本 (string)。currentResponse: 用于临时存储 *正在进行中* 的、从后端流式接收到的 AI 回复片段 (string)。isLoading: 布尔值 (boolean),标记当前是否正在等待后端响应。messagesEndRef: 一个 Ref 对象 (React.RefObject<HTMLDivElement>),附加到消息列表末尾的空div上,用于useEffect实现自动滚动。
response.body.getReader()方法的作用是获取这个流的"读取器" (ReadableStreamDefaultReader)。- 这个读取器是你用来从流中 主动拉取 (pull) 数据的工具。
- 一旦你调用了
getReader(),这个流就被"锁定"了,意味着在当前读取器被释放之前,不能再有其他读取器来读取这个流。
reader.read()是一个异步方法,它返回一个 Promise。当你调用它时,它并不会立即返回所有数据,而是:- 请求下一个数据块: 它向流请求下一个可用的数据块 (
chunk)。 - 等待数据: 如果当前没有数据块可用(因为服务器还没发送过来,或者网络传输需要时间),
await reader.read()会暂停在这里,等待数据到达。
返回结果: 当一个数据块可用时,或者当流结束时,Promise 会解决 (
resolve)。解决的值是一个对象,形如{ value: dataChunk, done: boolean }。value: 这就是实际的数据块,通常是一个Uint8Array(原始二进制数据)。如果流结束了 (done是true),value通常是undefined。done: 这是一个布尔值。如果是false,表示流中还有更多数据;如果是true,表示流已经结束,不会再有新的数据块了。
- 因为数据是分块传输的!你不知道服务器会把完整的响应分成多少块,也不知道每一块有多大,更不知道它们什么时候会到达。
reader.read()的设计就是让你每次只取当前可用的那一小块数据。- 因此,你需要在一个循环 (通常是
while(true)或while(!done)) 中反复调用await reader.read(),并在每次迭代中处理value,直到done变成true,表示你已经读取并处理完了所有的数据块。
-
当用户提交表单 (
<form onSubmit={handleSubmit}>) 时触发。 -
首先,阻止表单的默认提交行为(防止页面刷新),并检查当前输入是否为空或是否已在加载中,如果是则提前返回。
// src/App.tsx const handleSubmit = async (e: FormEvent) => { e.preventDefault(); // 阻止表单默认的提交行为(页面刷新) // 如果当前输入为空或正在加载中,则不执行任何操作 if (!currentInput.trim() || isLoading) return; // ... };
-
将用户的当前输入 (
currentInput) 包装成Message对象,并使用setMessages追加到messages状态数组中。 -
清空输入框 (
setCurrentInput('')),设置加载状态 (setIsLoading(true)), 清空上一次的流式响应临时存储 (setCurrentResponse(''))。 -
使用
fetch向后端/chat发送 POST 请求。- 关键设置:
method: 'POST'headers:'Content-Type': 'application/json':告知服务器请求体是 JSON。'Accept': 'text/event-stream':告知服务器客户端期望接收 SSE 流。
body: 将包含用户prompt的对象{ prompt: userMessageContent }序列化为 JSON 字符串。
// src/App.tsx const response = await fetch(backendUrl, { method: "POST", headers: { "Content-Type": "application/json", // 告知服务器请求体是 JSON 格式 // **关键**: 设置 Accept 头为 'text/event-stream' // 这是告知服务器,客户端期望接收 Server-Sent Events (SSE) 流 Accept: "text/event-stream", }, // 将包含用户输入的 prompt 构造成 JSON 字符串作为请求体 body: JSON.stringify({ prompt: userMessageContent }), });
- 关键设置:
-
在处理流数据前,必须检查
response.ok是否为true以及响应头Content-Type是否确实为'text/event-stream'。如果检查失败,应处理错误并显示给用户,然后停止后续处理。// src/App.tsx // 检查 HTTP 响应状态码是否表示成功 (例如 200 OK) if (!response.ok) { const errorData = await response .json() .catch(() => ({ message: `HTTP error! status: ${response.status}` })); setMessages(prev => [ ...prev, { role: "assistant", content: `Error: ${errorData.message || "Could not connect"}`, }, ]); setIsLoading(false); return; // 提前退出函数 } // 检查响应头中的 Content-Type 是否确实是 'text/event-stream' if (response.headers.get("Content-Type") !== "text/event-stream") { setMessages(prev => [ ...prev, { role: "assistant", content: "Error: Invalid response format from server.", }, ]); setIsLoading(false); return; // 提前退出函数 }
-
获取
response.body(一个ReadableStream)。 -
使用
response.body.getReader()获取流读取器。 -
创建
TextDecoder('utf-8')用于将Uint8Array格式的数据块解码为字符串。 -
设置一个
buffer字符串变量,用于缓存可能被网络分割的、不完整的 SSE 消息片段。 -
设置
accumulatedResponse字符串变量,用于累积当前 Assistant 回复的所有片段。 -
持续读取 (
while(true)) 和解码,直到流结束 (reader.read()返回{ done: true })。- 解码时使用
decoder.decode(value, { stream: true })允许处理跨数据块的字符。 - 将解码后的
chunk追加到buffer。
- 解码时使用
-
解析 SSE 消息:
- 将
buffer按 SSE 消息分隔符\n\n分割成数组lines。 - 将
lines的最后一个元素(可能是未完整接收的消息)重新赋值给buffer(buffer = lines.pop() || ''),等待下一个数据块。 - 遍历
lines数组中每个完整的消息行。 - 处理每个以
data:开头的行。
// src/App.tsx const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; let accumulatedResponse = ""; while (true) { const { done, value } = await reader.read(); if (done) break; // 流结束 const chunk = decoder.decode(value, { stream: true }); // 解码 buffer += chunk; // 追加到缓冲区 const lines = buffer.split("\n\n"); // 按 SSE 分隔符分割 buffer = lines.pop() || ""; // 保留可能不完整的部分 for (const line of lines) { if (line.startsWith("data: ")) { // ... 解析 data 内容 ... } else if (line.startsWith("event: error")) { // ... 处理错误事件 ... } } // ... (检查是否需要退出外层循环) ... } // while 循环结束
- 将
-
对
line.substring(6).trim()提取出的 JSON 字符串,使用try...catch块进行JSON.parse()。 -
处理
[DONE]标记: 如果原始数据是[DONE](DeepSeek 特定),则continue跳过当前行处理。 -
处理
parsedData.chunk: 如果解析后的对象包含chunk字段,将其值追加到accumulatedResponse变量,并调用setCurrentResponse(accumulatedResponse)更新 UI 以实时显示新增文本。 -
处理
parsedData.done: 如果解析后的对象包含done: true(自定义结束信号),则记录日志,可以选择await reader.cancel(),并break退出内层for循环。 -
处理错误事件: 如果
line以event: error开头,或者解析后的数据包含错误信息(如parsedData.message),则记录错误,更新messages显示错误给用户,取消读取器并break退出内层for循环。 -
处理 JSON 解析错误: 在
catch块中处理JSON.parse可能抛出的异常。// src/App.tsx (在 for...of lines 循环内部) const jsonData = line.substring(6).trim(); if (jsonData === "[DONE]") { console.log("Received [DONE] marker."); continue; } try { const parsedData = JSON.parse(jsonData); if (parsedData.done) { console.log("Received done:true signal from backend."); await reader.cancel(); break; // 跳出内层 for 循环 } else if (parsedData.chunk) { accumulatedResponse += parsedData.chunk; setCurrentResponse(accumulatedResponse); // 更新 UI } else if (parsedData.message && line.startsWith("event: error")) { setMessages(prev => [ ...prev, { role: "assistant", content: `Server Error: ${parsedData.message}` }, ]); await reader.cancel(); break; // 跳出内层 for 循环 } } catch (error) { console.error("Error parsing SSE data chunk:", jsonData, error); // 可选: setMessages((prev) => [...prev, { role: 'assistant', content: 'Error: Corrupted data received.' }]); // 可选: await reader.cancel(); break; }
-
流结束后添加完整消息: 当整个流处理完毕(
while循环结束),检查accumulatedResponse是否有实际内容(trim()后不为空)。如果有,则将其作为一个新的助手消息对象{ role: 'assistant', content: accumulatedResponse }添加到messages状态数组中。// src/App.tsx (在 while 循环之后) if (accumulatedResponse.trim()) { setMessages(prev => [ ...prev, { role: "assistant", content: accumulatedResponse }, ]); }
-
finally块: 无论请求成功、失败还是流处理中出现错误,finally块都会执行,确保isLoading状态被重置为false,并且currentResponse(临时流式响应区)被清空。// src/App.tsx (handleSubmit 函数末尾) } catch (error) { // ... 错误处理 ... } finally { setIsLoading(false); // 确保加载状态被重置为 false setCurrentResponse(""); // 清空临时流式响应区域 }
-
使用
useEffectHook 监听messages和currentResponse的变化。当它们更新时,调用messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })将聊天视图平滑滚动到底部。// src/App.tsx const messagesEndRef = useRef<HTMLDivElement>(null); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, currentResponse]); // 依赖项:消息列表或流式响应变化时触发
-
遍历
messages数组,为每个消息渲染一个div。- 使用
ReactMarkdown组件渲染msg.content,以支持 Markdown 格式(如列表、代码块、加粗等)。
- 使用
-
关键: 当
isLoading为true且currentResponse有内容时,额外渲染一个临时的 "Assistant" 消息框。- 直接显示
currentResponse的文本内容。 - 使用
whiteSpace: 'pre-wrap'CSS 样式保留换行和空格,模拟打字效果。 - 可以附加一个 CSS 实现的打字光标效果 (
<span className="typing-cursor"></span>)。
// src/App.tsx (JSX 部分) <div className="chat-messages"> {messages.map((msg, index) => ( <div key={index} className={`message ${msg.role}`}> <span className="role"> {msg.role === "user" ? "You" : "Assistant"} </span> {/* 使用 ReactMarkdown 渲染消息内容 */} <ReactMarkdown>{msg.content}</ReactMarkdown> </div> ))} {/* 条件渲染正在流式传输的响应 */} {isLoading && currentResponse && ( <div className="message assistant"> <span className="role">Assistant</span> {/* 保留空白符和换行 */} <p style={{ whiteSpace: "pre-wrap" }}> {currentResponse} <span className="typing-cursor"></span> {/* 打字光标 */} </p> </div> )} {/* 用于自动滚动的空 div */} <div ref={messagesEndRef} /> </div>
- 直接显示
-
渲染底部的输入框和提交按钮,根据
isLoading状态动态设置disabled属性和按钮文本。// src/App.tsx (JSX 部分) <form onSubmit={handleSubmit} className="chat-input-form"> <input type="text" value={currentInput} onChange={e => setCurrentInput(e.target.value)} placeholder="Ask me anything..." disabled={isLoading} // 禁用输入框 /> <button type="submit" disabled={isLoading}> {" "} {/* 禁用按钮 */} {isLoading ? "Thinking..." : "Send"} {/* 动态按钮文本 */} </button> </form>
- 提供一个与后端 AI 服务交互的用户界面。
- 实现流式响应的实时展示,提升用户体验,让用户感觉 AI 是"边思考边回答"。
- 解耦前端 UI 和后端 AI 调用逻辑。
-
Notifications
You must be signed in to change notification settings - Fork 1
Zenitsu-Tiger/ds-AIChat
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
About
这是一套模仿deepseek和chatGPT的简单问答页面的Mini Chat项目,可以方便你快速上手和理解如何迅速搭建一个类deepseek的问答页面。
Topics
Resources
Stars
Watchers
Forks
Releases
No releases published
Packages 0
No packages published