我开源了 Agent UI Lab 用交互实验理解 AG-UI A2UI 与 MCP Apps

当一个 Agent 开始查询工具、更新任务状态、收集用户输入,甚至在对话中打开一个可以操作的应用时,前端就需要回答一组具体的问题:

文字还在生成,工具已经开始执行,应该如何组织它们的状态?动态表单来自一份什么样的数据?用户点击确认时,确认的究竟是哪一版内容?网络中断之后,怎样恢复界面,才能避免意外重复业务操作?

这些问题横跨协议、状态管理、组件渲染和业务一致性。对于熟悉 React 的开发者来说,每一部分都有熟悉的概念,但把它们放进 Agent 的交互过程里,仍然需要一套能操作、能观察、能修改的例子。

因此,我开源了 Agent UI Lab :一个面向前端开发者的交互式学习项目与参考实现。它把 AG-UI、A2UI 和 MCP Apps 放进同一个应用,通过旅行对话、协议检查器、源码导读和独立实验,帮助你追踪从消息到界面的整个过程。

无需模型密钥,不调用大语言模型,在线版可以直接体验。 项目使用确定性规则与样本数据,让学习者能够固定条件,反复验证同一个机制。

一 从一次界面变化开始学习

我希望这个项目能够帮助读者建立一种具体的理解:看到界面发生变化时,能够继续找到引起变化的消息、状态和代码。

例如,一段文字刚刚追加到聊天气泡中,可以沿着它找到 TEXT_MESSAGE_CONTENT,查看其中的 messageId 和 delta,再对照 reducer 更新前后的状态。

一个表单字段出现在页面上,可以沿着实际控件找到组件 ID、组件声明、数据绑定路径,以及当前模型值。

一个工具应用更新了活动列表,可以继续检查:这次调用了哪个工具?有没有重新读取 HTML?原来的 iframe 是否还在?

这也是项目学习面板围绕三个动作设计的原因:

  1. 追溯原因:从界面定位消息,再找到更新逻辑。

  2. 改变输入:修改事件、组件声明、绑定路径或工具参数。

  3. 预测并验证:先判断结果,再观察实际执行是否符合预期。

这些操作复用实际的 reducer、Renderer 和 App。以 AG-UI 为例,编辑事件后的实验会从记录的旧状态开始执行同一个 reducer;以 MCP Apps 为例,更新数据和切换资源会实际经过不同的调用路径。学习反馈有可以继续查看的执行证据。学习方法与实验说明

用三句话进入项目

打开在线版,可以先按顺序发送:

帮我规划杭州三日游,喜欢慢节奏和本地小吃
把这些出行需求做成一个表单
打开这个城市的活动应用

第一轮观察流式文本、工具调用和共享状态,第二轮填写声明生成的表单,第三轮操作工具关联的应用。城市与天数可以在轮次之间共享,之前消息里的表单和 App 也可以继续操作。

这三次交互为后面的技术拆解提供了共同的业务背景。当前聊天每轮采用一种协议;协议选择由确定性规则或手动选择完成,没有接入模型自主决策。聊天入口与示例

二 AG-UI A2UI 与 MCP Apps 分别解决什么问题

这三个名字经常一起出现,但在理解和选型时,需要分别看它们描述的对象。

协议 核心关注点 开发者主要处理什么
AG-UI Agent 执行与用户界面之间的事件交互 运行生命周期、文本流、工具调用、共享状态
A2UI 声明式界面的结构、数据与动作 组件目录、组件引用、数据绑定、用户操作回传
MCP Apps MCP 工具关联的交互应用 UI 资源发现、应用加载、App 与 Host 通信

协议定义可以对照 AG-UI 官方文档、A2UI 官方文档 和 MCP Apps 官方文档 阅读。

如果你的问题是"工具运行到哪一步,前端怎样持续更新",可以从 AG-UI 的事件与状态入手。

如果你的问题是"根据当前任务展示哪些字段,如何让表单结构随需求变化",可以从 A2UI 的组件声明与数据模型入手。

如果工具已经具备自己的交互界面,希望在兼容的宿主中运行,就需要理解 MCP Apps 的资源与宿主机制。

这些能力可以出现在同一个产品里,但组合需要明确的业务契约。例如,表单提交影响哪一个任务?用户确认哪份提议?工具结果对应哪一次执行?协议本身不会替应用自动回答这些问题。

Agent UI Lab 的基础聊天先把三个机制分别展示,进阶的活动报名实验再连接表单、提议、确认和事件记录。当前实现没有把 A2UI 或 MCP Apps 消息封装进 AG-UI。项目协议分工与范围

三 项目如何同时支持在线体验和本地开发

Agent UI Lab 的前端采用 React 19、TypeScript 和 Vite,后端采用 Express,部分进阶实验使用 Node 内置 SQLite。核心代码可以按三层阅读:

目录 职责 典型内容
shared/ 可复用的业务与协议逻辑 规则路由、事件生成、协议校验、reducer、实验引擎
src/ 界面与交互 聊天工作台、React Renderer、检查器、学习面板、MCP Host
server/ 服务端边界与存储 HTTP、SSE、MCP 传输适配、SQLite

