《AI Agent 工程实战》9.2 前端渲染:TS 最小聊天 UI 逐步显示

把 9.1 的事件流变成可见界面:实现纯函数归约器 reduce(state, event) 与渲染器 render(state),token 追加气泡、tool 开折叠块、final 收尾;在 Node 里真跑一串事件、逐步打印状态快照与最终渲染。再讲增量与全量重渲染的取舍,最后给一段接 DOM 的最小代码。

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 续传不丢步 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距