第 23 章

待办事项列表

通过与 Agent 状态同步的实时待办事项列表来跟踪 Agent 的进度

从"聊天气泡"到"进度仪表盘"——Agent 状态驱动的 UI 新范式

进度不可见

并非所有 Agent 交互都是聊天——多步骤任务需要进度可视化

纯聊天界面的局限

  • Agent 在执行多步计划时,用户只能看到一堆消息气泡
  • 不知道哪些步骤已完成、哪些正在进行、还剩多少没做
  • 长时间任务中,用户焦虑地等待——没有进度反馈
  • 聊天记录滚动增长,关键信息被淹没

待办列表的解法

  • 直接从 Agent 状态读取 todos 数组,实时渲染进度
  • 每个待办项状态清晰:pending → in_progress → completed
  • 进度条 + 颜色编码,一目了然
  • 基于同一个 useStream hook,无需额外轮询
核心理念:Agent 状态可以驱动任何 UI,不仅仅是消息气泡。待办列表证明了 stream.values 能让前端组件与 Agent 执行过程实时同步。

工作原理

DeepAgents 内置的 todos 状态 + useStream hook 的实时同步

在 LangGraph Agent 中,状态不仅限于消息。除了你可以自己定义的自定义状态键,DeepAgents 还内置了 todos 数组来保存 Agent 的任务计划——它由内置的 todoListMiddleware 自动注入,无需手动定义。当 Agent 执行计划时,它通过内置的 write_todos 工具把每个待办项的状态从 "pending" 更新为 "in_progress" 再到 "completed"。

1. 用户提交

发送请求给 Agent

2. 创建计划

Agent 填充 todos 数组

3. 逐步执行

pending → in_progress → completed

4. 实时同步

stream.values.todos 更新

5. UI 重渲染

组件响应式更新视图

// Agent 端:todos 是 DeepAgents 内置状态键,
// 由 createAgent({ middleware: [todoListMiddleware()] }) 自动注入,
// 开发者无需(也不应)在 Annotation 中手动定义它。
// Agent 通过内置的 write_todos 工具更新 todo 列表(每次调用替换整个数组)。
const AgentState = Annotation({
  // messages 是 LangGraph 的标准状态键,使用内置的消息归并器
  messages: Annotation({ reducer: messagesReducer }),
  // 在这里你只需定义自己的自定义状态键;todos 会由中间件自动加入
});

// 前端:useStream hook 建立与 Agent 的流式连接
const stream = useStream({
  apiUrl: "http://localhost:2024",       // LangGraph 服务地址
  assistantId: "deep_agent_todo_list",   // 在 langgraph.json 中注册的图名称
});

// 从流式状态中读取 todos 数组,响应式更新
const todos = stream.values?.todos ?? [];
图:Agent 状态通过 useStream 实时同步到前端

配置流式数据

将 useStream 指向你的 Agent,从 stream.values 中读取 todos

不需要特殊配置。导入 Agent 类型作为类型参数,即可获得对状态值的类型安全访问。

React 实现

import { useStream } from "@langchain/react";
import type { myAgent } from "./agent";

const AGENT_URL = "http://localhost:2024";

export function TodoAgent() {
  // 泛型参数 typeof myAgent 让 stream.values 拥有完整类型提示
  const stream = useStream<typeof myAgent>({
    apiUrl: AGENT_URL,                      // LangGraph 服务地址
    assistantId: "deep_agent_todo_list",    // 注册的 Agent 图 ID
  });

  // 从 Agent 状态中读取 todos 数组
  const todos = stream.values?.todos ?? [];

  return (
    <div>
      {/* 待办列表:显示 Agent 进度 */}
      <TodoList todos={todos} />
      {/* 消息列表:显示 Agent 的对话消息 */}
      {stream.messages.map((msg) => (
        <Message key={msg.id} message={msg} />
      ))}
    </div>
  );
}

Vue 实现

<script setup lang="ts">
import { useStream } from "@langchain/vue";
import { computed } from "vue";
import type { myAgent } from "./agent";

const AGENT_URL = "http://localhost:2024";