这种组织方式让本地模式与静态模式能够复用核心实现。本文按项目固定的 AG-UI 1.0、A2UI v0.9.1 与 MCP Apps SDK 基线展开;其中 @modelcontextprotocol/ext-apps 固定为 2.0.3,具体依赖以仓库锁文件为准。技术栈与源码入口

相同处理逻辑通过不同适配器运行

AG-UI 和 A2UI 的基础请求通过 labFetch() 选择运行方式。下面是源码中的函数节选:

ts 复制代码
export async function labFetch(
  url: string,
  options: RequestInit = {},
): Promise<Response> {
  if (STATIC_MODE) {
    const { staticFetch } = await import('./static-api');
    options.signal?.throwIfAborted();
    return staticFetch(url, options);
  }
  return fetch(url, options);
}

本地模式会真正发送 HTTP 请求。静态模式进入浏览器内的处理器,复用事件生成、声明生成和动作校验函数,并返回 Response 对象。

MCP 使用另一条适配路径:两种模式复用 createTripMcpServer(),本地连接 Streamable HTTP,在线版用官方 InMemoryTransport 连接运行在浏览器里的 Client 和 Server。请求适配 · 静态 API · 静态 MCP

能力 在线静态学习版 本地模式
AG-UI 浏览器内生成事件,以 ReadableStream 演示 SSE 编码 通过真实 HTTP / SSE 接收事件
A2UI 浏览器内生成声明与校验动作 通过 HTTP 获取声明与提交动作
MCP Client 与 Server 官方 InMemoryTransport 官方 Streamable HTTP
App 与 Host iframe 与 postMessage iframe 与 postMessage
任务恢复、人工确认、联合报名 当前页面内存 SQLite 持久化相关任务与事实

在线版检查器中的 browser-memory 表示浏览器内处理;其中类似 POST /api/... 的记录是教学逻辑路由标签。需要观察真实网络传输、服务端进程和数据库行为时,应切换到本地模式。A2UI 兼容实验的本地文档保存在服务进程内存中,与表中的 SQLite 实验不同。运行模式说明 · 兼容实验存储边界

为什么采用确定性 Agent

学习协议时,我希望尽量控制变量。如果正在验证一个绑定路径,界面结果又受到模型输出波动影响,就很难判断问题发生在哪一层。

因此,当前项目用确定性 TypeScript 逻辑解析需求、选择交互方式、生成消息,并从样本中取得工具结果。输入会参与处理,但实验条件可以重复建立。

这让学习顺序更清楚:先验证消息、状态和界面的关系,再接入真实 Agent。项目的服务端决策与生成边界可以作为改造入口;真实模型适配器仍需要单独实现和验证。真实开发接入路径

四 AG-UI 如何把一次执行变成可观察的界面

在旅行规划示例中,前端同时展示文字、工具信息与任务状态。它们都来自事件处理,但更新的是不同的状态区域。

先建立事件与状态的对应关系

本地客户端向 POST /api/agui 提交运行输入,服务端以 SSE 返回 AG-UI 事件。一次成功运行包含下面几类变化;表格按职责归类,不代表所有事件必须连续出现。

事件 关键字段 前端需要处理的变化
RUN_STARTED threadId、runId 建立本轮执行身份与运行状态
TEXT_MESSAGE_START / CONTENT / END messageId、delta 创建文本消息、追加内容、结束该消息
TOOL_CALL_START / ARGS / END toolCallId、工具名、参数增量 收集工具调用描述与参数
TOOL_CALL_RESULT toolCallId、content 将实际结果关联到对应工具调用
STATE_SNAPSHOT snapshot 替换共享业务状态
STATE_DELTA delta 按补丁更新共享状态的指定位置
RUN_FINISHED / RUN_ERROR 完成结果或错误信息 表达执行终态

这里有两个需要分清的"结束"。TEXT_MESSAGE_END 只结束一条文字消息,整个 run 仍然可以继续。TOOL_CALL_END 表示工具调用参数已经收齐,工具结果由后续 TOOL_CALL_RESULT 承载。事件说明与运行时序

以工具参数为例,它们可能分成多段到达。某个 TOOL_CALL_ARGS.delta 只是 JSON 文本的一部分,客户端需要先累积参数,在参数描述结束时检查完整性。页面也应区分"参数仍在接收""已经形成调用"和"已经取得结果"。

从网络字节到状态更新存在多个边界

实现 SSE 时,一个容易遗漏的问题是:浏览器每次读取到的数据块,与一个完整事件的边界并不一致。一条事件可能跨多个网络块,一个块也可能包含多条事件。

项目中的 createSseDecoder() 会先缓存未完成内容,按 SSE 帧边界拆分,再提取 data: 并解析 JSON。consumeSse() 使用流式 TextDecoder 处理文本编码;如果流结束时仍残留不完整帧,会报告截断。

得到 JSON 后,还要通过官方事件 schema 校验,然后进入教学 reducer,检查这条事件在当前状态下是否合法。例如,文本增量不能随意追加到一个尚未建立或已经结束的消息上。

