阅读视图

发现新文章,点击刷新页面。

json-render:Generative UI 的终极框架 —— 让 AI 安全地生成界面

引言:当 AI 想要"画"界面

如果你用过 ChatGPT 或 Claude,你会发现它们回复的都是文字——无论多复杂的数据,最终呈现给用户的要么是 Markdown,要么是代码块。这就像请了一个天才设计师,却只允许他用打字机工作。

如果 AI 能直接生成界面本身呢? 不是生成描述界面的代码,而是生成一个可以立即渲染的 UI 结构?

这就是 Generative UI 的愿景,也正是 Vercel 开源的 json-render 要解决的核心问题。


一、传统方式的困境

为什么不直接让 AI 生成 React 代码?

最直觉的做法是让 LLM 直接输出 JSX 或 HTML,然后 eval 执行。但这条路有三个致命缺陷:

❌ 安全性  → AI 生成的代码可能包含任意 JavaScript 执行
❌ 可预测性 → LLM 可能"幻觉"出不存在的组件、无效的属性
❌ 跨平台  → React 代码无法直接跑在 React Native / Vue / Svelte 上

json-render 的核心洞察是:不要让 AI 生成代码,让它生成数据(JSON)。这份 JSON 严格约束在你预定义的组件范围内,然后由各平台的渲染器将其转化为原生 UI。

一句话概括:你设置围栏,AI 在围栏里自由发挥。


二、全局架构总览

在深入细节之前,先用两张图建立全局认知。

2.1 核心工作流(三步走)

┌─────────────┐    ┌─────────────────┐    ┌───────────────┐    ┌──────────────┐
│  用户 Prompt │───▶│ AI + Catalog     │───▶│  JSON Spec    │───▶│  Renderer    │
│ "创建仪表盘" │    │ (受限生成)       │    │ (结构化数据)   │    │ (原生UI)     │
└─────────────┘    └─────────────────┘    └───────────────┘    └──────────────┘
                        │                       │                     │
                   ✅ 有围栏的              ✅ 可预测的           ✅ 可流式的

第一步:你定义 Catalog("AI 能用什么组件和动作") 第二步:AI 根据 Catalog 的约束生成 JSON Spec("用这些组件搭出什么界面") 第三步:Renderer 把 JSON Spec 渲染成原生 UI("在屏幕上画出来")

2.2 包架构全景图

                        ┌─────────────────────────────────┐
                        │       @json-render/core          │
                        │  (Schema, Catalog, Prompt,       │
                        │   Props, Visibility, State,      │
                        │   SpecStream, Validation)        │
                        └───────────────┬─────────────────┘
                                        │
              ┌────────────┬────────────┼────────────┬──────────────┐
              ▼            ▼            ▼            ▼              ▼
     ┌──────────────┐ ┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐
     │@json-render/ │ │  /vue  │ │ /svelte  │ │  /solid  │ │ /react-native│
     │    react     │ │        │ │          │ │          │ │              │
     └──────┬───────┘ └────────┘ └──────────┘ └──────────┘ └──────────────┘
            │
   ┌────────┼──────────┬───────────┬──────────┬────────────┐
   ▼        ▼          ▼           ▼          ▼            ▼
┌───────┐┌────────┐┌─────────┐┌────────┐┌──────────┐┌────────────┐
│/shadcn││/remotion││/react-  ││/react- ││  /image  ││/react-three│
│(36个  ││(视频)   ││  pdf    ││ email  ││(SVG/PNG) ││  -fiber    │
│组件)  ││        ││(PDF)    ││(邮件)  ││          ││  (3D)      │
└───────┘└────────┘└─────────┘└────────┘└──────────┘└────────────┘

状态管理适配器: /redux  /zustand  /jotai  /xstate
其他工具:       /codegen  /mcp  /yaml

@json-render/core 是与框架无关的核心层,包含所有共享逻辑。各渲染器只负责将 JSON Spec 映射为各自平台的原生组件。


三、Schema / Catalog / Spec —— 先搞清这三兄弟

这三个概念经常被混淆,用一个类比就能记住:

┌──────────────────────────────────────────────────────┐
│  类比:写作文                                         │
│                                                      │
│  Schema  = 语法规则(主谓宾怎么排列)                   │
│  Catalog = 词汇表 (你能用哪些词)                      │
│  Spec    = 作文本身(AI 按语法用词汇写出的文章)          │
└──────────────────────────────────────────────────────┘

Schema 定义 JSON 的骨架结构。内置的 React schema 使用扁平元素树:一个 root 键 + 一个 elements map。这种扁平结构是刻意设计的——比深层嵌套更适合 AI 生成和流式传输。

Catalog 定义"词汇"——有哪些组件、各自接受什么属性、有哪些可用动作。用 Zod 做类型约束。

Spec 就是 AI 最终产出的 JSON 文档,遵守 Schema 的结构,使用 Catalog 中的组件。


四、从零开始:Hello World(🌱 入门级)

4.1 安装

npm install @json-render/core @json-render/react

4.2 最简三步

// ① 定义 Catalog —— "AI 能用什么"
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';

const catalog = defineCatalog(schema, {
  components: {
    Card: {
      props: z.object({ title: z.string() }),
      slots: ["default"],
      description: "容器卡片",
    },
    Text: {
      props: z.object({ content: z.string() }),
      description: "文本段落",
    },
  },
  actions: {},
});

// ② 定义 Registry —— "组件长什么样"
import { defineRegistry, Renderer } from '@json-render/react';

const { registry } = defineRegistry(catalog, {
  components: {
    Card: ({ props, children }) => (
      <div style={{ border: '1px solid #ddd', padding: 16, borderRadius: 8 }}>
        <h2>{props.title}</h2>
        {children}
      </div>
    ),
    Text: ({ props }) => <p>{props.content}</p>,
  },
});

// ③ 渲染一份手写的 Spec
const spec = {
  root: "card-1",
  elements: {
    "card-1": {
      type: "Card",
      props: { title: "Hello json-render!" },
      children: ["text-1"],
    },
    "text-1": {
      type: "Text",
      props: { content: "这是我的第一个 json-render 界面" },
      children: [],
    },
  },
};

function App() {
  return <Renderer spec={spec} registry={registry} />;
}

这就是全部!即使不接入 AI,json-render 也能作为一个 JSON 驱动的 UI 渲染引擎使用。

4.3 渲染流程图解

spec (JSON)
  │
  ├── root: "card-1"
  │
  └── elements:
        │
        ├── "card-1" ──▶ type: "Card" ──▶ registry 查到 Card 组件 ──▶ <div>
        │                 props.title: "Hello"                        <h2>Hello</h2>
        │                 children: ["text-1"]                        ↓ 递归渲染子节点
        │
        └── "text-1" ──▶ type: "Text" ──▶ registry 查到 Text 组件 ──▶ <p>第一个界面</p>

Renderer 读取 root,在 elements map 中查找该元素,匹配 registry 中的组件实现,递归渲染 children 引用的子元素。


五、接入 AI 生成(🌿 进阶级)

5.1 数据流全景

┌──────────┐  prompt   ┌───────────┐  system prompt   ┌──────────┐
│  浏览器   │─────────▶│  API Route │───────────────▶│   LLM    │
│  (React)  │          │  (Next.js) │                 │(Claude等)│
│           │◀─────────│            │◀────────────────│          │
│  useUI    │ JSONL    │  stream    │  JSONL patches  │          │
│  Stream   │ patches  │  Text     │                 │          │
└──────────┘          └───────────┘                 └──────────┘
     │
     ▼
  Renderer ──▶ 原生 UI(边生成边渲染)

5.2 服务端:API Route

// app/api/generate/route.ts
import { streamText } from 'ai';
import { catalog } from '@/lib/catalog';

export async function POST(req: Request) {
  const { prompt } = await req.json();

  const result = streamText({
    model: 'anthropic/claude-haiku-4.5',
    system: catalog.prompt(),   // ← 自动从 Catalog 生成 system prompt
    prompt,
  });

  return result.toTextStreamResponse();
}

catalog.prompt() 是关键——它把你的组件定义、属性约束、可用动作全部转化为 LLM 能理解的 system prompt,告诉 AI "你只能用这些积木"。

5.3 客户端:流式渲染

'use client';
import { Renderer, StateProvider, VisibilityProvider, useUIStream } from '@json-render/react';
import { registry } from '@/lib/registry';

export default function Page() {
  const { spec, isStreaming, send } = useUIStream({
    api: '/api/generate',
  });

  return (
    <StateProvider initialState={{}}>
      <VisibilityProvider>
        <input
          placeholder="描述你想要的界面..."
          onKeyDown={(e) => {
            if (e.key === 'Enter') send(e.currentTarget.value);
          }}
        />
        <Renderer spec={spec} registry={registry} loading={isStreaming} />
      </VisibilityProvider>
    </StateProvider>
  );
}

用户输入 "创建一个登录表单",AI 会流式输出类似这样的 JSONL:

{"op":"add","path":"/root","value":"card-1"}
{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"登录"},"children":["email","pwd","btn"]}}
{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"邮箱","name":"email","type":"email"}}}
{"op":"add","path":"/elements/pwd","value":{"type":"Input","props":{"label":"密码","name":"password","type":"password"}}}
{"op":"add","path":"/elements/btn","value":{"type":"Button","props":{"label":"登录"}}}

每一行到达,UI 就多渲染一个组件,用户看到界面在眼前"生长"出来。

5.4 秒用 shadcn/ui —— 36 个开箱即用组件

不想从头写组件?直接用预构建的 shadcn/ui 套件:

import { shadcnComponentDefinitions } from '@json-render/shadcn/catalog';
import { shadcnComponents } from '@json-render/shadcn';

// Catalog:从 36 个组件中挑选你需要的
const catalog = defineCatalog(schema, {
  components: {
    Card: shadcnComponentDefinitions.Card,
    Button: shadcnComponentDefinitions.Button,
    Input: shadcnComponentDefinitions.Input,
    Table: shadcnComponentDefinitions.Table,
    // ... 一共 36 个可选
  },
  actions: {},
});

// Registry:对应实现一一映射
const { registry } = defineRegistry(catalog, {
  components: {
    Card: shadcnComponents.Card,
    Button: shadcnComponents.Button,
    Input: shadcnComponents.Input,
    Table: shadcnComponents.Table,
  },
});

从 Accordion 到 Tooltip,Table 到 LineGraph,基本覆盖了 Web 应用的全部常见 UI 元素。


六、数据绑定 —— 让界面活起来(🌿 进阶级)

静态 JSON 只是起点。json-render 的表达式系统让 AI 生成的界面能绑定到运行时数据。

6.1 表达式速查表

