9.2 前端渲染:TS 最小聊天 UI 逐步显示
9.1 把事件解成了可信的判别联合,但联合类型不会自己变成界面。前端要做的事只有一句:把一串事件变成用户能看的东西。这句话听起来简单,坑却在「一串」上——事件是逐条到来的,界面必须每来一条就更新一次,而且更新过程要能中断、能回放、能断言。把「更新」写成一堆散落在事件回调里的 element.append(...),这三件事一件都做不到。
本节把 ResearchCopilot 推进到「事件可见」:用纯函数归约器
reduce(state, event)把事件流折成一个 UI 状态,再用render(state)把状态渲染成文本;整条链路不依赖 DOM,在 Node 里真跑并断言,最后单独给一段接浏览器的代码。
9.2.1 为什么把渲染拆成 reduce + render
最直觉的写法是「一个事件一个处理函数」,收到 token 就往 DOM 追加一个字、收到 tool_call 就插一个节点。这种「就地改界面」的写法有三个绕不开的毛病:状态散在 DOM 里(想读「现在回答到哪了」得去 DOM 里翻)、渲染逻辑与 DOM API 焊死(Node 里跑不了、测不了)、每次更新都得想「这次该动哪个节点」(容易漏、容易错)。
把链路拆成两段就干净了:
事件流 --reduce--> UiState --render--> 文本/DOM
reduce 是纯函数:输入旧状态与一条事件,输出新状态,不碰任何外部世界。render 也是纯函数:输入状态,输出可显示内容。于是「状态怎么变」与「怎么显示」彻底解耦——状态逻辑可以在 Node 里真跑、可以断言、可以回放整段历史;渲染部分只负责把现成状态画出来,想换 React、换 canvas、换终端都不影响归约器。这是所有流式 UI 的通用骨架,React 的 reducer、Elm 的 update、Redux 的 store 都是这个形状。
9.2.2 状态形状与归约器
状态要装下「界面此刻长什么样」。一条回答由若干块组成:文字气泡、工具折叠块,按发生顺序排列。于是:
interface Bubble { kind: "assistant"; text: string; done: boolean; }
interface ToolBlock { kind: "tool"; id: string; name: string; args: string; result: string | null; }
type Block = Bubble | ToolBlock;
interface UiState {
runId: string | null;
status: "idle" | "running" | "done";
steps: number;
blocks: Block[];
}
const INITIAL: UiState = { runId: null, status: "idle", steps: 0, blocks: [] };
归约器本身是一个大 switch,每个分支处理一类事件。关键约定是不改原状态、返回新状态({ ...state, ... })——这让「上一帧状态」永远可保留,回放与撤销才有可能:
function reduce(state: UiState, ev: AgentEvent): UiState {
switch (ev.type) {
case "run.started":
return { ...state, runId: ev.run_id, status: "running" };
case "step":
return { ...state, steps: state.steps + 1 };
case "token": {
const last = state.blocks[state.blocks.length - 1];
if (last && last.kind === "assistant" && !last.done) {
const merged: Bubble = { ...last, text: last.text + ev.text };
return { ...state, blocks: [...state.blocks.slice(0, -1), merged] };
}
const fresh: Bubble = { kind: "assistant", text: ev.text, done: false };
return { ...state, blocks: [...state.blocks, fresh] };
}
case "tool_call": {
const tb: ToolBlock = { kind: "tool", id: ev.id, name: ev.name,
args: JSON.stringify(ev.arguments), result: null };
return { ...state, blocks: [...state.blocks, tb] };
}
case "tool_result":
return { ...state, blocks: state.blocks.map((b) =>
b.kind === "tool" && b.id === ev.id ? { ...b, result: ev.result } : b) };
case "final": {
const last = state.blocks[state.blocks.length - 1];
const closed: Bubble = { kind: "assistant", text: ev.content, done: true };
const blocks = last && last.kind === "assistant"
? [...state.blocks.slice(0, -1), closed]
: [...state.blocks, closed];
return { ...state, status: "done", blocks };
}
case "run.succeeded":
return { ...state, status: "done", steps: ev.steps };
}
}
四处细节值得点出。其一,token 要判断「追加还是新建」:如果最后一块是尚未完成的文字气泡,就把 token 拼上去(打字机效果);否则新开一个气泡——这样「工具调用 → 模型继续说」会自然分成「工具块 + 新气泡」两段。其二,tool_result 按 id 回填,不是「改最后一个工具块」:并发工具的场景下多个 tool_call 可能交错返回,按 id 匹配才正确。其三,final 用 ev.content 覆盖气泡文本并标记 done——它是权威全文,比逐 token 拼出来的更可靠(真实场景里 token 可能丢)。其四,reduce 无 default:将来多一种事件而忘了处理,TS 会因返回类型不完整而报警。
9.2.3 render:state 到可显示文本
渲染器把状态摊成一段文本。这里刻意不碰 DOM,只产出字符串,方便在 Node 里断言:
function render(state: UiState): string {
const head = `[run ${state.runId ?? "-"} · ${state.status} · steps=${state.steps}]`;
const lines = state.blocks.map((b) => {
if (b.kind === "assistant") {
return `AI> ${b.text}${b.done ? "" : "▌"}`;
}
const tail = b.result === null ? "…" : b.result;
return ` ⚙ ${b.name}(${b.args}) → ${tail}`;
});
return [head, ...lines].join("\n");
}
两个设计点。未完成的文字气泡带一个 ▌ 光标——这正是「打字机」在纯文本里的等价物,DOM 里换成 CSS 闪烁光标即可。结果未到的工具块显示 …——tool_call 先到、tool_result 后到,中间那段时间界面要能表示「正在调用」,而不是留一个空块。
9.2.4 真跑:逐步归约与最终渲染
把 3.2 的 11 帧喂进归约器,每处理一条打印一次快照,最后打印完整渲染:
const FRAMES: [number, string, string][] = [
[1, "run.started", '{"run_id": "r_d98c8f3c"}'],
[2, "step", '{"index": 0, "kind": "tool_call"}'],
[3, "tool_call", '{"id": "c1", "name": "search_corpus", "arguments": {"q": "pgvector 召回率"}}'],
[4, "tool_result", '{"id": "c1", "name": "search_corpus", "result": "命中 3 条"}'],
[5, "token", '{"text": "pgvector"}'],
[6, "token", '{"text": " 在"}'],
[7, "token", '{"text": "低维"}'],
[8, "token", '{"text": "场景更省"}'],
[9, "step", '{"index": 1, "kind": "final"}'],
[10, "final", '{"content": "pgvector 在低维场景更省。"}'],
[11, "run.succeeded", '{"run_id": "r_d98c8f3c", "steps": 2}'],
];
function decode(event: string, data: string): AgentEvent {
return { type: event, ...(JSON.parse(data) as Record<string, unknown>) } as AgentEvent;
}
let state = INITIAL;
for (const [id, event, data] of FRAMES) {
state = reduce(state, decode(event, data));
const last = state.blocks[state.blocks.length - 1];
const tail = last ? (last.kind === "assistant" ? `bubble="${last.text}"` : `tool=${last.name}`) : "-";
console.log(`#${String(id).padStart(2)} ${event.padEnd(13)} -> blocks=${state.blocks.length} status=${state.status} ${tail}`);
}
console.log("--- 最终渲染 ---");
console.log(render(state));
本机实测(Node 26.5.0,node render.ts 直跑):
# 1 run.started -> blocks=0 status=running -
# 2 step -> blocks=0 status=running -
# 3 tool_call -> blocks=1 status=running tool=search_corpus
# 4 tool_result -> blocks=1 status=running tool=search_corpus
# 5 token -> blocks=2 status=running bubble="pgvector"
# 6 token -> blocks=2 status=running bubble="pgvector 在"
# 7 token -> blocks=2 status=running bubble="pgvector 在低维"
# 8 token -> blocks=2 status=running bubble="pgvector 在低维场景更省"
# 9 step -> blocks=2 status=running bubble="pgvector 在低维场景更省"
#10 final -> blocks=2 status=done bubble="pgvector 在低维场景更省。"
#11 run.succeeded -> blocks=2 status=done bubble="pgvector 在低维场景更省。"
--- 最终渲染 ---
[run r_d98c8f3c · done · steps=2]
⚙ search_corpus({"q":"pgvector 召回率"}) → 命中 3 条
AI> pgvector 在低维场景更省。
读这段快照序列:tool_call 到来自动开了第 1 个块(工具块),tool_result 把它回填;第 5 条 token 才开出第 2 个块(文字气泡),之后每个 token 都拼进同一个气泡——这就是打字机效果的机制:块数停在 2 不动,气泡文本逐字变长。final 把气泡收尾、状态转 done。整个链路没碰一次 DOM,却完整重演了界面演化。
9.2.5 增量渲染 vs 全量重渲染
render(state) 是「全量重渲染」:拿整个状态重新算一遍输出。另一种做法是「增量渲染」:只处理当前事件、只改动受影响的节点。两者取舍如下:
| 维度 | 全量重渲染 | 增量渲染 |
|---|---|---|
| 实现复杂度 | 低,render 是状态的纯函数 | 高,每个事件要算「动哪个节点」 |
| 状态一致性 | 天然一致(输出只依赖状态) | 易漂移(DOM 成了第二份状态) |
| 长回答性能 | 每次都重算整段,O(总长) | 只算增量,O(本事件) |
| 可测试性 | 好,纯函数可断言 | 差,逻辑缠在 DOM 操作里 |
| 适合 | 中等长度回答、逻辑优先 | 超长回答、高频 token 流 |
本卷选全量重渲染:ResearchCopilot 的单条报告通常几百到几千字,重算一次的开销远小于一次网络往返,而它换来的「状态是唯一事实源」价值极高。但真实场景有个必须防的坑——token 可能每秒来几十条,若每条都触发一次全量重算 + 整段重绘,长回答会肉眼可见地卡。标准解法是节流(throttle):事件照常 reduce 进状态(状态更新不能丢),但渲染用 requestAnimationFrame 或固定间隔(如 50ms)批量触发一次。这样「状态永远最新」与「渲染不超预算」两件事同时成立。归约器保持纯粹、渲染层做节流,是这个架构最舒服的分工。
9.2.6 接上 DOM 的最小代码
上面全是纯函数。真正把它画到浏览器里,只需一层薄薄的适配:把 render(state) 产出的块结构映射成 DOM 节点。以下代码依赖浏览器 API,需浏览器环境,本机未执行:
// 需浏览器环境,本机未执行
function paint(root: HTMLElement, state: UiState): void {
root.replaceChildren(); // 全量重绘:清空再画
for (const b of state.blocks) {
const el = document.createElement("div");
el.className = b.kind === "assistant" ? "bubble" : "tool";
el.textContent = b.kind === "assistant"
? b.text + (b.done ? "" : "▌")
: `⚙ ${b.name}(${b.args}) → ${b.result ?? "…"}`;
root.appendChild(el);
}
}
// EventSource 自动解析 SSE,并内建断线重连(带 Last-Event-ID)
const es = new EventSource("/runs/r_d98c8f3c/stream");
let ui: UiState = INITIAL;
let pending = false;
es.onmessage = (m) => {
ui = reduce(ui, decode(m.type, m.data)); // 状态立即更新,绝不丢事件
if (!pending) { // 渲染节流到一帧一次
pending = true;
requestAnimationFrame(() => { paint(document.body, ui); pending = false; });
}
};
三件事一目了然。EventSource 就是浏览器的 SSE 客户端——它自动解析 event: / data: 行、自动重连,第 9.3 节要讲的 Last-Event-ID 它也会自动带上。paint 是全量重绘,replaceChildren 一把清空重画,逻辑简单到不会出错。节流用 requestAnimationFrame,最多一帧渲染一次,避免高频 token 把主线程刷爆。真正上生产时会把 paint 换成框架的 diff(React 的列表、Vue 的 v-for),但归约器与 decode 一行都不用改——框架换的是渲染层,状态层是稳定的。
9.2.7 真实环境 / 真实模型下会怎样
真实模型下,reduce / render 的形状不变,但有三处要加固:
- token 节奏不匀。真实流可能几百毫秒才来一片,也可能一次涌来一片长文本。节流之外还要有「正在输入」指示,并在长时间无 token 时提示等待,而不是让用户以为卡死。
- 断流要可恢复。流意外中断时,状态停在半途(气泡带
▌)。第 9.3 节的重连会把剩余事件补齐,reduce照常把它们折进同一个状态——因为状态是纯函数折出来的,续传与首连走的是完全相同的代码路径,这是本架构最实用的性质。 - 长会话要分段渲染。几十轮对话后
blocks会很长的,全量重绘的常数因子开始显形。此时把已完成的历史块冻结成静态节点、只对「活跃块」做重绘,是自然的下一个优化。
本节验收清单:
-
reduce是纯函数:不改入参、无副作用、可重复调用 -
token能正确「追加到当前气泡 / 新开气泡」 -
tool_result按id回填,不是改最后一个块 -
render不依赖 DOM,Node 里可断言 - 接 DOM 的那段已明确标注「需浏览器环境,本机未执行」
9.2.8 小结
- 流式 UI 的通用骨架:
事件 --reduce--> 状态 --render--> 显示,两段都是纯函数。 reduce无default,漏处理新事件会被编译器逮住;状态用不可变更新,便于回放。token追加到未完成气泡、final用权威全文收尾、tool_result按id回填。- 全量重渲染换来「状态唯一事实源」,代价靠渲染层节流(
requestAnimationFrame)压住。 - 浏览器里
EventSource直接消费 SSE 并内建重连;框架只替换渲染层,归约器不动。
界面能随流生长了,但还只经得起「一次连接」。真实网络会断,断了之后用户不能从头再看一遍。下一节 9.3 断线重连:Last-Event-ID 续传不丢步 就补上这层。想回看事件类型从哪来,翻 9.1 事件协议:token/tool/step 统一 schema,TS 与 Python 共享类型 。
阅读导航:上一节:9.1 事件协议:token/tool/step 统一 schema,TS 与 Python 共享类型 · 下一节:9.3 断线重连:Last-Event-ID 续传不丢步 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。