可以把这个过程理解为以下关系图。图中省略了具体组件与错误分支:
#mermaid-svg-yBOaxeQBr8ctgpUt{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-yBOaxeQBr8ctgpUt .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-yBOaxeQBr8ctgpUt .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-yBOaxeQBr8ctgpUt .error-icon{fill:#552222;}#mermaid-svg-yBOaxeQBr8ctgpUt .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-yBOaxeQBr8ctgpUt .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-yBOaxeQBr8ctgpUt .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-yBOaxeQBr8ctgpUt .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-yBOaxeQBr8ctgpUt .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-yBOaxeQBr8ctgpUt .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-yBOaxeQBr8ctgpUt .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-yBOaxeQBr8ctgpUt .marker{fill:#333333;stroke:#333333;}#mermaid-svg-yBOaxeQBr8ctgpUt .marker.cross{stroke:#333333;}#mermaid-svg-yBOaxeQBr8ctgpUt svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-yBOaxeQBr8ctgpUt p{margin:0;}#mermaid-svg-yBOaxeQBr8ctgpUt .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-yBOaxeQBr8ctgpUt .cluster-label text{fill:#333;}#mermaid-svg-yBOaxeQBr8ctgpUt .cluster-label span{color:#333;}#mermaid-svg-yBOaxeQBr8ctgpUt .cluster-label span p{background-color:transparent;}#mermaid-svg-yBOaxeQBr8ctgpUt .label text,#mermaid-svg-yBOaxeQBr8ctgpUt span{fill:#333;color:#333;}#mermaid-svg-yBOaxeQBr8ctgpUt .node rect,#mermaid-svg-yBOaxeQBr8ctgpUt .node circle,#mermaid-svg-yBOaxeQBr8ctgpUt .node ellipse,#mermaid-svg-yBOaxeQBr8ctgpUt .node polygon,#mermaid-svg-yBOaxeQBr8ctgpUt .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-yBOaxeQBr8ctgpUt .rough-node .label text,#mermaid-svg-yBOaxeQBr8ctgpUt .node .label text,#mermaid-svg-yBOaxeQBr8ctgpUt .image-shape .label,#mermaid-svg-yBOaxeQBr8ctgpUt .icon-shape .label{text-anchor:middle;}#mermaid-svg-yBOaxeQBr8ctgpUt .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-yBOaxeQBr8ctgpUt .rough-node .label,#mermaid-svg-yBOaxeQBr8ctgpUt .node .label,#mermaid-svg-yBOaxeQBr8ctgpUt .image-shape .label,#mermaid-svg-yBOaxeQBr8ctgpUt .icon-shape .label{text-align:center;}#mermaid-svg-yBOaxeQBr8ctgpUt .node.clickable{cursor:pointer;}#mermaid-svg-yBOaxeQBr8ctgpUt .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-yBOaxeQBr8ctgpUt .arrowheadPath{fill:#333333;}#mermaid-svg-yBOaxeQBr8ctgpUt .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-yBOaxeQBr8ctgpUt .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-yBOaxeQBr8ctgpUt .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yBOaxeQBr8ctgpUt .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-yBOaxeQBr8ctgpUt .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yBOaxeQBr8ctgpUt .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-yBOaxeQBr8ctgpUt .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-yBOaxeQBr8ctgpUt .cluster text{fill:#333;}#mermaid-svg-yBOaxeQBr8ctgpUt .cluster span{color:#333;}#mermaid-svg-yBOaxeQBr8ctgpUt div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-yBOaxeQBr8ctgpUt .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-yBOaxeQBr8ctgpUt rect.text{fill:none;stroke-width:0;}#mermaid-svg-yBOaxeQBr8ctgpUt .icon-shape,#mermaid-svg-yBOaxeQBr8ctgpUt .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yBOaxeQBr8ctgpUt .icon-shape p,#mermaid-svg-yBOaxeQBr8ctgpUt .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-yBOaxeQBr8ctgpUt .icon-shape .label rect,#mermaid-svg-yBOaxeQBr8ctgpUt .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yBOaxeQBr8ctgpUt .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-yBOaxeQBr8ctgpUt .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-yBOaxeQBr8ctgpUt :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 网络数据块
SSE 帧解码
事件 schema 校验
reducer 更新状态
文字消息
工具调用
共享业务状态
React 界面
事件与前后状态记录
追溯与回放面板

这几个边界分别负责消息是否完整、结构是否有效、当前能否应用,以及应用之后如何展示。它们分开之后,错误更容易定位。SSE 解码实现 · 事件校验与 reducer

用明确的更新规则维护状态

TEXT_MESSAGE_CONTENT 的行为是按照 messageId 追加 delta。共享状态则由快照和补丁更新。

下面是一条状态补丁的示意,前提是已有快照中存在相应任务与字段:

json 复制代码
{
  "type": "STATE_DELTA",
  "delta": [
    { "op": "replace", "path": "/tasks/1/status", "value": "done" }
  ]
}