┌──────────────┬──────────────────────────────────────┬──────────────┐
│  表达式       │  语法                                │  用途         │
├──────────────┼──────────────────────────────────────┼──────────────┤
│  $state      │  { "$state": "/user/name" }          │  读取状态     │
│  $bindState  │  { "$bindState": "/form/email" }     │  双向绑定     │
│  $item       │  { "$item": "title" }                │  列表项字段   │
│  $index      │  { "$index": true }                  │  列表项索引   │
│  $cond       │  { "$cond":..,"$then":..,"$else":..} │  条件选择     │
│  $template   │  { "$template": "Hi, ${/user/name}!" │  字符串插值   │
│  $computed   │  { "$computed": "fn", "args": {...} } │  计算函数     │
└──────────────┴──────────────────────────────────────┴──────────────┘

6.2 实战:带状态的设置表单

这个例子展示了 $bindState 双向绑定——表单组件既能读取状态,也能写回状态:

{
  "root": "card",
  "state": {
    "name": "Ada Lovelace",
    "email": "ada@example.com",
    "notifications": true
  },
  "elements": {
    "card": {
      "type": "Card",
      "props": { "title": "账户设置" },
      "children": ["nameInput", "emailInput", "notifSwitch"]
    },
    "nameInput": {
      "type": "Input",
      "props": {
        "label": "姓名",
        "name": "name",
        "value": { "$bindState": "/name" }
      }
    },
    "emailInput": {
      "type": "Input",
      "props": {
        "label": "邮箱",
        "name": "email",
        "type": "email",
        "value": { "$bindState": "/email" }
      }
    },
    "notifSwitch": {
      "type": "Switch",
      "props": {
        "label": "接收邮件通知",
        "name": "notifications",
        "checked": { "$bindState": "/notifications" }
      }
    }
  }
}

所有路径都是 JSON Pointer(RFC 6901):/name 指向 state.name/notifications 指向 state.notifications。用户在输入框里修改内容,状态自动更新;状态变化后,所有引用该路径的组件自动重新渲染。

6.3 实战:repeat 列表渲染

{
  "root": "todo-list",
  "state": {
    "todos": [
      { "id": "1", "title": "买牛奶", "done": false },
      { "id": "2", "title": "遛狗",   "done": true }
    ]
  },
  "elements": {
    "todo-list": {
      "type": "Stack",
      "props": { "direction": "vertical", "gap": "sm" },
      "repeat": { "statePath": "/todos", "key": "id" },
      "children": ["todo-item"]
    },
    "todo-item": {
      "type": "Card",
      "props": {
        "title": { "$item": "title" }
      },
      "children": ["toggle"]
    },
    "toggle": {
      "type": "Switch",
      "props": {
        "label": "完成",
        "checked": { "$bindItem": "done" }
      }
    }
  }
}

repeat 告诉渲染器:"遍历 /todos 数组,每一项都渲染 todo-item 和它的子元素"。$item 读取当前项的字段,$bindItem 实现列表项内的双向绑定。

6.4 表达式解析流程

原始 props(含表达式)
  │
  ▼
resolvePropValue()  ← core/props.ts 中的核心函数
  │
  ├── 是 { $state } ?  → getByPath(stateModel, path) 读值
  ├── 是 { $bindState } ?  → 读值 + 暴露路径给组件写回
  ├── 是 { $item } ?  → 从 repeatItem 中读字段
  ├── 是 { $index } ?  → 返回当前循环索引
  ├── 是 { $cond } ?  → evaluateVisibility(条件) → 选 $then$else
  ├── 是 { $template } ?  → 正则替换 ${/path} 为状态值
  ├── 是 { $computed } ?  → 找到注册函数 → 递归解析 args → 调用函数
  ├── 是数组?  → 递归解析每个元素
  ├── 是普通对象?  → 递归解析每个值
  └── 其他  → 原样返回(字面量)

所有表达式的解析在单次遍历中完成,且支持任意嵌套深度。


七、条件可见性(🌿 进阶级)

visible 字段让 AI 生成的界面可以根据状态条件显示/隐藏元素,而不需要写一行逻辑代码。

7.1 简单条件

{
  "type": "Alert",
  "props": { "message": "表单有错误" },
  "visible": { "$state": "/form/hasErrors" }
}

/form/hasErrors 为真值时显示。

7.2 组合条件(AND + OR)

{
  "type": "Button",
  "props": { "label": "退款" },
  "visible": [
    { "$state": "/auth/isSignedIn" },
    { "$state": "/user/role", "eq": "support" },
    { "$state": "/order/amount", "gt": 0 },
    { "$state": "/order/isRefunded", "not": true }
  ]
}

数组 = 隐式 AND。这个按钮只在"已登录 + 角色为客服 + 订单金额 > 0 + 未被退款"时才可见。

7.3 条件求值引擎

visible 条件
  │
  ▼  evaluateVisibility() ← core/visibility.ts
  │
  ├── undefined → true(无条件 = 可见)
  ├── boolean → 直接返回
  ├── 数组 → 隐式 AND(every)
  ├── { $and } → 显式 AND(every,支持嵌套)
  ├── { $or }  → OR(some,支持嵌套)
  └── 单条件 → evaluateCondition()
                  │
                  ├── 无运算符 → Boolean(value) 真值判断
                  ├── eq / neq → 相等 / 不等
                  ├── gt / gte / lt / lte → 数值比较
                  └── not: true → 对结果取反

八、高级特性实战(🔥 高级)

8.1 Watchers + $computed:级联选择器

这是仓库 examples/no-ai 中的真实示例。当用户选择国家时,城市列表自动更新:

{
  "root": "card",
  "state": {
    "form": { "country": "", "city": "" },
    "availableCities": []
  },
  "elements": {
    "card": {
      "type": "Card",
      "props": { "title": "收货地址" },
      "children": ["countrySelect", "citySelect", "preview"]
    },
    "countrySelect": {
      "type": "Select",
      "props": {
        "label": "国家",
        "options": ["US", "Canada", "UK", "Germany", "Japan"],
        "value": { "$bindState": "/form/country" }
      },
      "watch": {
        "/form/country": [
          {
            "action": "setState",
            "params": {
              "statePath": "/availableCities",
              "value": {
                "$computed": "citiesForCountry",
                "args": { "country": { "$state": "/form/country" } }
              }
            }
          },
          {
            "action": "setState",
            "params": { "statePath": "/form/city", "value": "" }
          }
        ]
      }
    },
    "citySelect": {
      "type": "Select",
      "props": {
        "label": "城市",
        "options": { "$state": "/availableCities" },
        "value": { "$bindState": "/form/city" }
      }
    },
    "preview": {
      "type": "Heading",
      "props": {
        "text": {
          "$computed": "formatAddress",
          "args": {
            "city": { "$state": "/form/city" },
            "country": { "$state": "/form/country" }
          }
        },
        "level": "h3"
      }
    }
  }
}

交互流程图:

用户选择 "Japan"
  │
  ▼ $bindState 写入 /form/country = "Japan"
  │
  ▼ watch 触发
  │
  ├── ① setState: /availableCities = citiesForCountry("Japan")
  │                                   → ["Tokyo","Osaka","Kyoto",...]
  │
  └── ② setState: /form/city = "" (重置城市选择)
  │
  ▼ citySelect 的 options 读取 $state: /availableCities → 下拉更新
  ▼ preview 的 $computed: formatAddress 重新计算 → 显示 "Japan"

注册 $computed 函数:

const computedFunctions = {
  citiesForCountry: (args) => {
    const cityData = { US: ["New York", "LA"], Japan: ["Tokyo", "Osaka"] };
    return cityData[args.country] ?? [];
  },
  formatAddress: (args) => {
    if (!args.city && !args.country) return "未选择地址";
    if (!args.city) return args.country;
    return `${args.city}, ${args.country}`;
  },
};

8.2 跨字段表单验证 + validateForm

注册表单示例,展示了 json-render 的完整表单能力:

{
  "type": "Input",
  "props": {
    "label": "确认密码",
    "type": "password",
    "value": { "$bindState": "/form/confirmPassword" },
    "checks": [
      { "type": "required", "message": "请确认密码" },
      {
        "type": "matches",
        "args": { "other": { "$state": "/form/password" } },
        "message": "两次密码不一致"
      }
    ],
    "validateOn": "blur"
  }
}

提交按钮使用内置的 validateForm 动作一键校验所有字段:

{
  "type": "Button",
  "props": { "label": "注册" },
  "on": {
    "press": [
      { "action": "validateForm", "params": { "statePath": "/result" } }
    ]
  }
}

验证结果写入 /result,然后用 $cond 条件显示不同的提示:

{
  "type": "Alert",
  "props": {
    "title": "验证结果",
    "message": {
      "$cond": { "$state": "/result/valid", "eq": true },
      "$then": "所有字段验证通过,可以提交!",
      "$else": "请修正上方的错误后再提交。"
    },
    "type": {
      "$cond": { "$state": "/result/valid", "eq": true },
      "$then": "success",
      "$else": "error"
    }
  },
  "visible": { "$state": "/result", "neq": null }
}

8.3 Inline 模式:聊天中的 Generative UI

仓库的 examples/chat 展示了最接近生产的用法——AI 聊天机器人在对话中嵌入动态 UI:

┌──────────────────────────────────────────────────┐
  用户: 比较纽约、伦敦和东京的天气                     
├──────────────────────────────────────────────────┤
  AI: 这是三个城市的实时天气对比:                     
                                                  
  ┌────────────┐ ┌────────────┐ ┌────────────┐   
    New York      London       Tokyo        
     22°C ☀️      15°C 🌧      28°C       
    Humidity:     Humidity:    Humidity:    
      65%           82%          70%        
  └────────────┘ └────────────┘ └────────────┘   
                                                  
  纽约今天晴朗适合户外活动...                         
└──────────────────────────────────────────────────┘

服务端使用 pipeJsonRender 分离文字和 JSONL patch:

import { pipeJsonRender } from '@json-render/core';

const stream = createUIMessageStream({
  execute: async ({ writer }) => {
    writer.merge(pipeJsonRender(result.toUIMessageStream()));
  },
});

客户端用 useJsonRenderMessage 从聊天消息中提取 spec:

function ChatMessage({ message }) {
  const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);

  return (
    <div>
      {/* 文字部分正常渲染 */}
      {text && <p>{text}</p>}
      {/* UI 部分用 Renderer 渲染 */}
      {hasSpec && <Renderer spec={spec} registry={registry} />}
    </div>
  );
}

8.4 自定义 Action Handler:安全的交互模型

Actions 是 json-render 安全性的关键。AI 不生成代码,只声明意图:

┌──────────┐  JSON声明      ┌──────────────┐  实际执行     ┌──────────┐
│   AI     │───────────────▶│  Action 名称  │──────────────▶│ 你的代码  │
│"触发     │ { action:      │ "submitForm"  │ handler 里    │ fetch()  │
│ submit""submitForm" }│              │ 才有真正逻辑   │ 处理业务  │
└──────────┘               └──────────────┘              └──────────┘
     ❌ 不生成代码               ✅ 只是个名字              ✅ 你完全控制
const { registry, handlers } = defineRegistry(catalog, {
  components: { /* ... */ },
  actions: {
    submitForm: async (params, setState) => {
      const res = await fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify(params),
      });
      const result = await res.json();
      setState((prev) => ({ ...prev, formResult: result }));
    },
    confetti: () => {
      // 放烟花!🎉
      confettiListener?.();
    },
  },
});

九、状态管理深潜(🔥 高级)

9.1 内置 StateStore 的工作原理

immutableSetByPath("/user/name", "Bob")
  │
  ├── 解析 JSON Pointer → ["user", "name"]
  ├── 浅拷贝 root → { ...root }
  ├── 浅拷贝 root.user → { ...root.user }  ← 只拷贝受影响路径
  ├── 设置 root.user.name = "Bob"
  └── 通知所有订阅者 → React 重新渲染

使用结构共享(structural sharing),只浅拷贝变更路径上的对象,未改变的分支保持原引用。这意味着 React 的 === 比较能正确跳过未变化部分。

9.2 接入外部状态管理

通过 createStoreAdapter 可以接入任何外部状态库,只需提供三个回调:

import { createStoreAdapter } from '@json-render/core';

// 只需实现 3 个方法
const store = createStoreAdapter({
  getSnapshot: () => myZustandStore.getState(),
  setSnapshot: (next) => myZustandStore.setState(next),
  subscribe: (listener) => myZustandStore.subscribe(listener),
});

官方已提供 Redux、Zustand、Jotai、XState 四个适配器包。


十、跨平台能力矩阵

同一份 Catalog 定义,可以驱动完全不同的输出:

┌─────────────┬────────────────────────────────────┐
│  渲染器      │  输出                              │
├─────────────┼────────────────────────────────────┤
│  /react     │  浏览器 DOM                         │
│  /vue       │  Vue 3 组件树                       │
│  /svelte    │  Svelte 5 组件树(runes 响应式)     │
│  /solid     │  SolidJS 细粒度响应式组件            │
│  /react-native │  iOS/Android 原生视图            │
│  /shadcn    │  36 个精美预构建组件(Radix+Tailwind)│
│  /react-pdf │  PDF 文档(发票、报告)              │
│  /react-email│ HTML 邮件                          │
│  /remotion  │  视频合成(时间轴+轨道+转场)        │
│  /image     │  SVG/PNG(OG 图、社交卡片)          │
│  /react-three-fiber │ 3D 场景(19 个内置组件)    │
└─────────────┴────────────────────────────────────┘

生成 PDF 示例:

import { renderToBuffer } from '@json-render/react-pdf';

