Skip to content

第 12 章 前端:把事件流拼成界面 ​

本章代码 code/ch12/(index.html / app.js / style.css / mock_data.js / main.py) · 读完约 26 分钟

12.0 上一章留下的问题 ​

第 11 章结束时,后端已经在往外吐事件了。用 curl -N 能看到:

id: 1
event: REPLY_START
data: {"reply_id": "a59289d2...", "ts": 1792639244.61}

id: 2
event: HINT_BLOCK
data: {"hint": "<current-time>2026-10-22T03:20:44</current-time>", ...}

id: 3
event: TEXT_BLOCK_DELTA
data: {"block_id": "96bb7d6d...", "delta": "先扫一遍目录。"}

管道通了,能读的人只有你。产品要能读的人是用户。

很多人把前端这一步理解成"把字符串接到页面上",于是写出这样的代码:每收到一个 delta,就把整段对话用 innerHTML 重画一遍。它在两轮对话、一句话的规模下能跑;一旦出现思考块、工具卡片、折叠状态,就立刻散架——折叠好的块会自己弹开,滚动条会跳回顶部,用户选中的文字会消失。

前端真正的任务不是显示文字,是把十几个并行的事件归位到它们该在的格子里。 这一章做这件事。

12.1 先跑起来 ​

后端一行没改。第 12 章的 main.py 只有十几行,做两件事:把第 11 章的目录挂进 sys.path,把静态目录换成整个 ch12/。

python
HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "ch11"))

import main as ch11            # 第 11 章的 app / 路由 / build_agent
app = ch11.app

# 第 11 章在 "/" 上挂了它自己的验证页;本章要把根路径让给真正的界面,
# 所以先摘掉那条路由,再把整个目录挂到 "/" 上。
app.router.routes = [r for r in app.router.routes if getattr(r, "path", "") != "/"]
app.mount("/", StaticFiles(directory=str(HERE), html=True), name="ui")

StaticFiles(html=True) 的作用是:访问 / 时自动返回目录里的 index.html,访问 /app.js 时返回那个文件。不需要为每个静态文件写一条路由。

三种跑法,按你手上的条件选:

跑法命令用的是什么
只看界面双击 mock.html一份写死的事件流(mock_data.js)
前后端联调MOCK=1 python main.py真后端 + 假模型(第 11 章的 MockModel)
真实对话配好 .env 后 python main.py真后端 + 真模型

第一种连服务器都不用起。这不是偷懒的产物——前后端对接时,先让前端在假事件流上跑通,接口一好就能直接切成真数据,改动只有一个 MOCK 开关。

空态

三栏从左到右:会话列表、对话流、本次运行。

右侧那条栏值得单独说一句。它显示的不是装饰性指标,而是前面几章做出来的能力的可视化:

  • 任务清单——第 8 章让 Agent 把计划写进 agent.state.tasks_context。写进去却看不见,等于没写
  • 用量——第 10 章的 MODEL_CALL_END 事件带着 input_tokens / output_tokens,不显示就只能等月底账单
  • 注入与事件——第 8 章的 HINT_BLOCK 是模型"看到的额外信息",默认用户不可见,这里把它摊开

12.2 事件的归位表 ​

先给结论:整个前端只做一件事,把事件映射成界面动作。这张表是本章的骨架。

事件界面动作
REPLY_START新建一个助手气泡,标记为"运行中"
THINKING_BLOCK_DELTA按 block_id 找思考块,追加文字
TEXT_BLOCK_DELTA按 block_id 找正文块,追加文字
TOOL_CALL_START新建工具卡片,状态"执行中"
TOOL_CALL_DELTA拼接工具参数(是 JSON 的片段,不是完整对象)
TOOL_RESULT_TEXT_DELTA拼接工具返回的正文
TOOL_RESULT_END卡片状态改为成功或失败,算出耗时,刷新时间线
MODEL_CALL_END累加调用次数与 token
HINT_BLOCK只进右侧事件日志,不打扰对话
REQUIRE_USER_CONFIRM气泡标记"等待中",准备确认界面
error插一条红色告警
REPLY_END / done收尾,解锁输入框

两个键决定了这一切能不能对齐:

  • reply_id——这一轮回复里所有事件共用。用它找到"该往哪个助手气泡里放"
  • block_id / tool_call_id——同一轮里的不同内容块。用它找到"该往哪个格子里放"

