# 手把手教你从零搭建 AI 对话系统 - React + Spring Boot 实战(一)

一个完整的类 ChatGPT 对话系统,支持流式输出、打断,会话历史,前后端分离架构,非常适合拿来练手熟悉技术实现或者面试使用

基于 React 18 + TypeScript + Vite 的 AI 对话系统前端,支持流式对话、深度思考、会话管理等功能。

技术栈

  • React 18 - UI 框架
  • TypeScript - 类型安全
  • Vite - 构建工具
  • Zustand - 状态管理
  • Ant Design 5 - UI 组件库
  • React Router 6 - 路由管理
  • React Markdown - Markdown 渲染

一、打字机效果(流式对话)的实现

实现原理: 使用 SSE (Server-Sent Events) 技术,后端逐字返回,前端实时追加渲染。

核心代码:

typescript 复制代码
// ChatEditor/index.tsx
const handleSubmit = async () => {
  // 1. 创建 AbortController 用于中断请求
  abortControllerRef.current = new AbortController();

  // 2. 调用后端流式接口
  const response = await fetch("/api/chat/stream", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({ sessionId, content, history }),
    signal: abortControllerRef.current.signal, // 绑定中断信号
  });

  // 3. 获取 ReadableStream 读取器
  const reader = response.body?.getReader();
  const decoder = new TextDecoder();

  let fullContent = "";

  // 4. 循环读取流数据
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    // 5. 解码二进制数据
    const chunk = decoder.decode(value, { stream: true });
    const lines = chunk.split("\n");

    for (const line of lines) {
      // 6. 解析 SSE 格式数据
      if (line.startsWith("data: ") && line !== "data: [DONE]") {
        const data = JSON.parse(line.slice(6));
        const delta = data.choices?.[0]?.delta;

        // 7. 累加内容并实时更新 UI
        if (delta?.content) {
          fullContent += delta.content;
          updateMessage(sessionId, aiMessageId, fullContent);
        }
      }
    }
  }
};

关键点:

  1. response.body.getReader() - 获取流的读取器
  2. TextDecoder - 将 Uint8Array 解码为字符串
  3. reader.read() - 异步读取下一块数据
  4. SSE 格式解析 - 以 data: 开头的行是有效数据

二、中断功能的实现

实现原理: 使用 AbortController 中断 Fetch 请求,同时后端检测连接状态停止生成。

前端实现:

typescript 复制代码
// ChatEditor/index.tsx
const abortControllerRef = useRef<AbortController | null>(null);

// 开始对话时创建 AbortController
const handleSubmit = async () => {
  abortControllerRef.current = new AbortController();

  try {
    const response = await fetch("/api/chat/stream", {
      // ...
      signal: abortControllerRef.current.signal, // 绑定信号
    });
    // ...
  } catch (error) {
    // 判断是否为中断错误
    if (error instanceof Error && error.name === "AbortError") {
      console.log("用户主动中断");
      isAborted = true;
    }
  }
};

// 打断按钮点击事件
const handleStop = () => {
  if (abortControllerRef.current) {
    abortControllerRef.current.abort(); // 触发中断
  }
};

后端配合(Java):

java 复制代码
// AiChatServiceImpl.java
private boolean isClientConnected(HttpServletResponse response, PrintWriter writer) {
  try {
    writer.write("");
    writer.flush();
    return !writer.checkError();  // 检测连接是否有效
  } catch (Exception e) {
    return false;
  }
}

// 流式输出时检测连接
while ((line = reader.readLine()) != null) {
  if (!isClientConnected(response, writer)) {
    System.out.println("客户端已断开,停止生成");
    break;
  }
  writer.write(line + "\n");
  writer.flush();
}

关键点:

  1. AbortController - Web 标准 API,用于中断请求
  2. signal - 将中断信号绑定到 fetch
  3. AbortError - 中断时抛出的特定错误
  4. 后端 checkError() - 检测客户端是否断开

三、Loading 动画(三个跳动的点)

实现原理: 使用 CSS 动画,三个点依次上下跳动,形成律动感。

组件实现:

tsx 复制代码
// ChatMessage/index.tsx
{
  message.isGenerating && !message.content && (
    <span className="loading-dots">
      <span className="dot">.</span>
      <span className="dot">.</span>
      <span className="dot">.</span>
    </span>
  );
}

CSS 动画:

less 复制代码
.loading-dots {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  font-size: 20px;
  font-weight: bold;
  color: var(--text-secondary);

  .dot {
    animation: bounce 1.4s infinite ease-in-out both;

    // 依次延迟,形成波浪效果
    &:nth-child(1) {
      animation-delay: -0.32s;
    }
    &:nth-child(2) {
      animation-delay: -0.16s;
    }
    &:nth-child(3) {
      animation-delay: 0s;
    }
  }
}

@keyframes bounce {
  0%,
  80%,
  100% {
    transform: translateY(0);
    opacity: 0.5;
  }
  40% {
    transform: translateY(-6px); // 向上跳动
    opacity: 1;
  }
}

关键点:

  1. animation-delay - 负值让动画提前开始,形成依次跳动
  2. ease-in-out - 缓动函数,让动画更自然
  3. both - 动画开始前和结束后都应用关键帧样式
  4. transform: translateY - 使用 GPU 加速,性能更好

