043AI Agent阅读记录
第 043 卷

从零开始 AI Agent 实战(十四):React 前端的 SSE 状态机与交互闭环

构建可用的 Agent Web 客户端:SSE 增量解析、断线重连去重、工具状态、引用折叠、审批和文档上传进度。

乌漆嘛黑和 Ahri
第 043 期

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 上渲染进程起不来,先结束残留进程再清缓存……
Preview
断线重连后答案为什么重复了

EventSource 断线时会自动重连,但它重连时不会告诉服务端「我收到哪里了」,所以服务端从头重放。浏览器拿到前 20 个 token 第二遍,继续 text + delta,于是开头那句话出现了两次。

这不是偶现——只要网络抖一下就能复现。地铁里、电梯里、WiFi 切 4G 的瞬间,都是这个 bug 的触发点。

四层分离:字节流、帧、状态、界面

Preview
从网络字节到界面的四层数据流

第 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>

这个写法的价值在刷新时体现。如果界面持有一部分状态(比如折叠状态、展开的引用),刷新后这些状态就没了,而 answercitations 还在——界面和数据不一致。全部状态放 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,收到 donecancelled 停止。页面可见性变化时(切到后台 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-idstate.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 状态,允许继续提问——不要因为一次拒绝就把整个会话锁死。

网络彻底挂掉(重连三次都失败)时显示明确提示,并提供「复制当前对话」按钮——用户至少能把已有内容保存下来,而不是眼睁睁看着它消失。

本篇验收标准

  1. 模拟断线(DevTools Network Offline)后重连,答案不重复、不丢 token,最终只出现一个 done
  2. 工具调用、失败、审批、取消均有明确视觉状态,审批卡片内容来自服务端而非前端拼接。
  3. 引用点击能打开正确原文;权限不足时前端展示「不可用」而非 404 / 403 字样。
  4. 上传接口返回 202 后能显示排队、解析、embedding、完成和失败五种进度;失败后重试不产生重复索引。
  5. 桌面与移动端均不溢出;移动端键盘弹出时输入框不被遮挡、审批卡片固定在可见区域。
  6. 刷新页面后从服务端加载会话快照,界面状态与 state 一致。
  7. Markdown 渲染关闭原始 HTML,链接带 rel="noreferrer",代码块高亮前 escape。

界面到这里就能用了。下一篇是最后一篇:Trace、成本统计、限流、压测和可回滚部署——把这个系统推到能真实运行的状态。