const spec = {
  root: "doc",
  elements: {
    doc: { type: "Document", props: { title: "发票" }, children: ["page-1"] },
    "page-1": { type: "Page", props: { size: "A4" }, children: ["heading", "table"] },
    heading: { type: "Heading", props: { text: "发票 #1234", level: "h1" } },
    table: {
      type: "Table",
      props: {
        columns: [
          { header: "商品", width: "60%" },
          { header: "价格", width: "40%", align: "right" },
        ],
        rows: [["Widget A", "¥68.00"], ["Widget B", "¥172.00"]],
      },
    },
  },
};

const buffer = await renderToBuffer(spec);

生成 OG 图片:

import { renderToPng } from '@json-render/image/render';

const png = await renderToPng(spec, { fonts });

十一、两种生成模式对比

┌────────────────────┬────────────────────────────────┐
│   Standalone 模式   │        Inline 模式             │
├────────────────────┼────────────────────────────────┤
│  AI 只输出 JSONL    │  AI 先写文字,需要时嵌入 JSONL   │
│  整个页面都是 UIUI 内嵌在聊天对话中              │
│  适合:Playground   │  适合:聊天机器人 / Copilot      │
│       仪表盘构建器   │       教育助手 / 智能客服        │
│       表单生成器     │                                │
├────────────────────┼────────────────────────────────┤
│  catalog.prompt()   │  catalog.prompt({mode:"inline"})│
│  useUIStream        │  pipeJsonRender + useChat       │
└────────────────────┴────────────────────────────────┘

十二、设计哲学总结

┌──────────────────────────────────────────────────────────────┐
│                    json-render 设计原则                        │
├──────────────┬───────────────────────────────────────────────┤
│  数据非代码   │ AI 生成 JSON 而非可执行代码,消除安全风险        │
│  契约优先    │ Catalog = AI 与应用之间的严格契约,              │
│             │ Zod schema 保证编译时 + 运行时双重类型安全        │
│  渐进增强    │ 从最简单的静态渲染开始,逐步加入数据绑定、        │
│             │ 条件可见性、动作处理、表单验证等能力              │
│  平台无关核心 │ core 包含所有共享逻辑(表达式解析、可见性        │
│             │ 求值、状态管理、流编译),渲染器只做组件映射       │
│  声明式交互   │ AI 声明意图(action 名称),开发者提供实现,     │
│             │ 永远不会有未经授权的代码执行                     │
└──────────────┴───────────────────────────────────────────────┘

十三、完整实战:从零搭建一个 AI Dashboard Builder

下面把所有知识串起来,用一个完整示例展示 json-render 在真实项目中的全貌。

13.1 项目结构

my-dashboard/
├── app/
│   ├── api/generate/route.ts    ← AI 生成接口
│   └── page.tsx                 ← 前端页面
├── lib/
│   ├── catalog.ts               ← 组件目录定义
│   └── registry.tsx             ← 组件实现 + 动作处理
└── package.json

13.2 Catalog:定义 AI 的"工具箱"

// lib/catalog.ts
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
import { shadcnComponentDefinitions } from '@json-render/shadcn/catalog';
import { z } from 'zod';

export const catalog = defineCatalog(schema, {
  components: {
    // 布局类
    Card:    shadcnComponentDefinitions.Card,
    Stack:   shadcnComponentDefinitions.Stack,
    Grid:    shadcnComponentDefinitions.Grid,
    // 展示类
    Heading: shadcnComponentDefinitions.Heading,
    Text:    shadcnComponentDefinitions.Text,
    Badge:   shadcnComponentDefinitions.Badge,
    Table:   shadcnComponentDefinitions.Table,
    // 图表类
    BarGraph:  shadcnComponentDefinitions.BarGraph,
    LineGraph: shadcnComponentDefinitions.LineGraph,
    // 交互类
    Button:  shadcnComponentDefinitions.Button,
    Input:   shadcnComponentDefinitions.Input,
    Select:  shadcnComponentDefinitions.Select,
    // 反馈类
    Alert:   shadcnComponentDefinitions.Alert,
    Progress: shadcnComponentDefinitions.Progress,
  },
  actions: {
    refresh_data: {
      params: z.object({ source: z.string() }),
      description: '刷新指定数据源',
    },
    export_report: {
      params: z.object({ format: z.enum(['csv', 'pdf']) }),
      description: '导出报告',
    },
  },
  functions: {
    formatCurrency: {
      description: '将数字格式化为货币',
    },
  },
});

13.3 Registry:组件实现 + 动作处理

// lib/registry.tsx
import { defineRegistry } from '@json-render/react';
import { shadcnComponents } from '@json-render/shadcn';
import { catalog } from './catalog';
import type { ComputedFunction } from '@json-render/core';

export const { registry, handlers } = defineRegistry(catalog, {
  components: {
    Card:      shadcnComponents.Card,
    Stack:     shadcnComponents.Stack,
    Grid:      shadcnComponents.Grid,
    Heading:   shadcnComponents.Heading,
    Text:      shadcnComponents.Text,
    Badge:     shadcnComponents.Badge,
    Table:     shadcnComponents.Table,
    BarGraph:  shadcnComponents.BarGraph,
    LineGraph: shadcnComponents.LineGraph,
    Button:    shadcnComponents.Button,
    Input:     shadcnComponents.Input,
    Select:    shadcnComponents.Select,
    Alert:     shadcnComponents.Alert,
    Progress:  shadcnComponents.Progress,
  },
  actions: {
    refresh_data: async (params, setState) => {
      const res = await fetch(`/api/data?source=${params.source}`);
      const data = await res.json();
      setState((prev) => ({ ...prev, [params.source]: data }));
    },
    export_report: async (params) => {
      const blob = await fetch(`/api/export?format=${params.format}`)
        .then(r => r.blob());
      const url = URL.createObjectURL(blob);
      window.open(url);
    },
  },
});

export const computedFunctions: Record<string, ComputedFunction> = {
  formatCurrency: (args) => {
    const value = Number(args.value ?? 0);
    return new Intl.NumberFormat('zh-CN', {
      style: 'currency',
      currency: 'CNY',
    }).format(value);
  },
};

13.4 API Route:对接 AI

// app/api/generate/route.ts
import { streamText } from 'ai';
import { catalog } from '@/lib/catalog';

export async function POST(req: Request) {
  const { prompt } = await req.json();

  const result = streamText({
    model: 'anthropic/claude-haiku-4.5',
    system: catalog.prompt({
      customRules: [
        '用 Card 作为每个独立区块的容器',
        '用 Grid 做多列布局,columns 根据内容数量合理选择',
        '数值指标使用 Text + Badge 组合展示',
        '始终提供 refresh_data 按钮让用户刷新数据',
      ],
    }),
    prompt,
  });

  return result.toTextStreamResponse();
}

13.5 前端页面:组装一切

// app/page.tsx
'use client';
import { useState } from 'react';
import {
  Renderer, JSONUIProvider, useUIStream,
} from '@json-render/react';
import { registry, handlers, computedFunctions } from '@/lib/registry';

export default function DashboardBuilder() {
  const [prompt, setPrompt] = useState('');
  const { spec, isStreaming, send, clear } = useUIStream({
    api: '/api/generate',
  });

  return (
    <div className="min-h-screen bg-gray-50">
      {/* 顶部输入栏 */}
      <header className="border-b bg-white px-6 py-4">
        <div className="max-w-4xl mx-auto flex gap-3">
          <input
            value={prompt}
            onChange={(e) => setPrompt(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === 'Enter' && !isStreaming) {
                send(prompt);
                setPrompt('');
              }
            }}
            placeholder="描述你想要的仪表盘,比如:创建一个电商销售数据看板..."
            className="flex-1 border rounded-lg px-4 py-2"
          />
          <button
            onClick={() => { send(prompt); setPrompt(''); }}
            disabled={isStreaming || !prompt.trim()}
            className="px-6 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
          >
            {isStreaming ? '生成中...' : '生成'}
          </button>
          <button onClick={clear} className="px-4 py-2 border rounded-lg">
            重置
          </button>
        </div>
      </header>

      {/* 渲染区域 */}
      <main className="max-w-6xl mx-auto p-6">
        <JSONUIProvider
          registry={registry}
          initialState={spec?.state ?? {}}
          handlers={handlers}
          functions={computedFunctions}
        >
          <Renderer spec={spec} registry={registry} loading={isStreaming} />
        </JSONUIProvider>
      </main>
    </div>
  );
}

13.6 效果:用户输入 → AI 生成 → 即时渲染

用户输入: "创建一个电商销售数据看板,包含总收入、订单量、转化率,
          以及最近7天的销售趋势图和热销商品排行表"

AI 逐行输出 JSONL patch:
  → /root = "dashboard"
  → /elements/dashboard = Grid(columns:2) [metrics, chart, table]
  → /elements/metrics = Stack [revenue, orders, conversion]
  → /elements/revenue = Card > Text "总收入 ¥128,450"
  → /elements/orders = Card > Text "订单量 1,234"       ← 每行到达,UI 多一块
  → /elements/conversion = Card > Badge "转化率 3.2%"
  → /elements/chart = Card > LineGraph(7天趋势)
  → /elements/table = Card > Table(热销商品)
  → /elements/refresh = Button "刷新数据"

整个过程:用户看到界面在屏幕上一块一块地"生长"出来

十四、与其他方案的对比

┌────────────────┬───────────────┬──────────────┬──────────────┐
│                │  json-render  │ AI 生成代码   │  AI 填充数据  │
│                │  (Generative  │ (v0/Bolt     │  (传统方式)    │
│                │   UI)         │  等)          │              │
├────────────────┼───────────────┼──────────────┼──────────────┤
│ AI 生成的是什么 │ JSON 数据     │ 源代码       │ 文本/数据     │
│ 运行时安全     │ ✅ 无代码执行  │ ❌ 需沙箱    │ ✅ 安全       │
│ 实时流式渲染   │ ✅ 逐行渲染   │ ❌ 整体编译  │ N/A          │
│ UI 可变性      │ ✅ 每次不同   │ ✅ 每次不同  │ ❌ 固定布局   │
│ 跨平台         │ ✅ 12+ 渲染器 │ ❌ 单平台    │ ❌ 单平台     │
│ 类型安全       │ ✅ Zod + TS   │ ⚠️ 不确定    │ ✅ 可控       │
│ 适合场景       │ 运行时动态UI  │ 开发时生成   │ 数据展示      │
└────────────────┴───────────────┴──────────────┴──────────────┘

json-render 的定位是运行时的 Generative UI——界面在用户使用过程中由 AI 实时生成,而不是在开发阶段生成代码。这与 v0 等代码生成工具互补而非竞争。


十五、适用场景速查

 非常适合:
   AI 聊天机器人需要展示丰富 UI(不只是文字)
   动态仪表盘 / 数据看板生成器
   表单生成器(AI 根据需求自动构建表单)
   CMS 后台(JSON 驱动的页面渲染)
   多端统一(同一份 Spec 驱动 Web + Mobile + PDF + Email)

⚠️ 需要评估:
   高度定制化的交互(复杂拖拽画布编辑器等)
   性能极致敏感的场景(每次渲染都经过表达式解析层)

 不太适合:
   完全静态的不需要动态生成的页面
   需要像素级精确控制的设计稿还原

结语

json-render 代表了一种有趣的范式转移:从"AI 辅助开发者写代码"到"AI 直接为用户生成界面"。它的核心智慧在于找到了一个平衡点——让 AI 拥有足够的创造自由(可以自由组合组件、选择布局、绑定数据),同时保持绝对的安全边界(只能用你定义的组件、只能触发你实现的动作)。

如果你正在构建 AI 驱动的产品,json-render 至少值得你花一个下午深入了解。从一个简单的 Renderer + 手写 Spec 开始,逐步加入 AI 生成和流式渲染,你会发现这套"JSON 驱动 UI"的思路打开了一个全新的产品设计空间。

🔗 GitHub: github.com/vercel-labs… 🔗 官方文档: json-render.dev 📦 核心安装: npm install @json-render/core @json-render/react 📦 快速体验: npm install @json-render/shadcn(36 个预构建组件)

