Skip to content

这是一套模仿deepseek和chatGPT的简单问答页面的Mini Chat项目,可以方便你快速上手和理解如何迅速搭建一个类deepseek的问答页面。

Notifications You must be signed in to change notification settings

Zenitsu-Tiger/ds-AIChat

Repository files navigation

  • 项目概述

    这个项目旨在构建一个简单的网页聊天界面,用户可以在前端输入问题,前端将问题发送给后端服务器。后端服务器作为一个代理,接收到请求后,调用真正的 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) 来管理组件状态(如消息列表、用户输入、加载状态、流式响应内容)和处理副作用(如自动滚动)。
    • 异步通信: 使用 fetch API 与后端进行异步通信,发送用户请求并接收响应。
    • 流式响应处理: 核心在于处理后端通过 SSE 发送的流式数据。前端需要能够接收、解析这些数据片段,并实时更新 UI,而不是等待整个响应完成后再显示。
    • 用户体验:
      • 实时显示 AI 的回复过程,提供即时反馈。
      • 自动滚动聊天记录到底部,方便查看最新消息。
      • 在 AI 思考时禁用输入框和发送按钮,防止重复提交。

    实现思路

    状态定义

    • messages: 存储整个聊天记录(用户和已完成的助手消息)的数组 (Message[])。
    • currentInput: 保存当前用户在输入框中输入的文本 (string)。
    • currentResponse: 用于临时存储 *正在进行中* 的、从后端流式接收到的 AI 回复片段 (string)。
    • isLoading: 布尔值 (boolean),标记当前是否正在等待后端响应。
    • messagesEndRef: 一个 Ref 对象 (React.RefObject<HTMLDivElement>),附加到消息列表末尾的空 div 上,用于 useEffect 实现自动滚动。

    读取数据流 reader.read()

    • response.body.getReader() 方法的作用是获取这个流的"读取器" (ReadableStreamDefaultReader)。
    • 这个读取器是你用来从流中 主动拉取 (pull) 数据的工具。
    • 一旦你调用了 getReader(),这个流就被"锁定"了,意味着在当前读取器被释放之前,不能再有其他读取器来读取这个流。

    reader.read() 是一个异步方法,它返回一个 Promise。当你调用它时,它并不会立即返回所有数据,而是:

    • 请求下一个数据块: 它向流请求下一个可用的数据块 (chunk)。
    • 等待数据: 如果当前没有数据块可用(因为服务器还没发送过来,或者网络传输需要时间),await reader.read() 会暂停在这里,等待数据到达。

    返回结果: 当一个数据块可用时,或者当流结束时,Promise 会解决 (resolve)。解决的值是一个对象,形如 { value: dataChunk, done: boolean }

    • value: 这就是实际的数据块,通常是一个 Uint8Array (原始二进制数据)。如果流结束了 (donetrue),value 通常是 undefined
    • done: 这是一个布尔值。如果是 false,表示流中还有更多数据;如果是 true,表示流已经结束,不会再有新的数据块了。

    为什么需要反复调用?

    • 因为数据是分块传输的!你不知道服务器会把完整的响应分成多少块,也不知道每一块有多大,更不知道它们什么时候会到达。
    • reader.read() 的设计就是让你每次只取当前可用的那一小块数据。
    • 因此,你需要在一个循环 (通常是 while(true)while(!done)) 中反复调用 await reader.read(),并在每次迭代中处理 value,直到 done 变成 true,表示你已经读取并处理完了所有的数据块。

    handleSubmit 函数 (核心)

    • 当用户提交表单 (<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 }),
      });

    处理 SSE 流

    • 在处理流数据前,必须检查 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 循环结束

    解析 data: 后面的 JSON 字符串

    • 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 循环。

    • 处理错误事件: 如果 lineevent: 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(""); // 清空临时流式响应区域
        }

    useEffect 自动滚动

    • 使用 useEffect Hook 监听 messagescurrentResponse 的变化。当它们更新时,调用 messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) 将聊天视图平滑滚动到底部。

      // src/App.tsx
      const messagesEndRef = useRef<HTMLDivElement>(null);
      
      useEffect(() => {
        messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
      }, [messages, currentResponse]); // 依赖项:消息列表或流式响应变化时触发

    渲染 (Rendering)

    • 遍历 messages 数组,为每个消息渲染一个 div

      • 使用 ReactMarkdown 组件渲染 msg.content,以支持 Markdown 格式(如列表、代码块、加粗等)。
    • 关键: isLoadingtruecurrentResponse 有内容时,额外渲染一个临时的 "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 调用逻辑。

About

这是一套模仿deepseek和chatGPT的简单问答页面的Mini Chat项目,可以方便你快速上手和理解如何迅速搭建一个类deepseek的问答页面。

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published