第 5 章 · Streaming 与实时交互

03 · 终端渲染器

监听事件流,把 Agent 状态实时输出到终端——用简单的 console.log 做出清晰的交互体验。

渲染器的职责

有了事件流,还需要一个东西把事件变成用户能看懂的输出——终端渲染器

渲染器的工作很简单:监听事件,输出文字。 它不修改 agent 的任何状态,只是一个纯粹的消费者。

设计原则:

  • 只用 console.log,不依赖任何终端 UI 库。 在课程后续章节中,如果需要更丰富的体验(spinner、进度条),可以替换渲染器实现。
  • 每个事件一行输出。 保持简洁,不让终端变得混乱。
  • 用图标区分类型。 在不支持颜色的终端中也能区分不同事件。

TerminalRenderer 实现

src/ui/renderer.ts 中实现渲染器:

import type { AgentEvent, AgentPhase } from "./events";

export class TerminalRenderer {
  private currentPhase: AgentPhase = "thinking";

  handleEvent(event: AgentEvent): void {
    switch (event.type) {
      case "agent_start":
        this.renderStart(event.task);
        break;
      case "model_call":
        this.currentPhase = event.phase;
        this.renderModelCall(event.phase);
        break;
      case "tool_call_start":
        this.renderToolCallStart(event.tool, event.args);
        break;
      case "tool_call_result":
        this.renderToolCallResult(event.tool, event.result, event.approved);
        break;
      case "permission_ask":
        this.renderPermissionAsk(event.tool, event.args, event.reason);
        break;
      case "todo_update":
        this.renderTodoUpdate(event.todos);
        break;
      case "agent_done":
        this.renderDone(event.stats);
        break;
    }
  }
  // ... 各个私有渲染方法
}

handleEvent 是入口方法,根据事件类型分发到对应的渲染方法。每个渲染方法只负责把自己的事件格式化为一行输出。

各事件的输出样式

agent_start: 截断过长的任务描述,显示任务开始。

>> 开始处理: 搜索所有 TypeScript 文件中的 TODO 注释

model_call: 用不同图标表示不同阶段。

  ... 思考中    (thinking)
  >  执行操作   (acting)
  ?  验证结果   (verifying)
  =  完成       (done)

tool_call_start: 工具名加上关键参数的预览。

  > search: 关键词
  > read_file: src/main.ts
  > run_command: npm test

tool_call_result: 显示工具结果的预览。被拒绝的调用用 x 标记。

  + search: 找到 3 个文件
  + read_file: import { Model } from "./model"
  x run_command: 操作被拒绝

todo_update: 用方括号图标显示每个步骤的状态。

  计划更新:
    [x] #1 搜索入口文件
    [>] #2 读取文件内容
    [ ] #3 分析代码结构

agent_done: 显示统计数据。

  = 完成 [3次模型调用, 5次工具调用]

工具参数的格式化

不同工具的参数结构不同,渲染时只需要提取最关键的信息。formatToolDetail 方法根据工具名决定显示什么:

private formatToolDetail(tool: string, args: Record<string, unknown>): string {
  switch (tool) {
    case "search":
      return String(args.query ?? "");
    case "read_file":
      return String(args.path ?? "");
    case "run_command": {
      const cmd = String(args.command ?? "");
      return cmd.length > 60 ? `${cmd.slice(0, 60)}...` : cmd;
    }
    default:
      return "";
  }
}

这样 tool_call_start 事件的输出就有了上下文——不只显示"search",还显示"搜索什么关键词"。

结果的截断处理

工具结果可能非常长(比如 read_file 返回整个文件内容)。渲染器只取结果的第一行,并且限制在 80 个字符以内:

private renderToolCallResult(tool: string, result: string, approved: boolean): void {
  if (!approved) {
    console.log(`  x ${tool}: 操作被拒绝`);
    return;
  }
  const firstLine = result.split("\n")[0] ?? "";
  const preview = firstLine.length > 80 ? `${firstLine.slice(0, 80)}...` : firstLine;
  console.log(`  + ${tool}: ${preview}`);
}

截断是渲染器的责任,不是工具的责任。工具返回完整的结果给模型(模型需要完整信息做判断),渲染器只负责给人类看的预览。

测试渲染器

测试渲染器的方式是 mock console.log,然后检查输出内容:

it("渲染 agent_start 事件", () => {
  renderer.handleEvent({ type: "agent_start", task: "测试任务" });
  expect(logs[0]).toContain("开始处理");
  expect(logs[0]).toContain("测试任务");
});

完整的测试文件在 test/ui/renderer.test.ts 中,覆盖了所有事件类型的渲染输出、长文本截断、拒绝标记等场景。

渲染器的设计取舍

为什么不直接在 agent 里 console.log? 把渲染逻辑写在 agent 里会违反单一职责——agent 既要做任务,又要管输出。将来想换渲染方式就得改 agent 代码。用事件 + 渲染器的模式,agent 和 UI 完全解耦。

为什么不用 chalk/ink 等库? 课程现阶段保持零依赖。等后续章节需要更丰富的终端体验时,只需替换渲染器实现,agent 和事件系统不需要任何修改。

为什么跳过 model_response 事件? 模型的文本输出可能很长,在简单渲染器中逐行显示会淹没工具调用信息。如果后续需要显示模型的"思考过程",可以升级渲染器来处理这个事件。

下一节把事件系统、渲染器和 agent 串起来,完成完整的实时交互体验。

登录以继续阅读

解锁完整文档、代码示例及更多高级功能。

立即登录

On this page