PageAgent-住在网页里的 AI 操控员

一、从一个问题说起:为什么需要"页面内"的 AI Agent?

过去两年,浏览器自动化领域热闹非凡。browser-use、Playwright MCP、各类 Headless 方案层出不穷,但它们都有一个共同特征——需要一个"外部大脑":Python 后端、无头浏览器实例、或浏览器扩展的特殊权限。

阿里巴巴开源的 PageAgent 提出了一个极为简洁的逆向思路:不从外部操控浏览器,让 AI Agent 直接"住在"网页里。 一行 <script> 标签,Agent 就在当前页面的 JavaScript 上下文中运行——不要 Python,不要无头浏览器,不要截图和多模态模型,甚至不要浏览器扩展。

下面这张图能直观地感受到区别:

┌─────────────────────────────────────────────────────────────────┐
│                    传统方案 vs PageAgent                         │
├─────────────────────────────┬───────────────────────────────────┤
│  browser-use / Playwright   │         PageAgent                 │
│                             │                                   │
│  ┌───────────┐              │  ┌─────────────────────────────┐  │
│  │ Python    │──WebSocket──▶│  │         你的网页              │  │
│  │ 后端服务   │  / CDP      │  │  ┌─────────────────────┐    │  │
│  └───────────┘              │  │  │  PageAgent (JS)     │    │  │
│       │                     │  │  │  ┌───────┐ ┌──────┐ │    │  │
│       ▼                     │  │  │  │ Agent │→│ DOM  │ │    │  │
│  ┌───────────┐              │  │  │  │ 循环  │ │ 操控 │ │    │  │
│  │ Headless  │              │  │  │  └───┬───┘ └──────┘ │    │  │
│  │ Browser   │              │  │  │      │  ↕ LLM API   │    │  │
│  └───────────┘              │  │  └──────┼──────────────┘    │  │
│                             │  └─────────┼───────────────────┘  │
│  需要: Python + 无头浏览器    │  只需: 一行 <script> 标签          │
└─────────────────────────────┴───────────────────────────────────┘

这篇文章将从最简单的用法出发,逐层深入到源码架构的核心设计,配有丰富示例和图解,帮你完整理解 PageAgent 的工作原理。


二、实战示例:从入门到高级

🟢 入门级:一行代码,5 秒体验

如果你只想快速感受效果,把下面这行代码贴到任意网页的控制台或 HTML 里:

<script src="https://cdn.jsdelivr.net/npm/page-agent@1.5.9/dist/iife/page-agent.demo.js" crossorigin="true"></script>

页面右下角会出现一个对话面板,输入自然语言指令即可操作页面。这个 Demo CDN 自带免费测试 LLM,开箱即用。

🟡 进阶级:NPM 集成 + 自选模型

实际项目中,你需要接入自己的 LLM:

import { PageAgent } from 'page-agent'

const agent = new PageAgent({
  model: 'qwen3.5-plus',
  baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
  apiKey: 'YOUR_API_KEY',
  language: 'zh-CN',
})

// 方式一:程序化执行
const result = await agent.execute('在搜索框输入 "iPhone 16",然后点击搜索按钮')
console.log(result.success)  // true / false
console.log(result.data)     // Agent 的执行总结

// 方式二:弹出对话面板,让用户自行输入
agent.panel.show()

支持的模型非常丰富——OpenAI GPT 系列、Claude、Qwen、DeepSeek、Gemini、Grok、MiniMax、Kimi、GLM,甚至通过 Ollama 本地部署的开源模型都可以。只要兼容 OpenAI 的 /chat/completions 接口即可。

🟡 进阶级:知识注入——让 AI 懂你的业务

裸用 Agent 时,它只知道页面上有什么元素,但不了解你的业务规则。通过 instructions 你可以注入领域知识:

const agent = new PageAgent({
  // ...LLM config
  instructions: {
    // 全局指令:所有页面生效
    system: `
      你是一个专业的电商运营助手。
      规则:
      - 提交订单前必须先确认价格和数量
      - 遇到错误时立即停止,不要盲目重试
      - 优先使用筛选器缩小搜索范围
    `,
    // 页面级指令:根据 URL 动态返回
    getPageInstructions: (url) => {
      if (url.includes('/checkout')) {
        return '这是结算页面。请先核对收货地址,再检查是否有优惠券可用。'
      }
      if (url.includes('/products')) {
        return '这是商品列表页。先使用左侧筛选器缩小范围,再帮用户选择商品。'
      }
      return undefined
    }
  }
})

指令的工作方式如下图所示:

每一步执行前,prompt 的组装结构:

┌────────────────────────────────────────┐
│  <instructions>                        │
│    <system_instructions>               │
│      你是电商运营助手...                  │
│    </system_instructions>              │
│    <page_instructions>                 │  ← 仅当 URL 匹配时才出现
│      这是结算页面...                     │
│    </page_instructions>                │
│  </instructions>                       │
│                                        │
│  <agent_state>                         │
│    用户请求 + 步数信息                    │
│  </agent_state>                        │
│                                        │
│  <agent_history>                       │
│    之前每步的反思 + 动作结果               │
│  </agent_history>                      │
│                                        │
│  <browser_state>                       │
│    当前页面 URL、可交互元素、滚动位置       │
│  </browser_state>                      │
└────────────────────────────────────────┘

🟡 进阶级:数据脱敏——敏感信息不出页面

在把页面内容发送给 LLM 之前,transformPageContent 钩子允许你过滤敏感数据:

const agent = new PageAgent({
  // ...LLM config
  transformPageContent: async (content) => {
    // 手机号脱敏:138****1234
    content = content.replace(/\b(1[3-9]\d)(\d{4})(\d{4})\b/g, '$1****$3')
    // 邮箱脱敏
    content = content.replace(
      /\b([a-zA-Z0-9._%+-])[^@]*(@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\b/g,
      '$1***$2'
    )
    // 银行卡号脱敏
    content = content.replace(/\b(\d{4})\d{8,11}(\d{4})\b/g, '$1********$2')
    return content
  }
})

LLM 看到的是脱敏后的内容,但页面上的真实数据不受影响,Agent 的操作仍然作用于原始 DOM 元素。

🔴 高级:自定义工具——给 AI 接上后端 API

内置工具只能操作 DOM,但通过 customTools 你可以让 Agent 调用任意业务接口:

import { z } from 'zod/v4'
import { PageAgent, tool } from 'page-agent'

const agent = new PageAgent({
  // ...LLM config
  customTools: {
    // 添加购物车工具:AI 可以直接调 API 而非点按钮
    add_to_cart: tool({
      description: '通过商品 ID 添加到购物车',
      inputSchema: z.object({
        productId: z.string(),
        quantity: z.number().min(1).default(1),
      }),
      execute: async function (input) {
        await fetch('/api/cart', {
          method: 'POST',
          body: JSON.stringify(input),
        })
        return `✅ 已添加 ${input.quantity}${input.productId} 到购物车`
      },
    }),

    // 搜索知识库工具:让 AI 先查资料再操作
    search_kb: tool({
      description: '搜索内部知识库',
      inputSchema: z.object({
        query: z.string(),
        limit: z.number().max(10).default(3),
      }),
      execute: async function (input) {
        const res = await fetch(`/api/kb?q=${encodeURIComponent(input.query)}&limit=${input.limit}`)
        return JSON.stringify(await res.json())
      },
    }),

    // 移除内置工具:比如禁止 AI 向用户提问
    ask_user: null,
  },
})

🔴 高级:完全自定义 UI(React 示例)

不想用内置面板?核心逻辑和 UI 完全解耦,你可以用 React/Vue/任何框架搭建自己的界面:

import { PageAgentCore } from '@page-agent/core'
import { PageController } from '@page-agent/page-controller'
import { useState, useEffect } from 'react'

// 1. 自定义 React Hook 监听 Agent 事件
function useAgent(agent) {
  const [status, setStatus] = useState(agent.status)
  const [history, setHistory] = useState(agent.history)
  const [activity, setActivity] = useState(null)

  useEffect(() => {
    const onStatus = () => setStatus(agent.status)
    const onHistory = () => setHistory([...agent.history])
    const onActivity = (e) => setActivity(e.detail)

    agent.addEventListener('statuschange', onStatus)
    agent.addEventListener('historychange', onHistory)
    agent.addEventListener('activity', onActivity)
    return () => {
      agent.removeEventListener('statuschange', onStatus)
      agent.removeEventListener('historychange', onHistory)
      agent.removeEventListener('activity', onActivity)
    }
  }, [agent])

  return { status, history, activity }
}

// 2. 创建无 UI 的 Core Agent
const agent = new PageAgentCore({
  pageController: new PageController({ enableMask: true }),
  baseURL: 'https://api.openai.com/v1',
  apiKey: 'your-key',
  model: 'gpt-5.1',
})

// 3. 你的自定义 UI 组件
function MyAgentPanel() {
  const { status, history, activity } = useAgent(agent)

  return (
    <div className="my-agent-ui">
      <div>状态: {status}</div>
      {activity?.type === 'thinking' && <div>🧠 思考中...</div>}
      {activity?.type === 'executing' && <div>⚡ 执行: {activity.tool}</div>}
      {history.filter(e => e.type === 'step').map((step, i) => (
        <div key={i}>步骤 {i+1}: {step.action.name} → {step.action.output}</div>
      ))}
    </div>
  )
}

🔴 高级:对接外部 Agent 系统

把 PageAgent 作为工具注册到你现有的 AI 客服/助手系统中:

// 你的主 Agent 系统中
const pageAgentTool = {
  name: 'operate_webpage',
  description: '在当前网页上执行操作,如点击、填写表单、查询信息',
  parameters: {
    type: 'object',
    properties: {
      instruction: { type: 'string', description: '操作指令' }
    },
    required: ['instruction']
  },
  execute: async (params) => {
    const result = await pageAgent.execute(params.instruction)
    return { success: result.success, message: result.data }
  }
}

// 注册到你的 Agent 框架...

这样你的客服机器人就不再只会说"请点击左上角的设置按钮",而是直接帮用户操作。


三、Monorepo 架构全景图

PageAgent 采用 monorepo 结构,packages/ 下 7 个子包分层清晰:

┌─────────────────────────────────────────────────────────────┐
│                      用户代码                                │
│              import { PageAgent } from 'page-agent'         │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│    📦 page-agent (门面层,28行代码)                          │
│    组装 Core + PageController + UI Panel                     │
└───────┬───────────────────┬──────────────────┬──────────────┘
        │                   │                  │
        ▼                   ▼                  ▼
┌──────────────┐  ┌──────────────────┐  ┌────────────┐
│  📦 core     │  │  📦 page-        │  │  📦 ui     │
│  Agent 循环   │  │   controller     │  │  交互面板   │
│  提示词工程   │  │  DOM 提取与简化   │  │            │
│  工具系统     │  │  元素动作模拟     │  │            │
│  AutoFixer   │  │  遮罩层管理       │  │            │
└──────┬───────┘  └──────────────────┘  └────────────┘
       │
       ▼
┌──────────────┐
│  📦 llms     │     📦 extension (可选)
│  OpenAI 协议  │     Chrome 扩展,多标签页
│  模型补丁     │
│  重试机制     │     📦 website
└──────────────┘     官方文档站

核心设计原则:core 不依赖 uipage-controller 不依赖 core,任何一层都可以独立替换。想换 UI?用 PageAgentCore 监听事件自己画。想换 DOM 操作方式?实现 PageController 接口即可。


四、核心引擎:Re-Act Agent 循环

PageAgent 的灵魂在 PageAgentCore 类中。它实现了经典的 Re-Act(Reasoning + Acting)循环

4.1 一次任务的完整生命周期

agent.execute("填写上周五出差的报销单")
         │
         ▼
┌─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
│          while (step < maxSteps)                          │
│                                                           │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐            │
│  │ 1.Observe│───▶│ 2.Think  │───▶│ 3.Act    │──┐         │
│  │ 观察页面  │    │ LLM 推理  │    │ 执行动作  │  │         │
│  └──────────┘    └──────────┘    └──────────┘  │         │
│       ▲                                        │         │
│       │           ┌──────────┐                 │         │
│       └───────────│ 4.Record │◀────────────────┘         │
│                   │ 记录历史  │                            │
│                   └──────────┘                            │
│                        │                                  │
│               action == 'done'?                           │
│                 ├── Yes → 返回结果                          │
│                 └── No  → 继续循环                          │
└─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘

第一阶段 ObservePageController.getBrowserState() 扫描 DOM 树,提取所有可交互元素并编号索引,输出一份 LLM 可读的简化文本。同时进行环境感知——URL 是否变化?累计等待时间是否过长?剩余步数是否告急?这些观察被推入历史流。

第二阶段 Think:系统提示词 + 用户提示词 + 浏览器状态 + 完整历史事件被一起发送给 LLM。这里有一个核心设计——MacroTool(详见下节)。

第三阶段 Act:从 LLM 输出中解析出动作名和参数,通过 PageController 在页面上执行真实的 DOM 操作。

第四阶段 Record:执行结果、LLM 的反思内容、token 用量等打包成 AgentStepEvent 推入历史数组,下一轮循环时回传给 LLM 形成连续记忆。

循环终止条件有三个:LLM 调用 done(任务完成)、步数超过 maxSteps(默认 40)、或不可恢复错误。

4.2 MacroTool:强制"先想后做"

传统方案让 LLM 从多个工具中自由选择。PageAgent 走了一条不同的路——把所有工具合并成一个叫 AgentOutput 的巨型工具:

┌────────────────────────────────────────────────┐
│              MacroTool: AgentOutput             │
│                                                │
│  {                                             │
│    evaluation_previous_goal: "上一步成功了...",   │  ← 反思
│    memory: "已找到搜索框,index=5...",            │  ← 记忆
│    next_goal: "在搜索框输入关键词",               │  ← 规划
│    action: {                                   │
│      input_text: { index: 5, text: "iPhone" }  │  ← 动作
│    }                 ▲                         │
│  }                   │                         │
│                      │                         │
│       action 字段是所有内置工具的联合类型:          │
│       click_element_by_index | input_text |     │
│       scroll | select_dropdown_option |         │
│       wait | done | ask_user | ...              │
└────────────────────────────────────────────────┘

源码中用 Zod 的 z.union 将所有工具的 inputSchema 合并成 action 字段的类型。LLM 每次调用 AgentOutput 时,必须同时输出反思和具体动作。这种设计大幅减少了"冲动行为"——Agent 不会跳过思考直接行动。

4.3 两条事件流:记忆 vs 反馈

┌───────────────────────────────────────────────────────┐
│                  PageAgentCore                        │
│                                                       │
│   Historical Events                Activity Events    │
│   (historychange)                   (activity)        │
│                                                       │
│   ┌─────────────┐                ┌─────────────┐     │
│   │ step        │                │ thinking    │     │
│   │ observation  │                │ executing   │     │
│   │ user_takeover│                │ executed    │     │
│   │ retry       │                │ retrying    │     │
│   │ error       │                │ error       │     │
│   └──────┬──────┘                └──────┬──────┘     │
│          │                              │             │
│    持久化 │ 传给 LLM               瞬态 │ 仅 UI 用     │
│          ▼                              ▼             │
│   agent.history[]               UI 状态动画/loading    │
└───────────────────────────────────────────────────────┘

History Events 构成 Agent 的"记忆",每轮都发送给 LLM。Activity Events 是瞬态 UI 反馈("正在思考"/"正在点击按钮"),不进入 LLM 上下文。这种分离保证了 LLM 的上下文始终干净。


五、DOM 翻译官:不靠截图的页面理解

5.1 纯文本路线 vs 截图路线

截图路线 (Claude Computer Use 等)        文本路线 (PageAgent)
                                        
  页面 → 截图 → 多模态LLM               页面 → DOM树 → 简化文本 → 文本LLM
                                        
  ✓ 能看到图片/Canvas                    ✗ 看不到图片/Canvas
  ✗ 需要多模态模型                       ✓ 普通文本模型即可
  ✗ 截图=更多token≈更贵                   ✓ token 更少更便宜
  ✗ 需要特殊权限                         ✓ 零权限

对于大多数 SaaS 后台、表单填写、数据录入场景,文本路线是极为务实的选择。

5.2 DOM 提取:从真实页面到 LLM 可读文本

PageController.getBrowserState() 是整条链路的入口。它的内部流程:

真实 DOM 树
    │
    ▼  getFlatTree()
遍历 DOM,识别可交互元素,分配数字索引
标记新出现的元素 (WeakMap 缓存)
    │
    ▼  flatTreeToString()
转换为 LLM 友好的文本格式
    │
    ▼  组装 BrowserState
    
  header: "Current Page: [商品列表](https://...)
           Page info: 1920x1080px viewport...
           [Start of page]"

  content: "[0]<a aria-label=首页 />
            [1]<input placeholder=搜索商品... />
            [2]<button>搜索</button>
            今日推荐
            *[3]<div>iPhone 16 Pro ¥7999</div>     ← * 号表示新出现
            *[4]<button>加入购物车</button>
            [5]<select>选择颜色</select>"

  footer: "... 1200 pixels below (2.3 pages) - scroll to see more ..."

flatTreeToString 做了大量优化细节:去除重复属性(aria-label 与文本内容相同时只保留一个)、截断过长属性、标注可滚动容器的滚动距离、缩进表示 DOM 层级关系。

5.3 动作模拟:为什么不用 .click()

简单调用 element.click() 在很多前端框架中不能正确触发事件。PageAgent 的 clickElement 模拟了完整的用户行为链:

clickElement(element) 的执行序列:

  scrollIntoView    ← 确保元素可见
       ↓
  movePointerTo     ← 移动指针到元素中心(触发UI动画)
       ↓
  mouseenter        ← 模拟鼠标进入
  mouseover
       ↓
  mousedown         ← 模拟按下
       ↓
  focus             ← 聚焦(确保 React 等框架的事件能触发)
       ↓
  mouseup           ← 模拟释放
       ↓
  click             ← 最终点击事件

文本输入更复杂——对 contenteditable 富文本编辑器,按顺序派发 beforeinput(清空)→ 修改 innerText → 派发 input(插入),以兼容 React 受控组件和 Quill 等编辑器。对普通 input/textarea,则使用原生 value setter 绕过框架拦截,再手动触发 input 事件。


六、LLM 层:兼容万家,容错为先

6.1 OpenAI 兼容协议统一天下

@page-agent/llms 没引入任何 LLM SDK,直接用 fetch 调 /chat/completions 接口。如今几乎所有主流模型商都支持这套协议,因此 PageAgent 天然兼容数十种模型。

6.2 模型补丁:实战踩坑的结晶

源码中的 modelPatch 函数根据模型名称动态调整请求参数:

模型                    补丁内容
─────────────────────────────────────────────────
Qwen 系列      →  temperature ≥ 1.0,关闭 thinking
Claude 系列    →  tool_choice 格式转换为 Claude 风格
Grok 系列      →  删除 tool_choice,禁用 reasoning
GPT-5 系列     →  reasoning_effort = 'low'
GPT-5-mini     →  reasoning_effort = 'low', temperature = 1
Gemini 系列    →  reasoning_effort = 'minimal'
MiniMax 系列   →  temperature 钳位到 (0, 1],删除 parallel_tool_calls

这些全是真实环境下踩坑后的总结,对多模型兼容开发极有参考价值。

6.3 AutoFixer:当 LLM 不守规矩时

不同 LLM 的输出格式千差万别。normalizeResponse 穷举了各种异常并逐一修复:

LLM 的常见"不规矩"输出              AutoFixer 的修复

把 JSON 放在 content 里              → 提取 JSON,包装成 tool_calls
而不是 tool_calls

返回动作层级而非                     → 包装一层 { action: ... }
AgentOutput 完整结构

双重 JSON 字符串化                    → 递归 JSON.parse
"{ \"action\": \"...\" }"

原始值输入                            → 根据 Zod schema 推断字段名
{ click_element_by_index: 2 }         → { click_element_by_index: { index: 2 } }

content 里还套了一层                   → 解析嵌套的 function 结构
function wrapper

这套容错机制是 PageAgent 能稳定兼容这么多模型的关键原因之一。


七、提示词工程:Agent 的"岗位说明书"

系统提示词(system_prompt.md)详细规定了 Agent 的输入格式、行为准则和能力边界。几个值得注意的设计:

输入格式约定:交互元素的格式是 [index]<type>text</type>,只有带数字索引的元素才可操作。新出现的元素用 *[ 标记。缩进表示 DOM 层级。

行为规则亮点:不要重复同一动作超过 3 次;输入文本后如果被中断,很可能弹出了建议列表(要去选择);遇到验证码告知用户无法解决;区分"精确步骤"和"开放式任务"两种模式。

"示弱"设计——这是最有意思的部分:明确告知 LLM "可以失败"、"用户可能是错的"、"网页可能有 bug"、"过度尝试可能有害"。避免 Agent 在无法完成任务时陷入无意义的死循环。


八、生命周期钩子:完整的可观测性

onBeforeTask ──▶ ┌───────────────────────────────┐
                 │  onBeforeStep ──▶ step ──▶ onAfterStep  │  × N 步
                 └───────────────────────────────┘
onAfterTask  ◀── 返回 ExecutionResult { success, data, history }

onDispose    ◀── agent.dispose()

配合 transformPageContent(数据脱敏)和 customSystemPrompt(完全自定义提示词),开发者拥有对 Agent 行为的完全控制权。


九、使用限制:诚实面对能力边界

PageAgent 选择了"纯文本 DOM"路线,这意味着:

能做的:点击、文本输入、下拉选择、表单提交、页面滚动、焦点切换、执行 JavaScript。

做不到的:悬停(hover)、拖拽、右键菜单、键盘快捷键、坐标定位操作、图片/Canvas/WebGL/SVG 等视觉内容识别、Monaco/CodeMirror 等特殊编辑器。

语义化的 HTML 和良好的可访问性(ARIA 标签等)会显著提升 Agent 效果。反常识的交互逻辑、纯视觉的操作提示则会降低成功率。


十、总结:一个务实的工程决策

通读源码后,PageAgent 的核心设计哲学可以归纳为三个词:

务实——纯文本 DOM 而非截图,牺牲视觉理解换来对普通模型的兼容性和更低的成本。MacroTool 强制"先想后做",在可控性和灵活性之间找到平衡。

容错——从 AutoFixer 对畸形输出的修复,到 modelPatch 对不同模型的适配,到提示词中鼓励"可以失败",整个系统对不确定性有很高包容度。

解耦——Core、PageController、UI、LLMs 四层分明,任何一层可独立替换。你可以只用 Core 做无头自动化,也可以换上自己的 React UI,还可以把它嵌入你现有的 Agent 系统作为"手和眼"。

对于 SaaS 开发者想快速给产品加 AI Copilot、企业想做管理后台的智能化改造、或者无障碍增强场景,PageAgent 提供了目前门槛最低的入口——一行 <script> 标签,你的网页就有了一个 AI 操作员。

A2UI 深度解读:让 AI Agent "说出"用户界面的开放协议

引言:Agent 时代的 UI 困境

想象这样一个场景——你对一个 AI 助手说:"帮我订一张明天晚上 7 点的两人桌。" 如果 Agent 只能回复文本,接下来将是:

用户: "帮我订一张明天晚上7点的两人桌"
Agent: "好的,请问几位用餐?"
用户: "两位"
Agent: "请问哪天?"
用户: "明天"
Agent: "什么时间?"
用户: "晚上7点"
Agent: "有什么忌口吗?"
...(五六个回合后终于订完)

更好的方式是:Agent 直接生成一个表单——日期选择器、时间选择器、人数输入框、提交按钮,一步搞定。但传统方案(Agent 返回 HTML/JS 塞进 iframe)笨重、割裂、不安全。

A2UI(Agent-to-User Interface) 就是为此而生的 Google 开源协议:Agent 发送声明式 JSON 描述界面意图,客户端用自己的原生组件渲染。安全如数据,表达如代码。


一、A2UI 全景架构图

先来一张图看全貌——A2UI 的核心是把 UI 生成和 UI 执行彻底解耦:

┌──────────────────────────────────────────────────────────────┐
│                        用户 (User)                           │
│    输入:"帮我找纽约的中餐馆"    │    看到原生渲染的卡片列表    │
└───────────────┬──────────────────────────────▲───────────────┘
                │ 文字请求                      │ 原生 UI
                ▼                              │
┌───────────────────────────────────────────────────────────────┐
│                   客户端应用 (Client App)                      │
│  ┌─────────────┐   ┌──────────────┐   ┌──────────────────┐   │
│  │  传输层      │   │ A2UI 渲染器   │   │  组件目录         │   │
│  │  (Transport) │──▶│  (Renderer)  │◀──│  (Catalog)       │   │
│  │  A2A/WS/SSE │   │  Lit/Angular │   │  Button, Card... │   │
│  └──────┬──────┘   │  /Flutter    │   └──────────────────┘   │
│         │          └──────────────┘                           │
└─────────┼────────────────────────────────────────────────────┘
          │ JSON 消息流 (JSONL)
          │
┌─────────▼─────────────────────────────────────────────────────┐
│                     AI Agent (后端)                             │
│  ┌───────────────┐    ┌──────────────────┐                     │
│  │  业务逻辑      │───▶│  A2UI 生成器      │                     │
│  │  (Tools/API)  │    │  (LLM 生成 JSON) │                     │
│  └───────────────┘    └──────────────────┘                     │
│                              │                                 │
│                     ┌────────▼────────┐                        │
│                     │   Gemini / GPT  │                        │
│                     │   等 LLM 模型    │                        │
│                     └─────────────────┘                        │
└────────────────────────────────────────────────────────────────┘

关键洞察:Agent 永远不会执行代码或操控 DOM。它只能从客户端预批准的"组件目录"中选取组件来组合界面——就像只能用菜单上的菜来点餐,不能自己跑进厨房。


二、三分钟理解核心概念

2.1 五个关键词

┌─────────────────────────────────────────────────────────────┐
│                    A2UI 五大核心概念                          │
├─────────────┬───────────────────────────────────────────────┤
│  Surface    │ 画布/容器,承载一组组件(如一个表单、一个卡片)  │
│  Component  │ UI 元素(Button, Text, Card, TextField...)    │
│  Data Model │ 应用状态,组件通过路径绑定到它                  │
│  Catalog    │ 组件目录,定义 Agent 能用哪些组件               │
│  Message    │ JSON 消息(创建画布/更新组件/更新数据/删除画布) │
└─────────────┴───────────────────────────────────────────────┘

2.2 邻接表模型:为什么是扁平列表而非嵌套树?

这是 A2UI 最独特的设计。传统 UI 描述用嵌套 JSON 树,但 LLM 生成深层嵌套时极易出错、难以流式传输。A2UI 把组件展平为一个列表,通过 ID 引用建立父子关系:

传统嵌套树(LLM 容易搞乱括号)        A2UI 邻接表(扁平 + ID 引用)
─────────────────────────            ──────────────────────────
{                                    components: [
  "Column": {                          { id: "root",    → Column, children: ["title","btn"] },
    "children": [                      { id: "title",   → Text, text: "Hello" },
      { "Text": { "Hello" } },        { id: "btn",     → Button, child: "btn-text" },
      { "Button": {                    { id: "btn-text",→ Text, text: "OK" }
        "child": {                   ]
          "Text": { "OK" }
        }
      }}
    ]
  }
}

层层嵌套,一个括号没对上就全废了         所有组件平铺,随时增量发送、按 ID 更新

2.3 数据绑定:结构与状态分离

组件定义"长什么样",数据模型定义"展示什么内容"。两者通过 JSON Pointer 路径连接:

         组件结构                              数据模型
    ┌──────────────┐                    ┌──────────────────┐
    │ Text          │                   │ {                │
    │ text: ────────┼───path───────────▶│   "user": {      │
    │   path:       │  "/user/name""name":"Alice"│
    │   "/user/name"│                   │   }              │
    └──────────────┘                    └──────────────────┘
                                              │
    当数据模型更新为 "Bob" 时 ──────────────────┘
    Text 自动显示 "Bob",无需重发组件定义!

三、消息生命周期图解

以一个完整的餐厅预订流程为例,看 A2UI 消息如何流转:

 用户                        客户端                         Agent
  │                            │                              │
  │  "订两人桌"                 │                              │
  │ ──────────────────────────▶│                              │
  │                            │  将用户消息转发给 Agent        │
  │                            │ ─────────────────────────────▶│
  │                            │                              │
  │                            │   ① createSurface            │
  │                            │◀─ (创建画布,指定 Catalog)──── │
  │                            │                              │
  │                            │   ② updateComponents         │
  │                            │◀─ (标题+人数框+日期框+按钮)── │
  │  看到表单渐进式渲染          │                              │
  │◀───────────────────────── │   ③ updateDataModel           │
  │                            │◀─ (日期="明天", 人数="2") ──── │
  │                            │                              │
  │  修改人数为 "3"             │                              │
  │ ──────────────────────────▶│  本地数据模型自动更新           │
  │                            │  /reservation/guests = "3"   │
  │                            │                              │
  │  点击「确认预订」            │                              │
  │ ──────────────────────────▶│                              │
  │                            │   ④ action                   │
  │                            │ ─(name:"confirm",context)───▶│
  │                            │                              │
  │                            │   ⑤ deleteSurface            │
  │  看到"预订成功"确认界面      │◀─ + 新 surface (确认卡片) ── │
  │◀───────────────────────── │                              │

四、实战示例:由浅入深

🟢 入门级:Hello World — 一张静态信息卡

适合人群:想快速了解 A2UI JSON 长什么样的开发者

这是最简单的 A2UI 示例——展示一张带标题和描述的卡片,没有交互,没有数据绑定,纯静态内容。

// 消息 1:创建画布
{
  "version": "v0.9",
  "createSurface": {
    "surfaceId": "hello-card",
    "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
  }
}

// 消息 2:定义组件
{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "hello-card",
    "components": [
      {
        "id": "root",
        "component": "Card",
        "child": "content"
      },
      {
        "id": "content",
        "component": "Column",
        "children": ["title", "desc"]
      },
      {
        "id": "title",
        "component": "Text",
        "text": "👋 欢迎使用 A2UI",
        "variant": "h1"
      },
      {
        "id": "desc",
        "component": "Text",
        "text": "这是一张由 Agent 生成的卡片,渲染为你应用的原生组件。"
      }
    ]
  }
}

解读如下——整个过程只需两条消息。createSurface 告诉客户端"我要创建一个画布,用基础组件目录"。updateComponents 发送四个组件:Card 是容器,Column 纵向排列子组件,两个 Text 分别是标题和正文。所有组件平铺在一个列表里,通过 childchildren 引用彼此的 ID。

渲染效果示意:

┌──────────────────────────┐
│ ┌──────────────────────┐ │
│ │  👋 欢迎使用 A2UI     │ │   ← h1 标题
│ │                      │ │
│ │  这是一张由 Agent     │ │   ← 正文描述
│ │  生成的卡片...        │ │
│ └──────────────────────┘ │
└──────────────────────────┘
         Card 容器

🟡 进阶级:带数据绑定的用户资料卡

适合人群:需要理解数据绑定、响应式更新的前端/全栈开发者

这个示例展示数据绑定的核心能力——组件不写死内容,而是绑定到数据模型的路径。当数据变化时,UI 自动刷新。

// 消息 1:创建画布
{
  "version": "v0.9",
  "createSurface": {
    "surfaceId": "profile",
    "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
  }
}

// 消息 2:定义组件(结构)
{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "profile",
    "components": [
      {
        "id": "root",
        "component": "Card",
        "child": "layout"
      },
      {
        "id": "layout",
        "component": "Column",
        "children": ["avatar", "name", "email", "role"]
      },
      {
        "id": "avatar",
        "component": "Image",
        "url": { "path": "/user/avatar" },
        "fit": "cover"
      },
      {
        "id": "name",
        "component": "Text",
        "text": { "path": "/user/name" },
        "variant": "h2"
      },
      {
        "id": "email",
        "component": "Text",
        "text": { "path": "/user/email" }
      },
      {
        "id": "role",
        "component": "Text",
        "text": { "path": "/user/role" },
        "variant": "caption"
      }
    ]
  }
}

// 消息 3:填充数据
{
  "version": "v0.9",
  "updateDataModel": {
    "surfaceId": "profile",
    "path": "/user",
    "value": {
      "name": "Sarah Chen",
      "email": "sarah@techco.com",
      "role": "Product Designer",
      "avatar": "https://example.com/sarah.jpg"
    }
  }
}

关键点在于,组件中的 { "path": "/user/name" } 就是数据绑定语法。渲染器看到它会去数据模型中读取 /user/name 的值来显示。当 Agent 后续发送新的 updateDataModel/user/name 改成 "Bob Lee" 时,名字自动变化,不需要重新发送组件定义。这就是结构与状态分离带来的高效更新。

   组件定义(不变)                  数据模型(可随时更新)
┌──────────────────┐          ┌────────────────────────┐
│ Text              │          │ { "user": {            │
│   text:           │─bindTo──▶│     "name": "Sarah"    │──▶ 显示 "Sarah"path:/user/name│         │   }                    │
└──────────────────┘          └────────────────────────┘
                                       │ Agent 发送数据更新
                              ┌────────▼───────────────┐
                              │ { "user": {            │
                              │     "name": "Bob"      │──▶ 自动显示 "Bob"
                              │   }                    │
                              └────────────────────────┘

🟡 进阶级:带表单交互的餐厅预订

适合人群:需要理解双向绑定和 Action 机制的开发者

这是官方 Demo 的核心场景——Agent 生成一个预订表单,用户填写后提交,Agent 收到数据进行处理。

// 消息 1:创建画布
{
  "version": "v0.9",
  "createSurface": {
    "surfaceId": "booking",
    "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
  }
}

// 消息 2:定义表单组件
{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "booking",
    "components": [
      {
        "id": "root",
        "component": "Column",
        "children": ["title", "img", "party-size", "datetime", "dietary", "submit-btn"]
      },
      {
        "id": "title",
        "component": "Text",
        "text": { "path": "/title" },
        "variant": "h2"
      },
      {
        "id": "img",
        "component": "Image",
        "url": { "path": "/imageUrl" }
      },
      {
        "id": "party-size",
        "component": "TextField",
        "label": "用餐人数",
        "value": { "path": "/partySize" },
        "textFieldType": "number"
      },
      {
        "id": "datetime",
        "component": "DateTimeInput",
        "label": "日期和时间",
        "value": { "path": "/reservationTime" },
        "enableDate": true,
        "enableTime": true
      },
      {
        "id": "dietary",
        "component": "TextField",
        "label": "饮食要求",
        "value": { "path": "/dietary" }
      },
      {
        "id": "submit-btn",
        "component": "Button",
        "child": "submit-text",
        "variant": "primary",
        "action": {
          "event": {
            "name": "submit_booking",
            "context": {
              "restaurant": { "path": "/restaurantName" },
              "partySize":  { "path": "/partySize" },
              "time":       { "path": "/reservationTime" },
              "dietary":    { "path": "/dietary" }
            }
          }
        }
      },
      {
        "id": "submit-text",
        "component": "Text",
        "text": "确认预订"
      }
    ]
  }
}

// 消息 3:填充初始数据
{
  "version": "v0.9",
  "updateDataModel": {
    "surfaceId": "booking",
    "path": "/",
    "value": {
      "title": "预订 - 西安名吃",
      "restaurantName": "西安名吃",
      "imageUrl": "https://example.com/xian.jpg",
      "partySize": "2",
      "reservationTime": "",
      "dietary": ""
    }
  }
}

这里有三个关键交互机制值得注意。

双向绑定——TextField 的 value 绑定到 /partySize,用户输入 "4" 时,本地数据模型立即更新为 {"partySize": "4"},完全在客户端本地完成,没有网络请求。

Action 的 context——Button 的 action.event.context 定义了提交时要携带哪些数据。每个 key 的 value 用 path 指向数据模型,客户端在点击时解析出当前值。

当用户点击"确认预订",客户端发送的消息如下:

{
  "version": "v0.9",
  "action": {
    "name": "submit_booking",
    "surfaceId": "booking",
    "sourceComponentId": "submit-btn",
    "timestamp": "2026-03-18T19:30:00Z",
    "context": {
      "restaurant": "西安名吃",
      "partySize": "4",
      "time": "2026-03-19T19:00:00Z",
      "dietary": "不吃辣"
    }
  }
}

Agent 端 Python 处理代码类似:

if action_name == "submit_booking":
    restaurant = context.get("restaurant")
    party_size = context.get("partySize")
    time = context.get("time")
    # 让 LLM 处理
    query = f"用户预订了 {restaurant}{party_size} 人,时间 {time}"
    response = await llm.generate(query)

🔴 高级:动态列表 + 模板渲染

适合人群:需要高效渲染大量数据的架构师和高级开发者

当 Agent 返回一组搜索结果时,不需要为每条结果分别定义组件——用一个模板 + 数据数组即可自动渲染:

// 组件定义:一个模板驱动的列表
{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "search-results",
    "components": [
      {
        "id": "root",
        "component": "Column",
        "children": ["result-header", "result-list"]
      },
      {
        "id": "result-header",
        "component": "Text",
        "text": "为你找到以下餐厅:",
        "variant": "h2"
      },
      {
        "id": "result-list",
        "component": "List",
        "children": {
          "componentId": "restaurant-card",
          "path": "/restaurants"
        },
        "direction": "vertical"
      },
      {
        "id": "restaurant-card",
        "component": "Card",
        "child": "card-layout"
      },
      {
        "id": "card-layout",
        "component": "Row",
        "children": ["card-img", "card-info"]
      },
      {
        "id": "card-img",
        "component": "Image",
        "url": { "path": "/imageUrl" },
        "fit": "cover"
      },
      {
        "id": "card-info",
        "component": "Column",
        "children": ["card-name", "card-rating", "card-detail"]
      },
      {
        "id": "card-name",
        "component": "Text",
        "text": { "path": "/name" },
        "variant": "h3"
      },
      {
        "id": "card-rating",
        "component": "Text",
        "text": { "path": "/rating" },
        "variant": "caption"
      },
      {
        "id": "card-detail",
        "component": "Text",
        "text": { "path": "/detail" }
      }
    ]
  }
}

// 数据模型:一个数组,有多少项就渲染多少张卡片
{
  "version": "v0.9",
  "updateDataModel": {
    "surfaceId": "search-results",
    "path": "/restaurants",
    "value": [
      {
        "name": "西安名吃",
        "detail": "正宗手拉面,香辣可口",
        "rating": "★★★★☆",
        "imageUrl": "https://example.com/xian.jpg"
      },
      {
        "name": "韩朝",
        "detail": "地道四川菜",
        "rating": "★★★★☆",
        "imageUrl": "https://example.com/han.jpg"
      },
      {
        "name": "红农场",
        "detail": "现代中餐,农场直供",
        "rating": "★★★★☆",
        "imageUrl": "https://example.com/red.jpg"
      }
    ]
  }
}

核心原理是作用域路径。模板中的 { "path": "/name" } 不是指向全局根路径,而是自动限定到当前数组项。第一张卡片的 /name 解析为 /restaurants/0/name,即 "西安名吃";第二张解析为 /restaurants/1/name,即 "韩朝"。

 数据:/restaurants = [ {name:"西安名吃"}, {name:"韩朝"}, {name:"红农场"} ]
                          │                   │                │
 模板自动实例化            ▼                   ▼                ▼
 ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
 │ 🖼️ 西安名吃  │  │ 🖼️ 韩朝      │  │ 🖼️ 红农场    │
 │ ★★★★☆      │  │ ★★★★☆      │  │ ★★★★☆      │
 │ 正宗手拉面   │  │ 地道四川菜   │  │ 现代中餐     │
 └─────────────┘  └─────────────┘  └─────────────┘

 新增一项到数组 → 自动多渲染一张卡片,无需修改组件定义!

🔴 高级:多 Agent 编排(Orchestrator)

适合人群:构建企业级多 Agent 系统的架构师

在真实的企业场景中,一个主协调器(Orchestrator)管理多个专业子 Agent,每个子 Agent 负责自己领域的 UI。这是仓库里 samples/agent/adk/orchestrator 示例所展示的架构:

                           ┌───────────────────┐
              用户问题       │   Orchestrator     │
         ───────────────── ▶│   (主协调 Agent)    │
                            │                   │
                            │  ① 意图识别        │
                            │  "找中餐" → 路由到  │
                            │   餐厅 Agent       │
                            └──┬──────┬─────┬──┘
                               │      │     │
               ┌───────────────┘      │     └───────────────┐
               ▼                      ▼                     ▼
   ┌───────────────────┐  ┌──────────────────┐  ┌──────────────────┐
   │  餐厅查找 Agent     │  │  联系人查找 Agent  │  │  数据图表 Agent   │
   │  (port 10003)      │  │  (port 10004)     │  │  (port 10005)    │
   │                    │  │                   │  │                  │
   │  返回:餐厅列表 UI  │  │  返回:联系人卡片  │  │  返回:图表 UI    │
   │  (A2UI JSON)       │  │  (A2UI JSON)      │  │  (A2UI JSON)     │
   └────────────────────┘  └───────────────────┘  └──────────────────┘

Orchestrator 需要处理两个关键安全问题:

Surface 所有权映射——当子 Agent 创建 Surface 时,Orchestrator 记录"这个 surfaceId 属于哪个子 Agent"。当用户在 UI 上操作触发 Action 时,Orchestrator 根据 surfaceId 把请求路由回正确的子 Agent。

数据模型隔离——当 sendDataModel: true 启用时,客户端会在每条消息元数据中附带所有 Surface 的数据模型。Orchestrator 必须在转发给子 Agent 前剥离其他 Agent 的数据,否则会导致跨 Agent 的数据泄露。

 客户端发来的元数据(包含所有 Surface 的数据):
 ┌──────────────────────────────────────┐
 │ a2uiClientDataModel: {              │
 │   surfaces: {                       │
 │     "restaurant-list": {...},  ◀─── 属于餐厅 Agent
 │     "contact-card":   {...},  ◀─── 属于联系人 Agent
 │     "sales-chart":    {...}   ◀─── 属于图表 Agent
 │   }                                 │
 │ }                                   │
 └──────────────────────────────────────┘
            │
    Orchestrator 必须 strip
            │
            ▼  转发给餐厅 Agent 时只保留:
 ┌──────────────────────────────────────┐
 │ a2uiClientDataModel: {              │
 │   surfaces: {                       │
 │     "restaurant-list": {...}        │  ✅ 只有自己的数据
 │   }                                 │
 │ }                                   │
 └──────────────────────────────────────┘

🔴 高级:自定义组件 Catalog

适合人群:需要扩展 A2UI 到特定业务领域的团队

标准 Catalog 只有通用组件。如果你需要地图、图表、股票行情等,就需要自定义 Catalog:

{
  "$id": "https://mycompany.com/catalogs/dashboard/v1/catalog.json",
  "components": {
    "allOf": [
      { "$ref": "basic_catalog.json#/components" },
      {
        "SalesChart": {
          "type": "object",
          "description": "交互式销售数据图表",
          "properties": {
            "chartType": {
              "type": "string",
              "enum": ["bar", "line", "pie"],
              "description": "图表类型"
            },
            "data": {
              "description": "绑定到数据模型的图表数据路径"
            },
            "title": {
              "type": "string",
              "description": "图表标题"
            }
          },
          "required": ["chartType", "data"]
        },
        "GoogleMap": {
          "type": "object",
          "description": "显示指定位置的 Google 地图",
          "properties": {
            "latitude":  { "type": "number" },
            "longitude": { "type": "number" },
            "zoom":      { "type": "integer", "default": 14 }
          },
          "required": ["latitude", "longitude"]
        }
      }
    ]
  }
}

然后 Agent 就可以这样使用自定义组件:

{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "dashboard",
    "components": [
      {
        "id": "root",
        "component": "Column",
        "children": ["chart", "map"]
      },
      {
        "id": "chart",
        "component": "SalesChart",
        "chartType": "bar",
        "data": { "path": "/sales/quarterly" },
        "title": "Q4 销售数据"
      },
      {
        "id": "map",
        "component": "GoogleMap",
        "latitude": 31.2304,
        "longitude": 121.4737,
        "zoom": 12
      }
    ]
  }
}

