第 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/。
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 分帧循环:
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 看起来像"结束",于是很容易顺手把它写成一个哨兵异常:
class StopStream extends Error {}
onFrame: (frame) => {
if (frame.event === "done") throw new StopStream(); // 看着很合理,其实有坑
}这个写法在实时对话里能用,一打开历史会话就会露馅。服务端会把缓冲区里的帧全部补发一遍,而缓冲区里每轮回复都有一个 done。碰到第一个就停,后面几轮的内容永远渲染不出来。症状是刷新页面后只剩第一问第一答,看不出任何报错,翻后端日志也查不出问题,因为前端根本没报错,它只是提前退出了。
正确的做法是把收尾交给服务端:它发完 done 就关连接,read() 返回 done: true,循环自然退出。
⑤ 每一轮发送之后要重新连接。 服务端在 done 之后会把这条流的订阅队列关掉,所以它是一次性的。send() 的最后一句必须是 connect()。同时用一个 state.connecting 做去重,避免连点两次发送时开出两条流去抢同一批帧。
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。
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 命中,不靠"第几个块"。因为一轮回复里模型的输出顺序是不可预期的——可能先思考、再调工具、回来再思考一次。
于是正文的追加就变得很无聊,无聊正是我们要的:
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 工具参数是流式拼出来的
这是本章最容易踩空的地方,值得单独一节。
直觉上,工具调用事件应该长这样:
{"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_idToolCallStartEvent 没有参数,只有工具名。参数在随后的一串 TOOL_CALL_DELTA 里,而且是未经解析的 JSON 字符串片段:
TOOL_CALL_DELTA {"pattern"
TOOL_CALL_DELTA : "**/*.png", "pa
TOOL_CALL_DELTA th": "assets"}原因和文字流式一样:模型是一个 token 一个 token 生成这些参数的,框架把它按到达顺序转成了增量。所以前端必须自己拼:
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 章的白名单里原本没有这两个事件——写前端时才发现"界面拿不到参数和返回值",补进去就是这样:
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:
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 三个细节
滚动吸底:用户往回看时不要抢位置。 流式输出每来一段文字都要滚到底,但用户手动往上翻历史时必须停手。判断方法很简单:
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 小结
这一章把事件流变成了界面。四件事:
- 归位靠 id——
reply_id找气泡,block_id/tool_call_id找格子,不靠顺序 - 手写 SSE 客户端——
EventSource不能发 POST,且会在done后无限重连 - 流式的不只是文字——工具参数与工具返回值都是增量,必须自己拼完整再解析
- 只更新变化的那一小块——重绘整段会破坏折叠状态、滚动位置和选区
下一章补上最后一块交互闭环:第 11 章的后端已经能发 REQUIRE_USER_CONFIRM、能接收 POST /api/confirm,但界面上还没有按钮。除此之外,模型有时需要反过来问用户一个问题("你要删的是这三个里的哪个?"),有时用户想中途叫停。这三件事——确认、提问、中断——合起来才是完整的对话。