四、状态管理设计

Zustand Store 设计:

typescript 复制代码
// useSessionStore.ts
interface SessionState {
  sessions: Session[]; // 所有会话
  currentSessionId: string | null;
  loading: boolean;

  createSession: () => string;
  addMessage: (sessionId, role, content) => string;
  updateMessage: (
    sessionId,
    messageId,
    content,
    reasoningContent,
    isComplete
  ) => void;
}

export const useSessionStore = create<SessionState>()(
  persist(
    (set, get) => ({
      // 创建会话
      createSession: () => {
        const id = uuidv4();
        const newSession: Session = {
          id,
          title: "新对话",
          messages: [],
          model: "deepseek-chat",
        };
        set((state) => ({
          sessions: [newSession, ...state.sessions],
          currentSessionId: id,
        }));
        return id;
      },

      // 添加消息,AI 消息标记为生成中
      addMessage: (sessionId, role, content) => {
        const message: Message = {
          id: uuidv4(),
          role,
          content,
          isGenerating: role === "assistant" ? true : undefined,
        };
        // ...
      },

      // 流式更新,isComplete 为 true 时停止 loading
      updateMessage: (
        sessionId,
        messageId,
        content,
        reasoningContent,
        isComplete = false
      ) => {
        set((state) => ({
          sessions: state.sessions.map((s) =>
            s.id === sessionId
              ? {
                  ...s,
                  messages: s.messages.map((m) =>
                    m.id === messageId
                      ? {
                          ...m,
                          content,
                          reasoningContent,
                          isGenerating: !isComplete,
                        }
                      : m
                  ),
                }
              : s
          ),
        }));
      },
    }),
    {
      name: "session-storage", // localStorage 持久化
      storage: createJSONStorage(() => localStorage),
    }
  )
);

关键点:

  1. persist 中间件 - 自动持久化到 localStorage
  2. isGenerating - 控制 Loading 动画的显示
  3. 函数式更新 set((state) => ...) - 基于最新状态更新
  4. 不可变数据 - 使用展开运算符创建新对象

五、防止重复创建会话

问题: React 18 StrictMode 会故意双重调用某些函数来检测副作用,导致 useEffect 执行两次,创建两个会话。

解决方案:

typescript 复制代码
// Chat/index.tsx
const Chat: React.FC = () => {
  const { sessionId } = useParams();
  const hasCreatedSession = useRef(false); // 使用 ref 标记

  useEffect(() => {
    // 只有未创建过且没有 sessionId 时才创建
    if (!sessionId && !hasCreatedSession.current) {
      hasCreatedSession.current = true; // 标记已创建
      const newId = createSession();
      navigate(`/chat/${newId}`, { replace: true });
    }
  }, [sessionId]);
};

关键点:

  1. useRef - 在组件生命周期内保持值不变
  2. hasCreatedSession.current - 标记是否已执行
  3. replace: true - 替换当前历史记录,避免返回时重复触发

项目结构

bash 复制代码
src/
├── api/
│   ├── backend.ts          # 后端 API 封装
│   └── deepseek.ts         # (已废弃)
├── components/
│   ├── ChatEditor/         # 对话输入(流式请求)
│   ├── ChatMessage/        # 消息气泡(Loading 动画)
│   ├── SessionList/        # 会话列表
│   └── Sidebar/            # 侧边栏
├── pages/
│   ├── Chat/               # 对话页(StrictMode 处理)
│   ├── Login/              # 登录注册
│   └── Setting/            # 设置页
├── stores/
│   └── useSessionStore.ts  # Zustand 状态管理
└── types/
    └── chat.ts             # TypeScript 类型定义

启动方式

bash 复制代码
npm install
npm run dev

核心设计亮点

功能 技术方案 关键点
打字机效果 SSE + ReadableStream 逐块读取、实时解码
中断功能 AbortController 前后端配合检测连接
Loading 动画 CSS Animation animation-delay 波浪效果
状态管理 Zustand + Persist localStorage 自动持久化
StrictMode 处理 useRef 标记 防止副作用重复执行

下一篇分享后端:源码地址

相关推荐
高桥凉介发量惊人1 小时前
基础与工程篇-多环境配置(dev/test/prod)与打包策略
前端
墨鱼笔记1 小时前
前端必看:Vite.config.js 最全配置指南 + 实战案例
前端·vite
kyriewen1 小时前
异步编程:从“回调地狱”到“async/await”的救赎之路
前端·javascript·面试
前端Hardy1 小时前
别再手动写 loading 了!封装一个自动防重提交的 Hook
前端·javascript·vue.js
前端Hardy1 小时前
前端如何实现“无感刷新”Token?90% 的人都做错了
前端·javascript·vue.js
秋水无痕1 小时前
# 手把手教你从零搭建 AI 对话系统 - React + Spring Boot 实战(二)
前端·后端·面试
Master_Azur2 小时前
java内部类与匿名内部类
后端
SuperEugene2 小时前
Vue Router 实战规范:path/name/meta 配置 + 动态 / 嵌套路由,统一团队标准|状态管理与路由规范篇
开发语言·前端·javascript·vue.js·前端框架
开心就好20252 小时前
不依赖 Mac 也能做 iOS 开发?跨设备开发流程
后端·ios