它表达的是将对应任务的状态替换为 done。前端能够据此更新任务卡片,不需要从自然语言回答中猜测任务是否完成。

项目的教学 reducer 只实现已有目标路径上的安全 replace,并检查路径与数据;它没有实现完整的 JSON Patch 操作集合。这样可以把这部分学习范围集中在"共享状态怎样被增量更新"。状态补丁实现

记录每次更新才能解释每次变化

聊天组件处理事件时,会保留三个对象:事件本身、更新前状态、更新后状态。下面是核心实现节选:

ts 复制代码
const before = stateRef.current;
const after = aguiReducer(before, { kind: 'event', event });
setFrames((previous) => [...previous, { event, before, after }]);
commit(after);

这些记录让学习面板可以回答:哪条事件修改了文字?哪个状态路径变化了?如果把这条事件的 delta 改掉,同一个 reducer 会产生什么结果?

修改事件的实验以记录的 before 为起点,结果显示在独立预览中,不覆盖原对话。这样可以把实验结果与原结果直接比较。

暂停展示时,后台仍会继续接收事件。这使"目前收到了多少事件"和"目前展示到哪一条事件"可以分别观察,避免把回放控制误当成任务控制。聊天事件记录 · 回放与事件实验

执行成功需要明确的协议结果

HTTP 返回 200,只说明事件流成功打开。前端需要继续等待协议终态,才能判断本轮结果。

本项目用 RUN_FINISHED 的结果表达成功或已确认取消,用 RUN_ERROR 表达运行错误。终止事件之前发生断流,客户端会把它识别为截断状态。

基础实验还提供两条不同路径:协议取消会通知服务端,并等待取消结果;浏览器的 AbortController.abort() 只中断传输。取消端点是这个实验的应用扩展,不能把关闭 HTTP 连接当成服务端已经确认取消。生命周期与取消机制

五 A2UI 如何把声明变成可以提交的表单

A2UI 部分可以沿着"结构、数据、动作"三个对象阅读。结构决定有哪些控件,数据提供当前值,动作描述用户操作时需要回传什么。

创建 Surface 并建立组件上下文

Surface 可以理解为一次声明式界面的实例。项目在初始化时依次处理 createSurface、updateDataModel 和 updateComponents:先建立实例,再写入模型,最后提供依赖这些数据的组件。

例如,创建消息使用这样的结构:

json 复制代码
{
  "version": "v0.9.1",
  "createSurface": {
    "surfaceId": "trip-planner",
    "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
  }
}

surfaceId 标识界面实例,catalogId 指明组件目录。这里协议版本是 v0.9.1,Basic Catalog 的标识仍包含 v0_9,需要按版本契约使用。

后续消息必须关联已创建的 Surface。教学实现会拒绝重复创建和未知 Surface,避免错误消息意外重置或污染当前界面。Surface 与消息定义

扁平组件集合怎样形成界面树

组件通过 ID 相互引用。下面是根据项目组件格式整理的最小结构示意,假设数据模型中已经存在 /form/traveler 字符串:

json 复制代码
[
  { "id": "root", "component": "Card", "child": "form" },
  { "id": "form", "component": "Column", "children": ["traveler"] },
  {
    "id": "traveler",
    "component": "TextField",
    "label": "出行人姓名",
    "value": { "path": "/form/traveler" }
  }
]

Renderer 从 root 出发,找到 Card 的子组件 form,再找到 Column 中的 traveler,最终渲染姓名输入框。传输的是扁平组件定义,父子关系由引用表达。

这也解释了 Workshop 中一个直观实验:从 Column 的 children 移除 traveler,但保留它的定义,页面里的输入框仍会消失。因为有定义与能够从根节点访问到,是两件不同的事情。

当前 Renderer 支持 Card、Column、Text、TextField、ChoicePicker、Button 六种组件。消息按组件目录接受校验,再映射到 React 控件。组件 schema 与引用图 · React Renderer

数据绑定与结构更新分别发生

value.path 是一个数据地址。渲染时,控件从模型中读取该位置;用户编辑时,再将新值写回模型。

例如,姓名原来是"学习者",用户改成"小林",变化的是 /form/traveler 的值。组件 ID、类型和绑定路径可以保持不变。这种分离使字段值更新和界面结构更新能够分别观察。

本地输入也会记录到检查器,但方向标为 local。只有提交动作时,才会进入相应的动作处理路径。

组件更新也有明确规则:项目中的 updateComponents 对同一 ID 使用完整对象替换。需要修改一个 Text 的内容时,应提供完整的新定义,不能假设渲染器会保留未提供的旧属性。绑定与组件更新规则

按钮点击时把路径解析成具体值

按钮声明保存需要读取的上下文路径。用户点击按钮后,resolveA2uiAction() 会从当前模型读取值,构造 action.context。

下面是项目动作格式的示例,时间和用户输入仅用于说明:

json 复制代码
{
  "version": "v0.9.1",
  "action": {
    "name": "save_trip",
    "surfaceId": "trip-planner",
    "sourceComponentId": "submit",
    "timestamp": "2026-10-28T08:00:00.000Z",
    "context": {
      "destination": ["hangzhou"],
      "traveler": "小林",
      "days": "3"
    }
  }
}

