1. 背景与痛点
在 AI 助手场景中,用户期望"模型一边思考,内容一边逐渐显示",而不是等待完整响应回来后一次性展示。这要求前端同时解决两个问题:
如何读取流式接口:响应体分块到达,同一条消息可能被拆分到不同 chunk;且接口兼容 data: {...} 的 SSE-like 行流格式。
如何实现"打字机"节奏:每次收到增量后,不是整段替换,而是逐字/分段渐显,形成"正在输入"的体验。
本文基于真实项目(React + TypeScript,Vite 构建)的经验,给出完整实现方案。
2. 适用场景与目标
2.1 场景
扁平化输出:纯文本流式增长,打字机效果逐步写入当前消息框。
模块化输出:结构化结果在流式生成过程中被增量解析为多个分区,通过 onPartialResult 持续刷新。
2.2 核心目标
稳定读取 SSE-like 行流,正确处理 chunk 边界(半行 JSON)与 [DONE] 结束标记。
将解析出的增量文本按顺序累积,并以可控的"打字机节奏"更新 UI。
抽象出可复用的工具函数,与 React 解耦,便于测试和维护。
3. 技术栈与模块职责
| 分类 |
技术/模块 |
职责 |
| 前端框架 |
React + TypeScript |
状态驱动 UI 更新 |
| 构建代理 |
Vite |
开发环境转发 AI 网关请求 |
| 流式读取 |
fetch + ReadableStream |
读取 resp.body 分块 |
| 流式解析 |
sseLikeStreamText.ts(新增) |
buffer 拼接、行分割、JSON 解析、增量提取 |
| 打字机决策 |
typewriter.ts(新增) |
纯函数计算下一帧展示内容及停止条件 |
| UI 组件 |
AIChatPanel.tsx / AIMessage.tsx |
渲染文本输出区域与结构化消息 |
| 状态管理 |
useAiChatSessions.ts |
管理消息列表、流式请求、打字机循环 |
核心文件路径(基于 src/app/):
txt
复制代码
services/
aiChatClient.ts # streamChat() / streamDiagnosis()
sseLikeStreamText.ts # accumulateSseLikeJsonStreamText()
aiChatSessionStorage.ts
hooks/
useAiChatSessions.ts # 状态 + 打字机循环
utils/
typewriter.ts # decideTypewriterTick()
components/
AIChatPanel.tsx
AIMessage.tsx
shared/device/AIDiagnosisEntry.tsx # 模块化输出入口
4. 整体链路(一图看懂)
4.1 打字机逐字渲染主流程
onDelta
nextContent
AIChatPanel
useAiChatSessions.sendMessage
aiChatClient.streamChat
fetch + stream
accumulateSseLikeJsonStreamText
appendTypingDelta
queueTyping
pumpTypingTask(每帧)
decideTypewriterTick
patchMessage 更新
React 重渲染,逐字出现
4.2 模块化增量输出流程
模块区 Card accumulateSseLikeJsonStreamText streamDiagnosis AIDiagnosisEntry 模块区 Card accumulateSseLikeJsonStreamText streamDiagnosis AIDiagnosisEntry loop [每收到一个 delta] 触发诊断 fetch + 流式读取 onDelta(delta) rawText += delta, 尝试解析部分结构 onPartialResult(partial) setResult(partial) 渐进刷新
5. 核心实现详解
5.1 流式解析:处理 SSE-like 行流与 chunk 边界
5.1.1 协议形态
流式接口返回文本行流,每行可能是:
data: {"response": {...}} (SSE 格式)
纯 JSON 文本
DONE\] 表示结束
由于网络分块,一个完整的 JSON 行可能被拆到两个 chunk 中,必须用 buffer 拼接。
#### 5.1.2 工具函数:accumulateSseLikeJsonStreamText
位置:src/app/services/sseLikeStreamText.ts(示例文件名)
核心逻辑:
```ts
export async function accumulateSseLikeJsonStreamText({
stream,
signal,
parseJson,
extractText,
onDelta,
}: {
stream: ReadableStream | null;
signal: AbortSignal;
parseJson: (text: string) => T;
extractText: (parsed: T) => string | undefined;
onDelta: (delta: string) => void;
}) {
const reader = stream?.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\n+/);
buffer = lines.pop() ?? ""; // 保留最后一个可能不完整的行
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
processLine(line);
}
}
function processLine(rawJsonText: string) {
let jsonText = rawJsonText.trim();
if (jsonText.startsWith("data:")) {
jsonText = jsonText.slice(5).trim();
}
if (!jsonText || jsonText === "[DONE]") return;
try {
const parsed = parseJson(jsonText);
const delta = extractText(parsed);
if (delta) onDelta(delta);
} catch {
// 忽略不完整 JSON,等待后续 buffer 补全
}
}
}
```
#### 5.1.3 在 aiChatClient.ts 中复用
```ts
await accumulateSseLikeJsonStreamText({
stream: resp.body,
signal,
parseJson: (text) => JSON.parse(text),
extractText: extractTextFromChunk,
onDelta,
});
```
其中 extractTextFromChunk 遍历 chunk.response.output\[\].content\[\],找到 type === "output_text" 的 text 字段并返回。
### 5.2 打字机渲染:从 delta 到逐字显示
#### 5.2.1 节奏控制参数
TYPEWRITER_INTERVAL_MS = 20:每帧间隔
TYPEWRITER_STEP = 2:每次推进的字符数(可调)
#### 5.2.2 纯函数决策器:decideTypewriterTick
位置:src/app/utils/typewriter.ts
输入当前可见内容、目标内容、当前状态等,输出"是否需要 patch、下一帧内容、是否停止、是否继续排程"。
```ts
export function decideTypewriterTick(params: {
currentVisibleContent: string;
targetContent: string;
currentStatus: string;
step: number;
completionStatus?: string;
}) {
const { currentVisibleContent, targetContent, currentStatus, step, completionStatus } = params;
// 情况1:内容已完全一致
if (currentVisibleContent === targetContent) {
if (completionStatus && currentStatus !== completionStatus) {
return { patchRequired: true, nextContent: targetContent, nextStatus: completionStatus, shouldStop: true, scheduleNext: false };
}
return { patchRequired: false, shouldStop: true, scheduleNext: false };
}
// 情况2:目标内容与当前可见内容不匹配(比如错误强制同步)
if (!targetContent.startsWith(currentVisibleContent)) {
const nextStatus = completionStatus ?? "streaming";
return {
patchRequired: true,
nextContent: targetContent,
nextStatus,
shouldStop: completionStatus !== undefined,
scheduleNext: false,
};
}
// 情况3:正常推进
const nextContent = targetContent.slice(0, currentVisibleContent.length + step);
return {
patchRequired: true,
nextContent,
nextStatus: "streaming",
shouldStop: false,
scheduleNext: true,
};
}
```
#### 5.2.3 Hook 中消费决策
useAiChatSessions.ts 内的 pumpTypingTask:
```ts
const decision = decideTypewriterTick({
currentVisibleContent: currentContent,
targetContent: typingTask.targetContent,
currentStatus: message.status,
step: TYPEWRITER_STEP,
completionStatus: typingTask.completionStatus, // 'done' 或 'error'
});
if (decision.patchRequired) {
patchMessage(sessionId, messageId, (msg) => ({
...msg,
content: decision.nextContent ?? msg.content,
status: decision.nextStatus,
}));
}
if (decision.shouldStop) {
typingTaskRef.current = null;
finalizeStreamingStateIfIdle();
return;
}
if (decision.scheduleNext) {
typingTimerRef.current = setTimeout(pumpTypingTask, TYPEWRITER_INTERVAL_MS);
}
```
### 5.3 结构化消息渲染
AIMessage.tsx 不参与逐字动画,仅根据 message.content 实时解析结构:
按换行分割
以 【标题】 格式识别段落标题
标题下的行作为正文渲染为
由于打字机每帧都会更新 message.content,React 自动重渲染,从而实现渐进显示。
## 6. 验证与效果
### 6.1 验证步骤
触发一次 AI 对话,观察文字是否按固定节奏逐字出现。
检查停止/错误场景:状态是否正确转为 done 或 error,内容是否完整显示。
触发模块化输出(如诊断结果),观察分区是否在部分解析时持续刷新。
### 6.2 预期效果
文本区域内容平滑增长,无突兀跳变。
流式结束时自动停止打字机循环。
模块区卡片内容随解析进度逐步填充。
## 7. 踩坑与注意事项
#### chunk 边界半行 JSON
必须保留 buffer 中最后一行,不能直接按行解析每个 chunk。
#### data: 前缀处理
SSE 格式必须去掉 `data:` 再 JSON.parse。
#### JSON 解析失败容忍
不完整 JSON 直接跳过,等待后续 chunk 补全。
#### 频繁 patch 的性能
当前每帧都会触发 React 重渲染和 localStorage 写入(如有持久化)。若消息极长,可考虑对 storage 写入做节流。
## 8. 总结
本文从项目真实需求出发,系统性地实现了:
SSE-like 行流解析器:处理 buffer、行分割、协议前缀、\[DONE\],输出纯增量文本。
打字机节奏决策器:纯函数决定每帧显示多少内容,何时停止。
与 React 的集成:在自定义 hook 中驱动定时器,每次 patch 消息状态,自动触发 UI 更新。
这两类抽象完全解耦于框架,可直接复用到任何需要"流式增量 + 渐进展示"的前端项目中。如果你也在实现类似 AI 对话效果,不妨参考本文的拆分方式,能让代码更清晰、更容易维护