整个协商流程如下:

 客户端                                     Agent
   │                                          │
   │  "我支持这些 Catalog":                     │
   │  [basic_catalog, dashboard/v1]           │
   │ ────────────────────────────────────────▶ │
   │                                          │
   │                      Agent 选择最佳匹配    │
   │                      dashboard/v1 ✅      │
   │                                          │
   │  createSurface:                          │
   │    catalogId: "dashboard/v1"             │
   │ ◀──────────────────────────────────────── │
   │                                          │
   │  此后该 Surface 只能用                     │
   │  dashboard/v1 中定义的组件                 │

五、v0.8 vs v0.9 差异速查表

两个版本的核心差异一图了然。如果你是新项目,建议直接用 v0.9;如果要维护旧代码,参考此表迁移。

       v0.8 (稳定版)                          v0.9 (草案版)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
组件格式:                                组件格式:
"component": {                          "component": "Text",
  "Text": {                             "text": "Hello"
    "text": {"literalString":"Hello"}
  }                                     ← 更扁平、更少 token
}

子组件:                                  子组件:
"children": {                           "children": ["a", "b"]
  "explicitList": ["a", "b"]
}                                        ← 标准数组

数据更新:                                数据更新:
[{"key":"name","valueString":"Alice"}]  {"name": "Alice"}
                                         ← 标准 JSON 对象