可以看到,提交的是已经解析的值。目的地单选控件使用字符串数组;天数来自文本字段,在这个动作中仍是字符串,之后由业务逻辑解析与检查。

这一步有三个相互独立的校验层次:

  • 声明与消息结构:协议版本、组件类型、字段形状是否受支持。

  • 界面关系:子组件引用是否存在,是否有循环,绑定路径是否有效。

  • 业务动作:Surface、来源组件、动作名和参数是否属于允许的业务范围。

因此,一份声明可以被 Renderer 接受,并不自动意味着任意按钮动作都会被服务端执行。动作处理器仍会校验姓名、目的地和天数等内容。动作解析 · 服务端动作校验

给表单增加预算字段验证整个闭环

理解了结构、数据和动作之后,可以在 Workshop 中完成一个具体练习:

  1. 增加预算 TextField,为它设置绑定路径。

  2. 在数据模型中增加对应值,并把组件 ID 加入布局引用。

  3. 在按钮动作上下文中增加预算绑定。

  4. 应用声明,输入 4200,点击"生成计划"。

然后检查实际的 action.context.budget 与返回的预算信息。只增加控件但遗漏动作绑定,就无法完成这条提交链路;路径写错,则会在校验阶段收到反馈,并保留最后一次有效界面。预算字段练习

六 MCP Apps 如何把工具变成可交互的应用

MCP Apps 部分需要先区分三个角色:MCP Server 提供工具与资源,Host 负责承载与能力代理,App 在自己的 iframe 中渲染和处理交互。

Host 同时包含 MCP Client 与 AppBridge。前者连接服务,后者连接 App。理解这两段通信,是理解整个实现的起点。

工具定义关联资源而业务结果提供数据

项目的 get_trip_activities 工具通过下面的元数据关联界面。这是工具声明的局部节选:

json 复制代码
{
  "name": "get_trip_activities",
  "_meta": {
    "ui": {
      "resourceUri": "ui://intelligent-ui/trip-picker",
      "visibility": ["model", "app"]
    }
  }
}

Host 通过 tools/list 发现关联,再用 resources/read 读取 HTML。工具定义中的 URI、资源中的界面代码、工具调用返回的业务数据分别承担不同职责。

工具结果同时提供 content 与 structuredContent:前者保留可读的文字结果,后者包含城市和活动等结构化信息。App 验证结构化结果后渲染,避免依赖从自然语言里提取数据。工具与资源注册

两段通信各自完成初始化

本地模式下,Host 的 MCP Client 先通过 Streamable HTTP 与 Server 初始化,再发现工具、读取资源并取得结果。iframe 内的 App 还需要通过 postMessage 与 Host 完成另一段握手。

下面是本项目打开应用与切换城市的简化时序,Host 合并表示 MCP Client 和 AppBridge:
iframe App MCP Server Host iframe App MCP Server Host #mermaid-svg-Vs33HsAJYu9ZK68t{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Vs33HsAJYu9ZK68t .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Vs33HsAJYu9ZK68t .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Vs33HsAJYu9ZK68t .error-icon{fill:#552222;}#mermaid-svg-Vs33HsAJYu9ZK68t .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Vs33HsAJYu9ZK68t .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Vs33HsAJYu9ZK68t .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Vs33HsAJYu9ZK68t .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Vs33HsAJYu9ZK68t .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Vs33HsAJYu9ZK68t .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Vs33HsAJYu9ZK68t .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Vs33HsAJYu9ZK68t .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Vs33HsAJYu9ZK68t .marker.cross{stroke:#333333;}#mermaid-svg-Vs33HsAJYu9ZK68t svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Vs33HsAJYu9ZK68t p{margin:0;}#mermaid-svg-Vs33HsAJYu9ZK68t .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Vs33HsAJYu9ZK68t text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Vs33HsAJYu9ZK68t .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Vs33HsAJYu9ZK68t .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Vs33HsAJYu9ZK68t .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Vs33HsAJYu9ZK68t .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Vs33HsAJYu9ZK68t #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Vs33HsAJYu9ZK68t .sequenceNumber{fill:white;}#mermaid-svg-Vs33HsAJYu9ZK68t #sequencenumber{fill:#333;}#mermaid-svg-Vs33HsAJYu9ZK68t #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Vs33HsAJYu9ZK68t .messageText{fill:#333;stroke:none;}#mermaid-svg-Vs33HsAJYu9ZK68t .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Vs33HsAJYu9ZK68t .labelText,#mermaid-svg-Vs33HsAJYu9ZK68t .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Vs33HsAJYu9ZK68t .loopText,#mermaid-svg-Vs33HsAJYu9ZK68t .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Vs33HsAJYu9ZK68t .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Vs33HsAJYu9ZK68t .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Vs33HsAJYu9ZK68t .noteText,#mermaid-svg-Vs33HsAJYu9ZK68t .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Vs33HsAJYu9ZK68t .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Vs33HsAJYu9ZK68t .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Vs33HsAJYu9ZK68t .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Vs33HsAJYu9ZK68t .actorPopupMenu{position:absolute;}#mermaid-svg-Vs33HsAJYu9ZK68t .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Vs33HsAJYu9ZK68t .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Vs33HsAJYu9ZK68t .actor-man circle,#mermaid-svg-Vs33HsAJYu9ZK68t line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Vs33HsAJYu9ZK68t :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 核心 MCP 初始化 能力协商完成 发现工具并读取 UI 资源 工具元数据与 HTML tools/call 初始城市 首次工具结果 挂载 iframe ui/initialize 宿主上下文与能力 ui/notifications/initialized 先发送工具输入再发送结果 tools/call 切换城市 校验后代理调用 新工具结果 返回本次调用结果

