React 前端的 SSE 状态机与交互闭环
先复现重复 token 的 bug
后端从第 2 篇起就在流式输出了。前端第一版很容易写成这样:
const eventSource = new EventSource(`/chat/${conversationId}/events`);
eventSource.onmessage = (e) => {
const event = JSON.parse(e.data);
if (event.type === 'token') {
setText(text + event.text); // 直接拼
}
};
测一下,能用。拔掉网线再插回去,答案开头重复了一遍:
3.1.4 上渲染进程起不来,先3.1.4 上渲染进程起不来,先结束残留进程再清缓存……
EventSource 断线时会自动重连,但它重连时不会告诉服务端「我收到哪里了」,所以服务端从头重放。浏览器拿到前 20 个 token 第二遍,继续 text + delta,于是开头那句话出现了两次。
这不是偶现——只要网络抖一下就能复现。地铁里、电梯里、WiFi 切 4G 的瞬间,都是这个 bug 的触发点。
四层分离:字节流、帧、状态、界面
第 1 层:字节流。 ReadableStream<Uint8Array> 经过 TextDecoderStream 变成字符串片段。一次 read() 可能只有半个事件,也可能包含三个完整事件,还可能只是一个心跳注释。
第 2 层:帧解析。 按 SSE 格式 \n\n 切分,把不完整的那截留到下一轮。产出完整的事件字符串,不碰业务:
async function* parseSSE(response: Response) {
const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
while (true) {
const {value, done} = await reader.read();
if (done) break;
buffer += value;
const frames = buffer.split('\n\n');
buffer = frames.pop()!; // 最后一截可能不完整,留给下一轮
for (const frame of frames) {
if (frame.startsWith(':')) continue; // 心跳注释
const lines = frame.split('\n');
const eventType = lines.find(l => l.startsWith('event:'))?.slice(6).trim() || 'message';
const dataStr = lines.find(l => l.startsWith('data:'))?.slice(5).trim();
if (dataStr) {
try {
yield { type: eventType, ...JSON.parse(dataStr) };
} catch {
// 忽略残帧
}
}
}
}
}
这个函数把第 2 篇定义的 SSE 线协议翻译成前端数据结构。它的输入是字节流,输出是带 type 的事件对象,中间不依赖任何界面业务逻辑。
第 3 层:reducer。 事件进状态,状态出界面。用 useReducer 而不是五个 useState,token、审批、done 的转换才是原子的:
type ChatState = {
conversationId?: string;
phase: 'idle' | 'streaming' | 'approval' | 'done' | 'error';
answer: string;
lastSeq: number;
citations: Citation[];
toolRuns: ToolRun[];
};
function reduce(state: ChatState, event: ServerEvent): ChatState {
if (event.type === 'status')
return {...state, conversationId: event.conversation_id, phase: 'streaming'};
if (event.type === 'token' && event.seq > state.lastSeq)
return {...state, answer: state.answer + event.text, lastSeq: event.seq};
if (event.type === 'tool_call')
return {...state, toolRuns: [...state.toolRuns, {id: event.id, name: event.name, status: 'calling'}]};
if (event.type === 'tool_result') {
const runs = state.toolRuns.map(r =>
r.id === event.id ? {...r, status: event.ok ? 'succeeded' : 'failed', result: event.result} : r
);
return {...state, toolRuns: runs};
}
if (event.type === 'approval_required')
return {...state, phase: 'approval', approvalId: event.approval_id, approvalSummary: event.summary};
if (event.type === 'done')
return {...state, phase: 'done', citations: event.citations || [], lastSeq: event.seq};
if (event.type === 'cancelled')
return {...state, phase: 'idle'};
return state;
}
去重在这里:event.seq > state.lastSeq 才更新。断线重连后收到已渲染过的 token,这个条件为假,事件被丢掉,答案不重复。
seq 必须覆盖所有事件类型。只给 token 编号,工具状态和审批照样会重复——界面上出现两个审批卡片,用户点哪个?都不对。
第 4 层:界面。 纯展示,不持有状态,完全由 state 驱动:
<div className={s.answer}>
{state.phase === 'streaming' && <div className={s.typing}>{state.answer}<Cursor /></div>}
{state.phase === 'done' && <Markdown text={state.answer} />}
{state.toolRuns.map(run => <ToolStatus key={run.id} {...run} />)}
{state.phase === 'approval' && <ApprovalCard {...state} onConfirm={handleApprove} />}
{state.citations.map(c => <Citation key={c.id} {...c} />)}
</div>
这个写法的价值在刷新时体现。如果界面持有一部分状态(比如折叠状态、展开的引用),刷新后这些状态就没了,而 answer 和 citations 还在——界面和数据不一致。全部状态放 reducer,刷新后从服务端加载快照重建 state,界面就是对的。
断线重连:带位置恢复
async function connect(conversationId: string, lastSeq: number) {
const url = lastSeq > 0
? `/chat/${conversationId}/events?last_seq=${lastSeq}`
: `/chat/${conversationId}/events`;
return fetch(url, {headers: {'Accept': 'text/event-stream'}});
}
后端支持 last_seq 查询参数(第 2 篇就留了这个口子)。服务端从 lastSeq + 1 继续发,即便网络抖动时它重发了几个旧事件,reducer 也会把它们过滤掉。
重连策略:最多 3 次,退避 300 / 900 / 2700 ms,收到 done 或 cancelled 停止。页面可见性变化时(切到后台 15 秒)主动断开,回到前台再连。
useEffect(() => {
if (!conversationId || state.phase === 'done') return;
let reconnects = 0;
const tryConnect = async () => {
try {
const response = await connect(conversationId, state.lastSeq);
for await (const event of parseSSE(response)) {
dispatch(event);
if (event.type === 'done' || event.type === 'cancelled') return;
}
} catch (err) {
if (reconnects < 3) {
reconnects++;
await sleep(300 * 3 ** (reconnects - 1));
tryConnect();
} else {
dispatch({type: 'error', message: 'connection_failed'});
}
}
};
tryConnect();
}, [conversationId, state.phase]);
注意依赖数组里没有 state.lastSeq。加上它的话,每收到一个 token 都会触发重连——lastSeq 变了,effect 重跑。这是个经典的 React 陷阱。正确的做法是 dispatch 在闭包外,effect 只在会话开始时跑一次。
工具执行与审批
工具状态单独一行展示,用颜色区分:
function ToolStatus({name, status, result}: ToolRun) {
return (
<div className={s.tool} data-status={status}>
<span className={s.name}>{name}</span>
{status === 'calling' && <Spinner />}
{status === 'succeeded' && <CheckIcon />}
{status === 'failed' && <span className={s.error}>{result?.error}</span>}
</div>
);
}
data-status 属性让 CSS 能按状态上色,不用写三个 class。失败时展示错误信息,但不展示完整的工具参数——那些可能包含敏感字段。
审批卡片显示服务端返回的摘要和字段,不允许前端自行拼接:
function ApprovalCard({approvalId, approvalSummary, onConfirm}: ApprovalProps) {
return (
<div className={s.approval}>
<div className={s.summary}>{approvalSummary}</div>
<button onClick={() => onConfirm(approvalId)}>确认</button>
<button onClick={() => dispatch({type: 'cancelled'})}>取消</button>
</div>
);
}
确认按钮调用 POST /approve/{approval_id},成功后继续消费原会话的 SSE——不开新会话,conversation_id 不变。后端从第 13 篇的 checkpoint 恢复,前端只是在等一个 resume 信号。
引用折叠只影响展示,不删除 state.citations 里的数据。点击引用时再请求原文,并让服务端重新做权限检查:
async function fetchSource(citationId: string) {
const resp = await fetch(`/citations/${citationId}/source`, {
headers: {'Authorization': `Bearer ${token}`}
});
if (!resp.ok) return null; // 权限不足或文档被删,静默失败
return resp.json();
}
返回 null 时显示「原文不可用」,不要显示 404 或权限错误——那会泄漏「这个 ID 存在」这个信息。
上传进度:轮询状态接口
第 9 篇的 POST /documents 返回 202 和 status_url。前端拿到 URL 后开始轮询:
useEffect(() => {
if (!statusUrl) return;
const timer = setInterval(async () => {
const next = await fetch(statusUrl).then(r => r.json());
setProgress(next);
if (next.status === 'ready' || next.status === 'failed') {
clearInterval(timer);
}
}, 1000);
return () => clearInterval(timer);
}, [statusUrl]);
进度条根据 progress 字段(0–1)和 status 渲染:
<div className={s.upload}>
<div className={s.bar} style={{width: `${progress.progress * 100}%`}} />
<span className={s.status}>{STATUS_TEXT[progress.status]}</span>
{progress.status === 'failed' && (
<button onClick={handleRetry}>重新上传</button>
)}
</div>
failed 状态展示 error_code(解析失败、不支持的格式、上游超时)和重试按钮。重试时复用原 document_id 走幂等逻辑,避免生成多份索引。
文件选择后立刻禁用输入框,上传完成或失败后恢复——防止用户连点两次。
安全 Markdown 渲染
模型输出是不可信输入。用 react-markdown 并关闭原始 HTML:
<ReactMarkdown
components={{
a: ({href, children}) => (
<a href={href} target="_blank" rel="noreferrer">{children}</a>
),
code: ({inline, children}) => {
const text = String(children).replace(/\n$/, '');
return inline ? <code>{text}</code> : <CodeBlock text={text} />;
},
}}
remarkPlugins={[remarkGfm]}
rehypePlugins={[]} // 不装任何允许 HTML 的插件
>
{state.answer}
</ReactMarkdown>
链接统一加 target="_blank" 和 rel="noreferrer",防止 window.opener 劫持。代码块在高亮前先 escape,不能假定模型不会输出 <script>。
引用编号 [1] 在 Markdown 渲染后再替换成可点击组件:
function replaceReferences(html: string, citations: Citation[]) {
return html.replace(/\[(\d+)\]/g, (_, num) => {
const cite = citations.find(c => c.id === num);
return cite ? `<sup class="cite" data-id="${num}">[${num}]</sup>` : `[${num}]`;
});
}
点击 <sup> 时用 data-id 去 state.citations 里找,而不是解析 innerHTML——DOM 内容是不可信的。
移动端适配
审批操作固定在可见区域,避免被键盘遮住:
.approval {
position: sticky;
bottom: 0;
background: #fff;
box-shadow: 0 -2px 8px rgba(0,0,0,0.1);
padding: 16px;
}
长引用折叠,三行后显示「展开」:
.citation {
max-height: 4.5em;
overflow: hidden;
&[data-expanded="true"] { max-height: none; }
}
输入框在键盘弹出时不应该被遮住。iOS 的 visualViewport API 能拿到键盘高度:
useEffect(() => {
const onResize = () => {
if (!visualViewport) return;
const keyboardHeight = window.innerHeight - visualViewport.height;
setInputBottom(keyboardHeight);
};
visualViewport?.addEventListener('resize', onResize);
return () => visualViewport?.removeEventListener('resize', onResize);
}, []);
桌面端不需要这些——直接写在 @media (max-width: 768px) 里。
错误恢复与重试
聊天失败允许「重试本轮」,但重试请求必须复用原 conversation_id:
async function retryLast() {
const lastUserMessage = state.messages.findLast(m => m.role === 'user');
if (!lastUserMessage) return;
dispatch({type: 'reset', keepMessages: state.messages.length - 1}); // 丢掉失败那条
await sendMessage(lastUserMessage.content, state.conversationId); // 同一个会话
}
不新开会话的原因:历史消息和已收集的 handoff 状态都在原会话里,新开一个就丢了。
审批超时或拒绝后, 前端回到 idle 状态,允许继续提问——不要因为一次拒绝就把整个会话锁死。
网络彻底挂掉(重连三次都失败)时显示明确提示,并提供「复制当前对话」按钮——用户至少能把已有内容保存下来,而不是眼睁睁看着它消失。
本篇验收标准
- 模拟断线(DevTools Network Offline)后重连,答案不重复、不丢 token,最终只出现一个
done。
- 工具调用、失败、审批、取消均有明确视觉状态,审批卡片内容来自服务端而非前端拼接。
- 引用点击能打开正确原文;权限不足时前端展示「不可用」而非 404 / 403 字样。
- 上传接口返回 202 后能显示排队、解析、embedding、完成和失败五种进度;失败后重试不产生重复索引。
- 桌面与移动端均不溢出;移动端键盘弹出时输入框不被遮挡、审批卡片固定在可见区域。
- 刷新页面后从服务端加载会话快照,界面状态与
state 一致。
- Markdown 渲染关闭原始 HTML,链接带
rel="noreferrer",代码块高亮前 escape。
界面到这里就能用了。下一篇是最后一篇:Trace、成本统计、限流、压测和可回滚部署——把这个系统推到能真实运行的状态。