一旦你决定按这两个 id 归位,就再也不会想去重新渲染整个列表了。

12.3 四十行的 SSE 客户端 ​

浏览器自带 EventSource,五行就能连上 SSE。我们不使用它,原因有两个,都是硬伤。

第一,它不能发 POST。 我们发起一轮对话需要带上 session_id 和文本,EventSource 只能做 GET,把这些塞进查询串既难看又有长度限制。

第二,它会在连接结束后自动重连。 一轮回复结束,后端发 done 然后关连接,EventSource 认为这是网络故障,两秒后重新连上,后端再发一遍 done,如此循环。你在网络面板里会看到一条永不停止的请求。

所以手写。核心是一个 \n\n 分帧循环:

js
async function readStream(url, { onFrame, onOpen, signal }) {
  const res = await fetch(url, { signal, headers: { Accept: "text/event-stream" } });
  if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
  if (onOpen) onOpen();

  const reader = res.body.getReader();
  const decoder = new TextDecoder("utf-8");
  let buf = "";

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buf += decoder.decode(value, { stream: true });

    let idx;
    while ((idx = buf.indexOf("\n\n")) !== -1) {
      const raw = buf.slice(0, idx);
      buf = buf.slice(idx + 2);          // 注意:切掉,别清空
      const frame = parseFrame(raw);
      if (frame) onFrame(frame);
    }
  }
}

五个容易写错的地方,每一个都值得单独看一眼。

① decoder.decode(value, { stream: true })。 中文在 UTF-8 里占三个字节,网络分片完全可能从这三个字节中间切开。不带 stream: true,半个字会被解成替换字符 �,而且它一出现就无法复原。这个参数告诉解码器"后面还有,先攒着"。

② buf 是切掉不是清空。 一次 read() 可能收到三帧半。前三次循环把整帧取走,第四次 indexOf 返回 -1,剩下的半帧必须留在 buf 里等下一片数据。写成 buf = "" 会随机丢帧——症状是偶尔少半句话,很难查。

③ 注释帧要跳过。 心跳是 : ping\n\n,按规约以冒号开头,没有 data 行。parseFrame 里那句 if (!line || line.startsWith(":")) continue; 就是为它准备的。不跳过会得到空 data,JSON.parse("") 抛异常。

④ 不要靠 done 中断读取。 done 看起来像"结束",于是很容易顺手把它写成一个哨兵异常:

js
class StopStream extends Error {}

onFrame: (frame) => {
  if (frame.event === "done") throw new StopStream();   // 看着很合理,其实有坑
}

这个写法在实时对话里能用,一打开历史会话就会露馅。服务端会把缓冲区里的帧全部补发一遍,而缓冲区里每轮回复都有一个 done。碰到第一个就停,后面几轮的内容永远渲染不出来。症状是刷新页面后只剩第一问第一答,看不出任何报错,翻后端日志也查不出问题,因为前端根本没报错,它只是提前退出了。

正确的做法是把收尾交给服务端:它发完 done 就关连接,read() 返回 done: true,循环自然退出。

⑤ 每一轮发送之后要重新连接。 服务端在 done 之后会把这条流的订阅队列关掉,所以它是一次性的。send() 的最后一句必须是 connect()。同时用一个 state.connecting 做去重,避免连点两次发送时开出两条流去抢同一批帧。

js
onFrame: (frame) => {
  if (frame.id) state.lastEventId = frame.id;
  handle(frame.event, frame.data);      // done 也照常交给 reducer
}

真正需要重连的只有网络故障。外层的 catch 里,AbortError 是用户主动断开,直接退出;其余情况等三秒重连,这一次会带上 last_event_id,后端从缓冲区把断线期间的帧补发过来——这就是第 11 章那个环形缓冲区的用处。

12.4 reducer:把增量放进格子里 ​

数据结构只有三层:消息数组 → 消息里的块数组 → 块对应的 DOM 节点。块和 DOM 之间用 block.el 直接挂着,收到增量时不需要再查一次 querySelector。

js
function ensureBlock(msg, blockId, kind) {
  const key = blockId || `${kind}-${msg.blocks.length}`;
  let block = msg.blocks.find((b) => b.key === key);
  if (!block) {
    block = { key, kind, text: "" };
    msg.blocks.push(block);
  }
  return block;
}