MCP Client 已经连接,并不意味着 App 已经就绪。Host 必须等待 ui/notifications/initialized,再按顺序发送完整工具输入与工具结果。

项目还处理了一个前端时序问题:iframe 脚本可能在 load 事件之前发出初始化消息。Host 使用 useLayoutEffect 在 iframe 提交时连接桥,尽早安装消息监听,避免错过握手。两段初始化与桥接时序

App 内的按钮通过 Host 调用工具

用户在 App 中选择城市时,应用通过 SDK 请求工具。下面是 chooseCity() 中省略加载与异常处理后的核心节选:

ts 复制代码
const result = await app.callServerTool({
  name: TOOL_NAME,
  arguments: { city },
});
if (!active) return;
applyToolResult(result);

这个请求先到 Host,由 Host 检查会话、允许的工具与参数,再通过 MCP Client 代理调用。结果返回后,App 更新自己的界面。

Host 的存在让应用承载、能力限制和工具访问有了明确位置。当前教学 Host 只加载项目自带资源,并限制工具访问;接入第三方应用时,需要按相应宿主规范扩展隔离与授权设计。App 实现 · Host 实现

更新数据与切换布局使用不同生命周期

项目提供了两个 Host 主动发起的对照实验。单次成功操作的计数增量如下:

操作 tools/call resources/read iframe 挂载
更新工具输入城市并推送结果 +1 +0 +0
读取另一份布局资源并重新加载 +0 +1 +1

更新数据时,Host 把新结果推送给现有 App,保留原 iframe。切换布局时,Host 先读取并校验另一份资源,再关闭旧桥、重新挂载与握手,随后发送保留的工具结果。这个顺序也让资源读取失败时,原来的 App 仍能保留。

这与 App 内主动调用工具的路径也有区别:前者由 Host 推送结果,后者由 App 使用自己发起的 RPC 返回值更新界面。

Host 还使用会话、iframe 和操作序号检查异步结果是否仍然有效。用户关闭或重开 App 后,旧请求即使较晚返回,也不应覆盖新会话。对于包含 iframe 和异步工具请求的 React 页面,这类生命周期处理同样值得单独观察。MCP Apps 数据与资源实验

七 断线之后如何恢复同一次任务

基础示例说明消息怎样驱动界面,进阶实验进一步处理任务在异常条件下怎样继续。

任务恢复实验首先区分了几种身份与位置:

对象 表示什么 恢复时需要遵守什么
threadId 任务所属会话 不混入其他会话的任务
taskId 持续追踪的工作目标 重新执行仍属于原 task
runId 一次具体执行 恢复沿用原 run,重新执行创建新 run
seq 与 cursor 事件序号与已连续应用的位置 去重并检查序号缺口
checkpoint 指定位置的状态快照 从快照起点应用后续事件

在这个独立实验里,服务端 runner 与浏览器连接分离。浏览器断开接流后,后台任务仍可继续推进。

恢复时,服务端在一致的读取快照中返回 task、checkpoint 和后续事件。客户端核对身份与序号,再逐条重放;重放完成后还需要核对结果,才能把界面视为已恢复。恢复客户端 · 恢复存储

用执行计数验证恢复有没有重做业务

可以在本地做下面的实验:

  1. 新建任务并开始运行,记下 task、run 和当前事件游标。

  2. 点击"断开接流",等待一段时间,再查询任务。

  3. 恢复连接,观察是否仍然对应原来的 run,以及任务状态是否已经前进。

  4. 等待同一 run 进入终态后,再重复查询、恢复,确认 toolExecutionCount 保持不变。

  5. 任务进入终态后,再显式"重新执行",比较 task 与 run 的变化。

恢复同一个 run 不会因为重新读取记录而增加工具执行计数。显式重新执行会创建新 run,随后产生新的执行记录。

实验还可以注入重复事件、旧 run 的迟到事件和序号缺口。重复事件不重复追加文字,旧 run 的事件不能覆盖新 run,序号缺口需要补拉或重新恢复,不能直接跳到更大的游标。任务恢复实验步骤

这里有三种需要独立表达的操作:断流改变连接,取消请求服务端结束任务,重新执行创建新的一次运行。把它们区分开,用户才知道点击一个按钮会改变什么。

