流式聊天界面
流式聊天界面 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
把 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跟随请求生命周期:切会话后旧请求的缓冲必须整体废弃,不能拼接。 - 流中断时已收到的半截文本保留显示并标记「未完成」,绝不静默丢弃——用户可能正要复制它。
动手任务
- 给发送按钮接上两道防线(禁用 + 逻辑忽略),用连点验证只产生一条回复。
- 实现切会话时 abort:在 Network 面板确认旧请求变为 canceled,新会话里没有旧回复的残影。
- 实现
renderContent的代码块渲染,问一个「写个防抖函数」,确认代码进了 pre/code;在流式过程中观察半截围栏的表现。
常见错误
- streaming 中不禁用发送:两条回复穿插,且会话里一条用户消息对应两条 assistant 回复。
- 切会话只切 id 不 abort:旧回复的 delta 继续到达,追加进新会话的消息列表——跨会话串台。
- 流结束不清理状态:光标还在闪、按钮一直禁用,下一次发送点不动。
finally里必须复位。 - 未闭合围栏当纯文本渲染:整个回答排版崩掉,代码糊进正文。
检查与复盘
自查清单:
- 重复提交、切换会话、页面刷新三个场景都有验证操作和预期结果
- delta 追加只发生在当前会话的占位消息上
- 流中断保留半截文本并标记未完成
参考资料
- MDN:AbortController —— 取消请求的标准机制
- MDN:ReadableStream —— reader.read() 循环与流关闭
- React 官方文档:Managing State —— 消息列表的状态组织
- 下一课 A12 把「停止」和「失败」做完整:中断、重试与恢复。
读完这一课了?
标记完成后,工作台会安排接下来的学习。