这里有个容易被忽略的设计:kind 只在新块创建时用一次。 后续所有事件都靠 block_id 命中,不靠"第几个块"。因为一轮回复里模型的输出顺序是不可预期的——可能先思考、再调工具、回来再思考一次。

于是正文的追加就变得很无聊,无聊正是我们要的:

js
case "TEXT_BLOCK_DELTA": {
  const msg = findByReply(d.reply_id) || currentMessage();
  if (!msg) break;
  const block = ensureBlock(msg, d.block_id, "text");
  block.text += d.delta || "";
  appendText(msg, block);      // 只改这一个节点的 textContent
  break;
}

appendText 里只做 block.el.textContent = block.text,不碰其他节点。滚动位置、折叠状态、用户选中的文字全部保留。这就是流式输出该有的样子。

流式输出中

上图里能看到三段内容的层次:折叠的思考过程、正文段落、右侧逐条到达的事件日志。日志第一行是 REPLY_START,最后一行是最新的——它是倒序的,因为排查问题时你关心的是刚刚发生了什么。

12.5 工具参数是流式拼出来的 ​

这是本章最容易踩空的地方,值得单独一节。

直觉上,工具调用事件应该长这样:

json
{"tool_call": {"name": "Glob", "input": {"pattern": "**/*.png"}}}

实际不是。翻一下事件类的字段定义就清楚了:

ToolCallStartEvent   : reply_id, tool_call_id, tool_call_name
ToolCallDeltaEvent   : reply_id, tool_call_id, delta
ToolCallEndEvent     : reply_id, tool_call_id

ToolCallStartEvent 没有参数,只有工具名。参数在随后的一串 TOOL_CALL_DELTA 里,而且是未经解析的 JSON 字符串片段:

TOOL_CALL_DELTA  {"pattern"
TOOL_CALL_DELTA  : "**/*.png", "pa
TOOL_CALL_DELTA  th": "assets"}

原因和文字流式一样:模型是一个 token 一个 token 生成这些参数的,框架把它按到达顺序转成了增量。所以前端必须自己拼:

js
case "TOOL_CALL_DELTA": {
  const block = ensureBlock(msg, d.tool_call_id, "tool");
  block.input = (block.input || "") + (d.delta || "");
  renderTool(msg, block);      // 拼完之前只做展示,不要 JSON.parse
  break;
}

在卡片头部展示参数时,formatArgs 里那个 try / catch 就是为此准备的:拼到一半的 JSON 一定解析失败,此时退回显示原始片段。等 TOOL_CALL_END 到达,参数才是完整的。

同理,工具的返回正文走的是 TOOL_RESULT_TEXT_DELTA,也是一个字一个字回来的。第 11 章的白名单里原本没有这两个事件——写前端时才发现"界面拿不到参数和返回值",补进去就是这样:

python
EventType.TOOL_CALL_DELTA,
EventType.TOOL_RESULT_TEXT_DELTA,

实践教训:白名单应该按"界面需要什么"来定,而不是按"后端觉得什么重要"来定。 这两类事件不转发,页面上的工具卡片就只剩一个名字和"完成"两个字。

工具执行中

图里第一个调用已经完成,展开显示返回的路径列表;第二个 Read 还在执行,所以是橙色的"执行中",它上面那条时间线也只有一枚 ✓ Glob 346ms 的徽章。

12.6 工具时间线 ​

这是本项目最值得抄走的一个小设计。

一次回复里可能发生十几次工具调用,如果只把它们做成一张张卡片竖着排,用户看到的是十张长得差不多的盒子,很难判断"它到底干了多少事"。时间线把同一轮的调用压成一行横向徽章:

✓ Glob 346ms   ✕ PowerShell 274ms   ◌ Read

三段信息,一眼可读:做了什么(工具名)、成不成(图标与颜色)、花了多久(毫秒)。三段都来自已经收到的事件:名字来自 TOOL_CALL_START,成败来自 TOOL_RESULT_END 的 state,耗时是两次事件的时间差。

耗时用哪个时钟算,这里有个细节。初版用的是 performance.now(),浏览器本地时钟,看起来没问题,但把网络来回的时间也算进了"工具执行时间"——一个 20ms 的工具可能显示成 400ms。改成用帧里的 ts:

js
block.elapsed = (d.ts ? d.ts * 1000 : performance.now()) - block.startedAt;

ts 是后端 time.time() 打的时间戳,两端都用它,算出来才是真的"工具执行了多久"。这也是第 11 章在 event_to_dict 里塞 ts 的原因——不是为了好看。

任务清单

右侧任务清单的三种状态(已完成、进行中、待办)来自第 8 章的 TaskList 工具。这里只做一件事:把 task.state 映射成颜色。注意字段名是 state 而不是 status——第 8 章提过这个不一致,前端也躲不掉。

12.7 三个细节 ​

滚动吸底:用户往回看时不要抢位置。 流式输出每来一段文字都要滚到底,但用户手动往上翻历史时必须停手。判断方法很简单:

js
messagesEl().addEventListener("scroll", () => {
  const box = messagesEl();
  const gap = box.scrollHeight - box.scrollTop - box.clientHeight;
  state.stick = gap < 40;      // 离底部 40px 以内才算"贴着"
});

gap < 40 而不是 === 0,是因为滚动有亚像素误差,四舍五入之后永远不等于 0。40px 给了一个容差窗口。

输入框自增高。 固定的 rows="1" 装不下三行的问题,但直接给一个高盒子又难看。input 事件里先把高度设成 auto(这样才能缩回去),再设成 scrollHeight 并封顶 140px。

出错要说清楚是谁的错。 页面上的红色告警分两种来源:工具自己返回的错误(TOOL_RESULT_END 的 state == "error",卡片变红但流程继续),和整轮回复失败(error 帧,流程终止)。

出错

图里这两件事同时发生了:工具卡片是红的(工具层面失败),下面那条告警是整轮的(权限引擎拒绝了调用,回复到此为止)。第 11 章的 _run 在 except 里发 error,finally 里发 done,所以输入框会解锁,用户可以换一句话再问。

12.8 三个常见错误 ​

错误 1:用 EventSource,然后被自动重连拖死。

症状:Network 面板里同一条流不断重连;对话内容重复出现;后端日志里 GET /api/stream/xxx 每秒一条。

原因:done 后连接关闭,EventSource 视为异常并重连。

对策:用 fetch + ReadableStream 手写客户端。done 之后不要自己抛异常终止,让服务端关掉连接即可;每一轮发送结束后再 connect() 重新订阅。

错误 2:每个 delta 都重绘整段对话。

症状:思考块自己弹开;滚动条跳回顶部;用户复制到一半的文字消失。

原因:innerHTML = 重新拼的 HTML 会销毁并重建所有子节点,折叠状态、滚动位置、选区都挂在被销毁的节点上。

对策:按 block_id 找到块对应的 DOM 节点,只改它的 textContent。本章的 appendText 只有一行。

错误 3:去 TOOL_CALL_START 里取工具参数。

症状:工具卡片的参数一直是空的,或者显示 undefined。

原因:ToolCallStartEvent 只有 tool_call_id 和 tool_call_name,参数在 TOOL_CALL_DELTA 里分片到达。

对策:在 TOOL_CALL_START 里初始化一个空字符串,在 TOOL_CALL_DELTA 里累加,TOOL_CALL_END 之后才 JSON.parse。顺带一提,第 10 章讲中间件改写参数时也踩过同一个点:ToolCallBlock.input 是字符串,不是对象。

12.9 小结 ​

这一章把事件流变成了界面。四件事:

  1. 归位靠 id——reply_id 找气泡,block_id / tool_call_id 找格子,不靠顺序
  2. 手写 SSE 客户端——EventSource 不能发 POST,且会在 done 后无限重连
  3. 流式的不只是文字——工具参数与工具返回值都是增量,必须自己拼完整再解析
  4. 只更新变化的那一小块——重绘整段会破坏折叠状态、滚动位置和选区

下一章补上最后一块交互闭环:第 11 章的后端已经能发 REQUIRE_USER_CONFIRM、能接收 POST /api/confirm,但界面上还没有按钮。除此之外,模型有时需要反过来问用户一个问题("你要删的是这三个里的哪个?"),有时用户想中途叫停。这三件事——确认、提问、中断——合起来才是完整的对话。

教程正文采用 CC BY-NC-SA 4.0 许可,code/ 下示例代码可自由使用