画布创建:                                画布创建:
beginRendering + surfaceUpdate          createSurface (含 catalogId)
                                         ← 显式目录协商

按钮样式:                                按钮样式:
"primary": true                         "variant": "primary"
                                         ← 更灵活的枚举

Action 格式:                             Action 格式:
{"name": "submit"}                      {"event": {"name": "submit"}}
                                         ← 支持 event/functionCall 区分

版本标识:                                版本标识:
无                                      每条消息含 "version": "v0.9"

六、安全模型图解

A2UI 的安全是多层防御体系,这是它区别于传统 iframe 方案的核心优势:

┌────────────────────────────────────────────────────────────┐
│                      安全防御层级                            │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  第 1 层:声明式格式 ─ 不是代码,是数据                       │
│  ────────────────────────────────────────                  │
│  Agent 发送的是 JSON 描述,不是 HTML/JS                      │
│  客户端永远不会 eval() 任何 Agent 内容                       │
│                                                            │
│  第 2 层:组件目录白名单 ─ 只能用"菜单上的菜"                 │
│  ────────────────────────────────────────                  │
│  Agent 只能请求 Catalog 中预定义的组件                       │
│  未知组件类型直接被忽略或降级为占位符                          │
│                                                            │
│  第 3 层:双端 Schema 验证 ─ Agent 端 + 客户端都检查          │
│  ────────────────────────────────────────                  │
│  Agent 端:发送前验证 JSON 是否合法                           │
│  客户端:接收后再验证一次,不合法就报错给 Agent               │
│                                                            │
│  第 4 层:VALIDATION_FAILED 反馈 ─ LLM 自我纠正              │
│  ────────────────────────────────────────                  │
│  客户端告诉 Agent "你的 JSON 第X处不对"                       │
│  Agent 据此修正并重新生成                                    │
│                                                            │
│  第 5 层:Orchestrator 数据隔离 ─ 多 Agent 不互相窥探         │
│  ────────────────────────────────────────                  │
│  必须剥离其他 Agent 的数据模型后再转发                        │
│                                                            │
└────────────────────────────────────────────────────────────┘

七、与同类方案的对比一览

                 A2UI              MCP Apps           AG UI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
本质          UI 描述格式        预构建 HTML          传输协议
                                (iframe)
                                
渲染方式

A2UI:让 AI Agent "说出"用户界面的开放协议

引言:Agent 时代的 UI 困境