八 人工确认与结果未知怎样对应业务事实

一个确认按钮需要关联明确的业务对象。否则,用户确认时看到的内容与系统实际执行的内容,可能已经发生变化。

任务版本和提议版本分别约束什么

人工确认实验把草稿、已保存提议和报名记录分开。用户修改输入时形成草稿,保存之后形成可审阅提议,确认成功之后才产生报名事实。

其中,expectedVersion 约束任务的并发前提,proposalVersion 标识用户审阅的提议内容。前者检查"任务还是刚才读到的状态吗",后者检查"确认的还是刚才那份内容吗"。

确认引擎还会检查提议身份、状态、有效期和教学会话。实际报名参数来自已保存提议,未保存草稿不会被悄悄作为执行内容。

在人工确认页面中,生成提议后修改并保存,再点击"尝试旧版本确认",就能观察旧版本确认被拒绝;保存修改时,实验会保留之前的确认命令供这项验证使用。人工确认教程 · 确认引擎

业务提交与收到响应是两个时刻

考虑这样的过程:服务端已经提交了报名记录,但响应返回之前连接断了。此时前端看到网络错误,却不能据此判断报名没有发生。

项目把这种情况表达为"结果未知"。客户端保留原命令,先提供事实查询入口,查询命令收据和报名记录,再决定后续操作。

命令使用 commandId 和完整请求指纹建立身份。同一个 ID 与同样参数表示重试同一操作;同一个 ID 携带不同参数则会冲突。

下面是根据人工确认引擎整理的简化伪代码,省略具体错误类型和存储接口:

text 复制代码
校验命令结构与教学会话
在同一事务内:
    读取任务并校验归属
    查询 commandId 对应的历史收据
    如果收据已存在:
        请求指纹不同 → 拒绝同键异参
        请求指纹相同 → 返回原收据
    校验任务版本
    校验提议身份、内容版本、状态和有效期
    更新任务,必要时写入报名记录
    保存命令收据
提交事务

检查顺序很关键:已经成功的命令再次到达时,应先核对是否为同一次操作并返回原收据。如果先要求当前任务版本仍等于旧版本,第一次成功导致的版本变化,就可能让一次正常重试被误报为失败。

SQLite 事务让任务变化、报名事实和命令收据一起提交。查询暂时没有找到收据时,也不能只凭一次空结果断言原请求永远不会提交;需要重试时,继续使用原命令身份与参数。幂等与结果未知处理 · 事务存储

九 A2UI 表单怎样保护用户草稿

动态表单还会遇到另一类问题:用户正在编辑字段,远端却更新了同一份数据。直接采用最新远端值,可能覆盖用户尚未提交的输入。

A2UI 兼容实验会比较三份内容:原始基线、本地草稿和远端新值。对于绑定没有变化、也没有已有冲突的普通字段,可以按下面的规则理解:

本地与远端的变化 合并行为
本地未修改 接受远端值
只有本地修改 保留本地草稿
两边修改后值相同 接受一致结果
两边都修改且值不同 保留草稿,要求明确解决冲突

例如,原始行程为 2 天,本地修改为 5 天,远端增加一天变成 3 天。页面读取新快照后保留 5,同时展示原值、草稿与远端值,让用户选择保留草稿或采用远端结果。

实现还处理了比这个例子更细的情况:旧 revision 不能把新状态回滚;已有冲突不会因为再次读取而自动消失;组件被删除或绑定路径改变时,需要处理对应草稿,不能只比较字段值。

还有一个值得验证的时序:先保存天数为 3 的动作,再把当前输入改成 5,然后发送先前保存的动作并模拟响应丢失。服务端最终可能是 3,当前草稿却应该继续保留 5。

因此,项目额外跟踪提交后的编辑。它需要知道"这次响应属于哪份提交",以及"用户在那之后又改了什么"。存在未解决冲突时,buildActionEnvelope() 会阻止生成提交动作。

这些行为在独立实验中都可以操作。它们说明了动态界面还需要维护用户输入的生命周期,远端新数据到达并不意味着可以直接覆盖当前表单。冲突实验教程 · 草稿合并实现

十 把表单确认与事件恢复放进同一个任务

前面的独立实验便于拆解机制,活动报名联合实验则用一份共同契约连接它们:A2UI 收集输入,保存为待确认提议,用户核对内容版本,确认后写入本地报名记录,并记录对应的 AG-UI 工具与状态事件。

这个流程使用一个持续的任务身份。等待用户输入、修改提议和最终确认属于同一个 run,只有被接受且没有作为历史命令重放的操作,才会追加相应事件。

可以按下面的步骤验证:

  1. 生成待确认提议,保存当前确认命令。

  2. 修改报名人数并保存,再发送之前的旧确认,观察版本冲突。

  3. 核对当前版本,打开"模拟命令提交后丢失响应",再确认。

  4. 页面进入"结果未知"后,点击"查询事实并恢复"。

  5. 检查报名记录、原命令收据与同一 run 的完成事件。

  6. 重放最后命令,观察 replayed: true,并确认报名 ID、事件游标与工具结果数量没有增加。

