A11第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

流式聊天界面

流式聊天界面 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

把 A10 的事件协议接进真实界面:消息展示、逐步输出、代码块渲染;并过三道验证——重复提交、快速切换会话、页面刷新。

问题场景

第一个字 1 秒内出现了,很好。但很快冒出三个 bug:回答还在流出时连点发送,界面穿插出两条回复;切到另一个会话再切回来,旧回复的半截话串进了新会话;刷新页面后,刚才「生成中」的那条消息永远转圈。协议是对的,界面接得不对。

核心解释

用 fetch 流消费 /api/ai/chat 的 SSE(聊天要 POST 请求体,不用 EventSource)。核心是 A10 的缓冲解析加上回调分发:

async function sendStream(body: string, signal: AbortSignal, handlers: {
  onDelta: (d: string) => void;
  onDone: () => void;
  onError: (category: string, message: string) => void;
}) {
  const res = await fetch("/api/ai/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body,
    signal,
  });
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buf = "";
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buf += decoder.decode(value, { stream: true });
    const parts = buf.split("\n\n");
    buf = parts.pop()!; // 余料留到下一轮(A10 的铁律)
    for (const part of parts) {
      const event = JSON.parse(part.replace(/^data:\s*/, ""));
      if (event.type === "text") handlers.onDelta(event.delta);
      if (event.type === "done") handlers.onDone();
      if (event.type === "error") handlers.onError(event.category, event.message);
    }
  }
}

界面侧的状态组织:messages 数组 + 一条占位 assistant 消息。本站的做法是请求发出时服务端先插一条 status='streaming' 的占位并落库,前端收到 start 事件后把 delta 一直追加到这条占位上,done 后占位转正:

// delta 只追加到末尾的 streaming 占位消息上
setMessages((prev) => {
  const next = [...prev];
  const last = next[next.length - 1];
  if (last?.role === "assistant" && last.streaming) {
    next[next.length - 1] = { ...last, content: last.content + delta };
  }
  return next;
});

代码块渲染:把消息文本按 ``` 围栏切段,奇数段是代码。流式期间最后一段可能是未闭合的半截围栏,先按代码块显示,流结束后自然纠正:

function renderContent(text: string) {
  return text.split(/```/).map((seg, i) =>
    i % 2 === 1 ? (
      <pre key={i}><code>{seg.replace(/^\w*\n/, "")}</code></pre>
    ) : (
      <p key={i}>{seg}</p>
    )
  );
}

三道验证各有一条主线:重复提交靠 streaming 中禁用按钮 + 逻辑忽略(A06 的两道防线);快速切换靠 abort 旧请求 + 重置缓冲;页面刷新靠服务端拉历史,遗留的 streaming 占位要收尾(A13 展开落库细节)。

示例拆解

切会话与停止的组合,顺序就是正确性:

const controllerRef = useRef<AbortController | null>(null);

function switchTo(id: string) {
  controllerRef.current?.abort(); // 1. 停掉旧请求:旧 delta 从此不再到达
  setMessages([]);                // 2. 清空缓冲:不残留上一个会话的半截内容
  loadHistory(id);                // 3. 再拉目标会话的历史
}

关键点:

  • abort 后 fetch 抛 AbortError,catch 里要区分「用户主动切走」和「真失败」——A12 课展开完整的错误分类。
  • 缓冲区 buf 跟随请求生命周期:切会话后旧请求的缓冲必须整体废弃,不能拼接。
  • 流中断时已收到的半截文本保留显示并标记「未完成」,绝不静默丢弃——用户可能正要复制它。

动手任务

  1. 给发送按钮接上两道防线(禁用 + 逻辑忽略),用连点验证只产生一条回复。
  2. 实现切会话时 abort:在 Network 面板确认旧请求变为 canceled,新会话里没有旧回复的残影。
  3. 实现 renderContent 的代码块渲染,问一个「写个防抖函数」,确认代码进了 pre/code;在流式过程中观察半截围栏的表现。

常见错误

  • streaming 中不禁用发送:两条回复穿插,且会话里一条用户消息对应两条 assistant 回复。
  • 切会话只切 id 不 abort:旧回复的 delta 继续到达,追加进新会话的消息列表——跨会话串台。
  • 流结束不清理状态:光标还在闪、按钮一直禁用,下一次发送点不动。finally 里必须复位。
  • 未闭合围栏当纯文本渲染:整个回答排版崩掉,代码糊进正文。

检查与复盘

自查清单:

  • 重复提交、切换会话、页面刷新三个场景都有验证操作和预期结果
  • delta 追加只发生在当前会话的占位消息上
  • 流中断保留半截文本并标记未完成

参考资料

检查题共 3 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1读代码中等

bug 现场:流式聊天界面偶发乱码和 JSON 解析报错,代码如下。指出问题。

const reader = res.body.getReader();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const text = new TextDecoder().decode(value);
  for (const event of text.split("data:")) {
    if (event.trim()) handleMessage(JSON.parse(event));
  }
}
Q2单选简单

流式过程中服务端发来 {"type":"error","category":"rate_limit","message":"请求太频繁"}。界面正确的处理是?

Q3单选简单

流式聊天界面收到 {"type":"tool","name":"search_courses","status":"running"} 事件时,界面上应该做什么?

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...