想象这样一个场景——你对一个 AI 助手说:"帮我订一张明天晚上 7 点的两人桌。" 如果 Agent 只能回复文本,接下来将是一连串低效的对话:"请问哪一天?""什么时间?""几位?"……一个本可以用一个表单瞬间解决的事情,变成了五六个回合的文字乒乓球。

更好的方式显然是:Agent 直接生成一个表单界面,带有日期选择器、时间选择器、人数输入框和确认按钮。用户在 UI 上操作,一次提交,搞定。

但这件"显然更好"的事情,在技术上却极其棘手。Agent 可能运行在远程服务器上,甚至跨越组织的信任边界。它不能直接操控你的 UI,只能发送消息。传统的方案——在 iframe 中嵌入 Agent 返回的 HTML/JavaScript——不仅笨重、风格割裂,还引入了严重的安全隐患。

Google 发起的开源项目 A2UI(Agent-to-User Interface) 就是为了解决这个问题而生的。它定义了一种让 Agent "说 UI" 的通用语言:Agent 发送声明式的 JSON 消息来描述界面的意图,客户端应用用自己原生的组件库来渲染。安全如数据,表达如代码。


一、A2UI 是什么?一句话理解核心理念

A2UI 是一个声明式 UI 协议,而不是一个框架。它的核心思想可以拆成三层:

Agent 生成一段 JSON,描述"我想展示一个标题、一个日期选择器和一个按钮"。这段 JSON 通过任意传输通道(A2A 协议、WebSocket、SSE 等)到达客户端。客户端的 A2UI 渲染器读取 JSON,将抽象的组件描述映射为自己代码库中的原生组件——可以是 Flutter Widget、Angular Component、Lit Web Component 或 React 组件。

这意味着同一份 A2UI JSON 可以在 Web、移动端和桌面端被不同的渲染器渲染为风格统一、性能原生的界面,同时 Agent 完全无法执行任何代码——它只能从客户端预先批准的"组件目录"中选取组件来组合界面。


二、为什么需要 A2UI?三大设计支柱

安全优先。 A2UI 是一种声明式数据格式,不是可执行代码。Agent 不能注入 JavaScript,不能操纵 DOM——它只能请求渲染客户端目录中已经存在的、预先审核过的组件(如 Card、Button、TextField)。安全性由客户端完全掌控。

LLM 友好且支持增量更新。 UI 被表示为一个扁平的组件列表,组件之间通过 ID 引用来建立父子关系,而不是深层嵌套的 JSON 树。这种"邻接表"模型让 LLM 可以逐步生成组件、流式发送,客户端可以渐进式渲染——用户看到界面逐步构建,而不是盯着转圈等待。当需要更新时,只需发送带有已有 ID 的新定义即可,无需重新生成整个 UI。

框架无关且可移植。 Agent 发送的是对组件树和数据模型的抽象描述。渲染的责任完全在客户端。同一份 JSON 可以被 Lit 渲染为 Web Component,被 Flutter 渲染为原生移动控件,被 Angular 渲染为 Angular 组件,甚至未来被 SwiftUI 或 Jetpack Compose 渲染为 iOS 和 Android 原生视图。


三、核心概念详解

3.1 Surface(画布)

Surface 是 A2UI 中承载组件的容器,可以理解为一个完整的 UI 单元——一个对话框、一个侧边栏或一个主视图。每个 Surface 有唯一的 surfaceId,拥有自己的组件树和数据模型。Agent 通过创建、更新和删除 Surface 来管理界面的生命周期。

3.2 组件与邻接表模型

这是 A2UI 最独特的设计决策。传统的 UI 描述通常使用嵌套树结构,但 LLM 生成深层嵌套 JSON 时很容易出错,且难以流式传输和增量更新。A2UI 采用了扁平的邻接表模型:所有组件排成一个列表,每个组件有唯一 ID,通过 ID 引用子组件。

举个例子,一个包含标题和两个按钮的简单界面,在 v0.9 中是这样描述的:根组件 Column 声明子组件列表为 ["greeting", "buttons"],greeting 是一个 Text 组件,buttons 是一个 Row 组件再引用两个 Button。所有组件平级排列,通过 ID 建立层次关系。

A2UI 提供了一套标准组件目录,按用途分为布局类(Row、Column、List)、展示类(Text、Image、Icon、Divider)、交互类(Button、TextField、CheckBox、DateTimeInput、Slider、ChoicePicker)和容器类(Card、Tabs、Modal)。

3.3 数据绑定

A2UI 将 UI 结构与应用状态分离。每个 Surface 拥有一个 JSON 数据模型,组件通过 JSON Pointer 路径(如 /user/name/cart/items/0/price)绑定到数据模型中的值。

这种分离带来了强大的响应式更新能力:当 Agent 更新数据模型中的某个路径时,绑定到该路径的所有组件自动更新显示内容,无需重新发送组件定义。同时,交互组件(如 TextField)支持双向绑定——用户输入立即写入本地数据模型,当用户点击提交按钮时,按钮的 Action 从数据模型中解析出最新值发送给 Agent。

动态列表是数据绑定的一个精彩应用:一个模板组件绑定到数据模型中的数组路径,数组中每增加一个元素就自动渲染一个新实例,且模板内的路径自动限定到当前数组元素的作用域。

3.4 消息类型与生命周期

以 v0.9 为例,Agent 与客户端之间通过四种核心消息通信。createSurface 创建画布并指定使用的组件目录。updateComponents 定义或更新 UI 组件。updateDataModel 更新应用状态。deleteSurface 移除一个界面。

一个典型的餐厅预订流程是这样的:Agent 先发送 createSurface 创建画布,然后通过 updateComponents 定义表单结构(标题、人数输入框、提交按钮),再通过 updateDataModel 填充初始数据(日期、人数)。用户修改人数,客户端自动更新本地数据模型。用户点击确认,客户端将按钮 Action 中引用的数据路径解析为当前值,封装为 action 消息发送给 Agent。Agent 处理后可以更新界面或删除 Surface。

3.5 Catalog(组件目录)

Catalog 是 A2UI 安全模型的关键枢纽。它是一个 JSON Schema 文件,定义了 Agent 可以使用的所有组件、函数和主题。客户端告诉 Agent 自己支持哪些 Catalog,Agent 在创建 Surface 时选择一个 Catalog 并锁定。之后 Agent 生成的所有 JSON 都会在双端被验证——Agent 端在发送前验证,客户端在接收后再验证。如果验证失败,客户端会发送 VALIDATION_FAILED 错误,Agent 可以据此自我纠正。

A2UI 团队维护了一个"Basic Catalog"作为起步用的通用组件集,但大多数生产应用会定义自己的 Catalog 来反映自己的设计系统。你可以从零开始定义,也可以导入 Basic Catalog 中的部分组件再扩展自定义组件(如图表、地图、股票行情组件)。Catalog 之间通过 URI 作为唯一标识符进行协商和版本管理。

3.6 传输层

A2UI 是传输无关的——任何能传递 JSON 的机制都可以工作。目前与 A2A 协议和 AG UI 有成熟的集成,REST、WebSocket 和 SSE 在路线图中。消息通常以 JSON Lines(JSONL)格式流式传输,每行一个完整的 JSON 对象。在 A2A 绑定中,A2UI 消息被编码为 DataPart,MIME 类型为 application/json+a2ui


四、v0.8 → v0.9:从"结构化输出优先"到"提示词优先"

A2UI 的版本演进体现了对 LLM 生成能力更深层的理解。v0.8 被设计为通过 LLM 的"结构化输出"模式生成,依赖深层嵌套和特定的包装结构。v0.9 则做了一次哲学性的转变——为"嵌入系统提示词"而优化。

最直观的变化是组件格式从嵌套键变成了扁平判别器。v0.8 写作 "component": { "Text": { "text": { "literalString": "Hello" } } },v0.9 则简化为 "component": "Text", "text": "Hello"。数据模型更新从键值对数组变成了标准 JSON 对象。子组件列表从 {"explicitList": [...]} 变成了简单的数组。这些改动大幅减少了 token 消耗,也更符合 LLM 天然擅长生成的 JSON 模式。

v0.9 还引入了几个重要的新能力:createSurface 消息要求指定 catalogId,使目录协商变得显式;新增了 sendDataModel 标志,允许客户端在每条消息的元数据中自动附带完整数据模型,实现"无状态 Agent"模式;引入了 formatString 函数支持字符串插值;以及结构化的 VALIDATION_FAILED 错误反馈机制,让 LLM 可以在"生成-验证-纠正"循环中自我改进。


五、双向交互:Action 与数据同步

A2UI 不只是单向的 UI 推送,它支持完整的双向通信。交互组件(如 Button)可以定义 Action,分为两种:Server Event 发送到 Agent 处理(如提交表单),Local Function Call 在客户端本地执行(如打开 URL、格式化字符串、输入验证)。

Action 中的 context 是一个精心设计的机制——它允许按钮在触发时从数据模型中"摘取"特定路径的当前值,封装成一个简洁的上下文对象发送给 Agent,避免 Agent 需要解析整个数据模型。

v0.9 的 Data Model Sync 更是支持了一种优雅的"口头提交"模式:当 sendDataModel 启用时,用户甚至不需要点击按钮——只要说"好的,提交吧",客户端会将当前完整数据模型附在消息元数据中,Agent 从元数据中读取所有表单值即可完成处理。

在多 Agent 架构中,协调器(Orchestrator)需要维护 Surface 到子 Agent 的所有权映射,确保用户的 Action 被路由回正确的子 Agent,并且在转发消息时剥离其他 Agent 的数据模型,防止跨 Agent 的数据泄露。


六、生态系统与真实应用

A2UI 不是一个纸上协议——它已经在多个 Google 产品和合作伙伴项目中投入使用。

Google Opal 使用 A2UI 驱动 AI 小应用的动态生成式 UI 系统,让数十万用户可以用自然语言创建、编辑和分享 AI 应用。Flutter GenUI SDK 在底层使用 A2UI 作为服务端 Agent 与 Flutter 应用之间的通信协议,实现跨 iOS、Android、Web、桌面的原生渲染。Google ADK(Agent Development Kit)内置了 A2UI v0.8 标准目录的原生渲染支持。CopilotKit / AG UI 提供了 A2UI 的 Day-zero 兼容,AG UI 作为传输层,A2UI 作为 UI 内容格式,形成互补。

A2UI 与同类方案的定位也值得理解:MCP Apps 让远程服务器通过 iframe 提供完整的 UI 体验,适合服务器完全掌控 UI 的场景;AG UI 是一个前后端连接的传输协议;A2UI 则是 UI 负载本身的格式标准。三者可以组合使用——A2UI + AG UI 用 AG UI 做管道、A2UI 做内容,A2UI + A2A 用 A2A 协议在多 Agent 系统中传递 A2UI 消息。


七、动手试一试

体验 A2UI 最快的方式是运行仓库自带的餐厅查找器 Demo。克隆仓库、设置 Gemini API Key、进入 samples/client/lit 目录运行 npm run demo:all,就能在浏览器中看到一个由 Gemini 驱动的 Agent 实时生成交互式 UI 的完整流程。

如果你想更深入地开发,有三条路径可选:前端开发者可以将 A2UI 渲染器集成到自己的应用中(目前支持 Lit、Angular,React 在路线图中);后端开发者可以使用 Google ADK 构建生成 A2UI 响应的 Agent;或者直接使用 AG UI/CopilotKit 或 Flutter GenUI SDK 等已内置 A2UI 支持的框架。


结语

A2UI 的出现代表了 Agent 交互范式的一次重要进化——从"Agent 只能说文字"到"Agent 可以说 UI"。它用声明式数据格式解决了安全问题,用邻接表模型解决了 LLM 生成和流式渲染的问题,用框架无关的抽象解决了跨平台问题,用 Catalog 协商机制解决了可扩展性问题。

作为 Google 发起、Apache 2.0 许可的开源项目,A2UI 目前处于 v0.8(稳定)和 v0.9(草案)阶段,正在积极向 v1.0 迈进。如果你正在构建 AI Agent 驱动的应用,无论是对话式界面、企业工作流还是多 Agent 系统,A2UI 都值得关注和尝试。

❌