本地模式把相关任务、收据、事实与事件放入同一 SQLite 事务。联合实验通过 HTTP JSON 事实查询批量返回事件,没有新增 SSE 长连接;A2UI 表单与审批通过应用层契约组合,没有把 A2UI 消息嵌入 AG-UI。

这项实验的价值,在于能够沿着同一任务检查输入、确认、执行与恢复的关系。本地事务里的幂等行为,也需要与接入外部业务系统后的保证范围分别理解。联合实验教程 · 联合引擎

十一 从实验环境走向自己的实现

项目提供中文教程、全文搜索、源码导读、预测练习和独立开发任务书。阅读文档后返回工作区,可以继续观察当前消息、表单和 App,方便把解释与实际操作放在一起。

我建议按这样的顺序使用:先跑通三轮聊天,再选择一个协议深入追踪,随后修改一个输入验证理解,最后换一个自己的只读业务场景完成闭环。项目中的学习路线和独立项目任务书可以提供具体步骤。

本地启动与检查

本地需要 Node.js 22.13+,推荐 Node.js 24。克隆发布版本后执行:

bash 复制代码
git clone --branch v0.1.0 https://github.com/likebeans/agent-ui-lab.git
cd agent-ui-lab
npm ci
npm run dev

访问 http://127.0.0.1:5173。同一命令会启动 Vite 前端与 API / MCP 后端;后端默认端口为 3100。修改源码前,可以从检出的版本创建自己的分支。快速开始

仓库使用 Vitest 与 Playwright,检查入口包括:

bash 复制代码
npm run check
npm run test:e2e
npm run test:pages
npm run format:check

这些检查分别覆盖单元与集成逻辑、类型和构建、本地浏览器交互、静态版行为与格式。Pages 回归还会检查静态资源路径,以及是否意外出现 /api 或 /mcp 网络请求;MCP 交互测试可以验证更新数据时保留 iframe、切换资源时重新挂载。

发布工作流先执行 CI,再按实际 Pages 路径构建和验证,部署前确认已验证提交仍是 main 的最新提交。这些约束让"在线学习版"和"本地协议模式"的差异能够持续接受检查。测试脚本 · Pages 回归 · 发布流程

接入真实业务时沿着明确边界扩展

当前版本定位于协议学习与参考实现。旅行和报名数据来自教学样本,三种协议也分别采用文中说明的实现范围。

接入真实 Agent 时,可以先保留前端状态和渲染边界,在服务端替换决策与生成逻辑,把真实文本流、工具调用与业务结果转换成协议消息。需要新增 A2UI 组件时,同步扩展目录、校验与 Renderer。需要接入新的 MCP App 时,补齐对应资源策略、权限和宿主隔离。

持久会话、生产鉴权和外部业务事务,需要结合实际系统继续设计。项目提供的真实开发说明可以作为接入时的阅读入口。

欢迎一起完善 Agent UI Lab

Agent UI Lab 想提供的是一条具体的学习路径:从操作界面开始,理解消息如何驱动状态,再亲手修改实现,最后把这些机制迁移到自己的业务中。

如果你已经掌握 JavaScript / TypeScript 和基础 React,正在学习 Agent 前端、生成式 UI 或 MCP 工具界面,欢迎打开在线体验试一试。

从一条文本增量开始,追到 reducer;从一个输入框开始,追到绑定路径与动作;从一个 App 按钮开始,追到宿主与工具调用。能解释这些具体变化,就更容易把一个可交互的 Agent 应用逐步实现出来。

如果项目对你有帮助,欢迎 Star。遇到可复现问题、难以理解的教程步骤,或希望补充新的实验,欢迎提交 Issue,也欢迎按贡献指南参与改进。

相关推荐
hhhjhl1 小时前
开源鸿蒙平台 CMP 三方库「compose-macos-26-ui」适配全流程
macos·开源·harmonyos
AAA小肥杨1 小时前
大模型量化简介
人工智能·大模型
miofly2 小时前
GitHub 日榜趋势速报 | 2026-10-29
开源·github
yz_aiks11 小时前
Spring AI 2.1 ChatMemory 已过时用什么替代:迁移到 Session Memory 实战(JDBC 存储与会话压缩)
java·spring boot·大模型·ai agent·spring ai
网络豆12 小时前
开源鸿蒙平台 CMP 三方库「navigation-compose-extended」适配全流程
华为·开源·harmonyos
虎鲸不是鱼13 小时前
AI 自动化逆向(二十六)「已解出」和「已解析」是两回事
人工智能·自动化·大模型·逆向·ai逆向
染指111014 小时前
158.Agent-LangGraph-子图(共享状态和不共享状态模式)
开发语言·人工智能·python·ui·agent·agents
2601_9494800614 小时前
开源鸿蒙平台 CMP 三方库「multiplatform-markdown-renderer」适配全流程
华为·开源·harmonyos
alsmile14 小时前
AI友好的可视化图形引擎自我革命:Meta2d.js 2.0 历时一年全新重写,性能与多人协作全面升级
前端·开源·数据可视化