// useStream 在 Vue 中返回 ref 包装的响应式对象
const stream = useStream<typeof myAgent>({
  apiUrl: AGENT_URL,
  assistantId: "deep_agent_todo_list",
});

// 使用 computed 从响应式状态中派生 todos
const todos = computed(
  () => stream.values.value?.todos ?? []
);
</script>

<template>
  <div>
    <TodoList :todos="todos" />
    <!-- stream.messages 需要用 .value 解包 ref -->
    <Message
      v-for="msg in stream.messages.value"
      :key="msg.id"
      :message="msg"
    />
  </div>
</template>

Svelte 实现

<script lang="ts">
  import { useStream } from "@langchain/svelte";
  import type { myAgent } from "./agent";

  const AGENT_URL = "http://localhost:2024";

  // Svelte 中 useStream 解构返回 store 对象
  const { messages, values, submit } =
    useStream<typeof myAgent>({
      apiUrl: AGENT_URL,
      assistantId: "deep_agent_todo_list",
    });

  // $ 前缀自动订阅 Svelte store,实现响应式读取
  $: todos = $values?.todos ?? [];
</script>

<div>
  <TodoList {todos} />
  {#each $messages as msg (msg.id)}
    <Message message={msg} />
  {/each}
</div>

Angular 实现

import { Component, computed }
  from "@angular/core";
import { injectStream } from "@langchain/angular";
import type { myAgent } from "./agent";

const AGENT_URL = "http://localhost:2024";

@Component({
  selector: "app-todo-agent",
  template: `
    <div>
      <!-- todos() 作为 Signal 函数调用,自动追踪依赖 -->
      <app-todo-list
        [todos]="todos()" />
      @for (msg of stream.messages();
        track msg.id) {
        <app-message [message]="msg" />
      }
    </div>
  `,
})
export class TodoAgentComponent {
  // Angular 中 injectStream 返回 Signal 包装的响应式对象
  stream = injectStream<typeof myAgent>({
    apiUrl: AGENT_URL,
    assistantId: "deep_agent_todo_list",
  });

  // computed Signal 从流式状态中派生 todos
  todos = computed(
    () => this.stream.values()?.todos ?? []
  );
}
关键点:无论哪个框架,核心都是 stream.values 访问自定义状态。框架差异仅在响应式包装方式上——React 用 Hook、Vue 用 computed、Svelte 用响应式 store($ 前缀)、Angular 用 computed Signal。

数据接口

每个待办项具有简单的结构——三个状态值 + 人类可读描述

interface Todo {
  status: "pending" | "in_progress" | "completed";
  content: string;
}
属性类型说明
status "pending" | "in_progress" | "completed" 任务的当前状态。pending 未开始、in_progress Agent 正在处理、completed 已完成
content string 人类可读的任务描述,如"搜索相关文档"

pending(未开始)

灰色图标 ○,灰色背景。Agent 尚未开始处理此项。

in_progress(进行中)

琥珀色图标 ◉,琥珀色背景,脉冲动画吸引用户注意。Agent 当前正在处理此项。

completed(已完成)

绿色图标 ✓,绿色背景,删除线样式。Agent 已完成此项。

状态流转方向:Agent 在创建计划时填充此数组,然后在执行每个步骤时更新各个待办项的状态。整个生命周期是 pending → in_progress → completed 的单向流转。
图:Todo 三态状态机

构建组件

TodoList 组件渲染进度条和待办项列表

function TodoList({ todos }: { todos: Todo[] }) {
  // 统计已完成的任务数量
  const completed = todos.filter((t) => t.status === "completed").length;
  // 计算完成百分比,避免除以零
  const percentage = todos.length
    ? Math.round((completed / todos.length) * 100)
    : 0;

  return (
    <div className="rounded-lg border bg-white p-4 shadow-sm">
      {/* 头部:标题 + 已完成计数 */}
      <div className="mb-4 flex items-center justify-between">
        <h2 className="text-lg font-semibold">Agent Progress</h2>
        <span className="text-sm text-gray-500">
          {completed}/{todos.length} tasks
        </span>
      </div>

      {/* 进度条:根据百分比填充宽度 */}
      <ProgressBar percentage={percentage} />

      {/* 待办项列表:每个项根据状态显示不同样式 */}
      <ul className="mt-4 space-y-2">
        {todos.map((todo, i) => (
          <TodoItem key={i} todo={todo} />
        ))}
      </ul>
    </div>
  );
}

ProgressBar 进度条

function ProgressBar({ percentage }) {
  return (
    <div className="space-y-1">
      {/* 标签行:左侧文字 + 右侧百分比数值 */}
      <div className="flex items-center
        justify-between text-xs text-gray-500">
        <span>Progress</span>
        <span>{percentage}%</span>
      </div>
      {/* 外层轨道:灰色背景作为进度条底色 */}
      <div className="h-2 overflow-hidden
        rounded-full bg-gray-200">
        {/* 内层填充:宽度由百分比动态控制,500ms 平滑过渡动画 */}
        <div
          className="h-full rounded-full
            bg-green-500 transition-all
            duration-500"
          style={{ width: `${percentage}%` }}
        />
      </div>
    </div>
  );
}

TodoItem 待办项

function TodoItem({ todo }: { todo: Todo }) {
  // 三种状态对应不同的视觉样式:图标、文字颜色、背景色
  const config = {
    pending: {                            // 未开始:灰色
      icon: "○",
      textClass: "text-gray-600",
      bgClass: "bg-gray-50",
    },
    in_progress: {                        // 进行中:琥珀色(高亮提示)
      icon: "◉",
      textClass: "text-amber-800",
      bgClass: "bg-amber-50 border-amber-200",
    },
    completed: {                          // 已完成:绿色 + 删除线
      icon: "✓",
      textClass: "text-green-800 line-through",
      bgClass: "bg-green-50 border-green-200",
    },
  };
  const style = config[todo.status];

  return (
    <li className={`flex items-start gap-3
      rounded-md border px-3 py-2 ${style.bgClass}`}>
      <span className={style.iconClass}>
        {style.icon}
      </span>
      <span className={`text-sm ${style.textClass}`}>
        {todo.content}
      </span>
    </li>
  );
}

计算进度

直接从待办数组推导进度指标——响应式自动同步

// 从流式状态中读取 todos,默认为空数组
const todos = stream.values?.todos ?? [];

// 统计各状态的任务数
const completed = todos.filter((t) => t.status === "completed").length;   // 已完成
const inProgress = todos.filter((t) => t.status === "in_progress").length; // 进行中
const pending = todos.filter((t) => t.status === "pending").length;        // 待处理
// 计算完成百分比
const percentage = todos.length
  ? Math.round((completed / todos.length) * 100)
  : 0;

为什么不需要手动轮询

stream.values 是响应式的。Agent 每次修改状态,前端自动收到更新并重新渲染。你不需要 setInterval、useEffect 轮询或手动刷新。

原理:useStream hook 内部通过流式连接(Server-Sent Events 或 WebSocket)监听 Agent 状态变化,任何 todos 数组的更新都会即时推送到前端。

进度指标一览

指标计算方式用途
完成率completed / total * 100进度条百分比
已完成数.filter(status === "completed")计数器 "3/5 tasks"
进行中.filter(status === "in_progress")高亮当前任务
剩余数.filter(status === "pending")预估剩余工作量
关键洞察:这些值会随着 Agent 修改状态而响应式更新,使进度条和计数器始终与 Agent 实际进度保持同步。

聊天结合

待办列表与聊天消息配合使用——顶部面板 + 下方消息区

function TodoAgentLayout() {
  // 建立与 Agent 的流式连接
  const stream = useStream<typeof myAgent>({
    apiUrl: AGENT_URL,
    assistantId: "deep_agent_todo_list",
  });

  const todos = stream.values?.todos ?? [];

  return (
    <div className="flex h-screen flex-col">
      {/* 条件渲染:只在有待办项时显示进度面板 */}
      {todos.length > 0 && (
        <div className="border-b bg-gray-50 p-4">
          <TodoList todos={todos} />
        </div>
      )}

      {/* 聊天消息区:flex-1 占据剩余空间,支持滚动 */}
      <main className="flex-1 overflow-y-auto p-6">
        <div className="mx-auto max-w-2xl space-y-4">
          {stream.messages.map((msg) => (
            <Message key={msg.id} message={msg} />
          ))}
        </div>
      </main>

      {/* 输入框:提交时构造 HumanMessage 发送给 Agent */}
      <ChatInput
        onSubmit={(text) =>
          stream.submit({
            messages: [{ type: "human", content: text }]
          })
        }
        isLoading={stream.isLoading}
      />
    </div>
  );
}
注意:仅当 todos.length > 0 时才显示待办列表。在 Agent 创建计划之前,没有任何内容可显示。显示空组件只会浪费屏幕空间。
图:待办列表与聊天组合布局

自定义状态

stream.values 可暴露内置的 todos 以及你自定义的任何状态键

此模式演示了一个强大的原则:stream.values 不限于消息,它暴露 Agent 状态中的所有键——既包括 DeepAgents 内置的 todos,也包括你在状态模式中自定义的状态键。

进度指标

stream.values.progress 包含数字完成度数据

适用于:任务完成百分比、阶段标记

生成的内容

stream.values.document 包含 Agent 正在构建的结构化文档

适用于:实时预览报告、代码生成

决策日志

stream.values.decisions 记录 Agent 做出的每一个选择

适用于:审计追踪、调试分析

资源列表

stream.values.sources 包含 Agent 找到的链接和引用

适用于:研究助手、文献综述

// 除内置的 todos 外,stream.values 还可访问你在状态模式中定义的自定义状态键
const document = stream.values?.document;                 // Agent 正在构建的文档
const sources = stream.values?.sources ?? [];             // Agent 收集的引用来源
const confidence = stream.values?.confidence_score;       // Agent 的置信度评分
零配置:内置的 todos 由 todoListMiddleware 自动注入;而 progress、document、decisions、sources 等自定义状态键则在你 LangGraph 图的状态模式中由你自己定义。useStream hook 会自动将它们一并包含在 stream.values 中,无需任何额外的客户端配置。

动画过渡

平滑的 CSS 过渡让状态切换显得精致而非突兀

function TodoItem({ todo }: { todo: Todo }) {
  return (
    <li className={`
      flex items-start gap-3 rounded-md border px-3 py-2
      transition-all duration-300 ease-in-out        /* ← 全属性过渡 */
      ${getStatusStyles(todo.status)}
    `}>
      <span className={`
        mt-0.5 text-lg leading-none
        transition-colors duration-300                 /* ← 颜色过渡 */
        ${getIconStyles(todo.status)}
      `}>
        {getStatusIcon(todo.status)}
      </span>
      <span className={`
        text-sm transition-all duration-300            /* ← 文本过渡 */
        ${todo.status === "completed"
          ? "line-through opacity-60" : ""}
      `}>
        {todo.content}
      </span>
    </li>
  );
}

过渡效果清单

属性效果用途
background-color颜色平滑切换状态变化时背景渐变
color文字颜色过渡图标和文本变色
opacity透明度渐变已完成项淡化
text-decoration删除线动画完成时划线效果

关键 CSS 类

  • transition-all duration-300 —— 所有属性 300ms 过渡
  • ease-in-out —— 先慢后快再慢的缓动曲线
  • animate-pulse —— in_progress 图标脉冲闪烁
  • transition-all duration-500 —— 进度条 500ms 平滑增长
提示:in_progress 状态使用 animate-pulse 来吸引用户注意当前正在进行的任务,这是整个列表中唯一的动态元素。

使用场景

任何 Agent 执行结构化计划的场景都适用

项目规划

Agent 将项目分解为任务并依次执行。每个任务成为一个待办项,用户实时看到哪些已完成、哪些正在进行。

研究工作流

每个研究问题成为 Agent 调查并完成的待办项。从"搜索文献"到"撰写摘要",步骤清晰可见。

数据处理

数据摄取、验证、转换和导出等步骤各有对应的待办项。管道式处理一目了然。

引导流程

Agent 逐步完成设置步骤,配置服务时逐一勾选。用户知道还差几步才能完成。

报告生成

报告的各部分成为待办项:收集数据 → 分析趋势 → 撰写摘要 → 格式化输出。

代码重构

Agent 将重构拆分为待办项:分析依赖 → 修改接口 → 更新测试 → 验证构建。

通用模式:只要你的 Agent 工作流可以拆分为有序步骤,待办列表就是最佳进度可视化方案。它与具体业务逻辑无关,纯粹是状态驱动的 UI。

空状态处理

在 Agent 创建计划之前优雅地处理初始状态

function TodoList({ todos, isLoading }) {
  // 情况 1:没有待办项,也没有加载 —— 不显示任何东西
  if (todos.length === 0 && !isLoading) {
    return null;
  }

  // 情况 2:没有待办项,正在加载 —— 显示加载提示
  if (todos.length === 0 && isLoading) {
    return (
      <div className="rounded-lg border bg-white p-4 shadow-sm">
        <div className="flex items-center gap-2
          text-sm text-gray-500">
          <span className="animate-spin">⟳</span>
          Agent is creating a plan...
        </div>
      </div>
    );
  }

  // 情况 3:有待办项 —— 显示完整列表
  return (
    <div className="rounded-lg border bg-white p-4 shadow-sm">
      {/* ... 完整的待办列表渲染 ... */}
    </div>
  );
}

三种状态

条件行为原因
空 + 不加载return nullAgent 尚未开始,无内容可显示
空 + 加载中加载动画 + 提示文字Agent 正在规划,给用户反馈
有数据完整 TodoList 渲染正常工作状态

设计原则

  • 不显示空壳——空列表只会让用户困惑
  • 加载时给反馈——旋转图标 + 描述性文字减少焦虑
  • 条件渲染——todos.length > 0 才渲染整个面板
  • 过渡自然——从加载到有内容的切换应平滑

最佳实践

让待办列表成为基于计划的 Agent 的核心进度指示器

1. 放在醒目位置

待办列表是基于计划的 Agent 的主要进度指示器。不要把它藏在页面下方。推荐放在顶部面板或固定侧边栏。

2. 添加状态动画

平滑的过渡让 Agent 显得更加灵敏。在背景色、文本装饰和透明度上使用 CSS transition。in_progress 项用 animate-pulse 闪烁。

3. 只高亮一个进行中

Agent 通常一次只处理一个任务。如果多个项同时显示为 in_progress,UI 会变得嘈杂。建议只让第一个正在进行的项闪烁。

4. 折叠已完成项

随着列表增长,已完成的项变得不那么重要。降低它们的视觉权重(透明度、删除线),让用户专注于还在进行中的任务。

5. 显示进度百分比

像"已完成 67%"这样简单的数字,即使远距离也能立即理解。进度条 + 百分比数字双重反馈。

6. 保持自动同步

stream.values 会响应式更新,待办列表自动保持最新。不要添加手动轮询或刷新逻辑——这会造成不必要的复杂性和性能浪费。

本章回顾

Agent 状态驱动的 UI——不只是消息气泡

你学到了什么

  • 通过 DeepAgents 内置的 todos 状态键跟踪 Agent 执行进度
  • useStream hook 通过 stream.values 暴露自定义状态
  • Todo 接口:status(三态)+ content(描述)
  • 构建 TodoList、ProgressBar、TodoItem 三层组件
  • 待办列表与聊天消息的组合布局
  • CSS 动画过渡让状态切换更加精致
  • 空状态和加载状态的优雅处理
  • stream.values 可用于任何自定义状态,不止 todos

后续章节预告

  • 沙盒 UI:在隔离环境中可视化 Agent 的代码执行过程
  • ACP 协议:Agent 通信协议的深度解析
  • 代码模式:在代码编辑器中集成 Agent 能力
核心结论:Agent 状态可以驱动任何 UI。待办列表只是开始——stream.values 能暴露进度指标、生成文档、决策日志等任何自定义数据。当你需要让用户理解 Agent 在做什么时,想想哪些状态值得可视化,然后用同样的模式构建你的 UI。