Skip to content

声明式生成式 UI ​

使用 json-render 与 A2UI 从已注册的组件目录中编排 Agent 生成的界面

概述 ​

声明式生成式 UI 处于生成式 UI 光谱的中间位置。Agent 发出一个结构化的规范(specification),前端则从你预先注册的组件目录中编排界面。Agent 的输出不再仅仅是在聊天气泡中渲染文本回复,而是直接成为 UI:表单、卡片、仪表板等。你定义哪些组件可用(即“目录” catalog),Agent 则将它们编排成合法的 UI 树。

组件目录是让这种方案具备安全性的安全护栏:Agent 可以自由排列和组合你的组件,但绝不能超出你批准的组件集合。这在创造力与可预测性之间取得了平衡。这也是长尾需求所在之处——用像素级的完美度换取覆盖的广度,非常适合次要交互、内部工具和仪表板等展示有用内容重于精准控制的场景。本页介绍了使用 json-render 实现的声明式生成式 UI,这是一个生成式 UI 框架,它定义了组件目录,通过 AI 生成规范,并能安全地在 React、Vue、Svelte 和 Angular 之间进行渲染。关于 Google 的 A2UI 规范(通过 CopilotKit 集成),请参阅下文的 A2UI。

何时使用此方案 ​

在产品的长尾场景中使用声明式生成式 UI,此时 Agent 可以在你批准的组件集范围内,编排你未完全预料到的布局:次要交互、内部工具和仪表板。当界面处于高流量或对品牌至关重要、必须精确无误的场景时,请转向受控生成式 UI。当你想在应用程序之外创建界面时,请转向开放式生成式 UI。

工作原理 ​

  1. 定义目录:声明 AI 可以使用的组件及其类型化 Props
  2. 提示 AI:用自然语言描述你想要的 UI
  3. AI 生成规范:一个描述组件树的 JSON 文档
  4. 安全渲染:json-render 的 Renderer 使用你的组件渲染该规范

目录充当了安全护栏:AI 只能使用你已定义的组件,且 Props 必须与你的 Schema 匹配。输出始终是可预测且安全的。

定义组件目录 ​

目录描述了允许 AI 使用的每个组件。每个组件都有用于其 Props 的 Zod Schema,以及供 AI 读取以理解何时使用它的描述:

ts
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";

const catalog = defineCatalog(schema, {
  components: {
    Card: {
      description: "A card container with optional title and padding",
      props: z.object({
        title: z.string().optional(),
        padding: z.enum(["sm", "md", "lg"]).optional(),
      }),
    },
    Stack: {
      description: "Layout children vertically or horizontally with consistent spacing",
      props: z.object({
        direction: z.enum(["vertical", "horizontal"]).optional(),
        gap: z.enum(["sm", "md", "lg"]).optional(),
      }),
    },
    TextInput: {
      description: "A text input field with optional label and placeholder",
      props: z.object({
        label: z.string().optional(),
        placeholder: z.string().optional(),
        type: z.enum(["text", "email", "password", "number", "textarea"]).optional(),
      }),
    },
    Button: {
      description: "A clickable button with label and style variants",
      props: z.object({
        label: z.string(),
        variant: z.enum(["primary", "secondary", "ghost", "link"]).optional(),
        fullWidth: z.boolean().optional(),
      }),
    },
  },
  actions: {},
});

TIP

保持目录精简聚焦。仅包含 AI 在当前用例中需要的组件。 与包罗万象的大杂烩式做法相比,较小的目录能产生更好的效果。

构建组件注册表 ​

注册表将目录中的每个组件映射为其真正的渲染实现。使用 defineRegistry 可以在目录 Props 与组件函数之间建立类型安全的绑定:

tsx
import { defineRegistry, Renderer, JSONUIProvider } from "@json-render/react";

const { registry } = defineRegistry(catalog, {
  components: {
    Card: ({ props, children }) => (
      <div className="card">
        {props.title && <h2>{props.title}</h2>}
        {children}
      </div>
    ),
    Stack: ({ props, children }) => (
      <div className={`stack stack-${props.direction ?? "vertical"} gap-${props.gap ?? "md"}`}>
        {children}
      </div>
    ),
    TextInput: ({ props }) => (
      <div>
        {props.label && <label>{props.label}</label>}
        <input type={props.type ?? "text"} placeholder={props.placeholder} />
      </div>
    ),
    Button: ({ props }) => (
      <button className={props.variant ?? "primary"}>
        {props.label}
      </button>
    ),
  },
});
vue
<script setup lang="ts">
import { h } from "vue";
import { defineRegistry, Renderer, JSONUIProvider } from "@json-render/vue";

const { registry } = defineRegistry(catalog, {
  components: {
    Card: ({ props, children }) =>
      h("div", { class: "card" }, [
        props.title ? h("h2", null, props.title) : null,
        children,
      ]),
    Stack: ({ props, children }) =>
      h("div", { class: `stack stack-${props.direction ?? "vertical"} gap-${props.gap ?? "md"}` }, children),
    TextInput: ({ props }) =>
      h("div", null, [
        props.label ? h("label", null, props.label) : null,
        h("input", { type: props.type ?? "text", placeholder: props.placeholder }),
      ]),
    Button: ({ props }) =>
      h("button", { class: props.variant ?? "primary" }, props.label),
  },
});
`&lt;/script&gt;`

连接到 Agent ​

Agent 使用结构化输出返回 json-render 规范。使用 Agent 的 Assistant ID 配置 useStream,然后从 AI 消息的 tool_calls 中提取规范:

tsx
import { useStream } from "@langchain/react";
import { AIMessage } from "langchain";

function GenerativeUI() {
  const stream = useStream<typeof myAgent>({
    apiUrl: "http://localhost:2024",
    assistantId: "generative_ui",
  });

  const aiMessage = stream.messages.find(AIMessage.isInstance);
  const rawSpec = aiMessage?.tool_calls?.[0]?.args;

  // ... 过滤并渲染(参见下方的流式传输部分)
}
vue
<script setup lang="ts">
import { useStream } from "@langchain/vue";
import { AIMessage } from "langchain";
import { computed } from "vue";

const stream = useStream<typeof myAgent>({
  apiUrl: "http://localhost:2024",
  assistantId: "generative_ui",
});

const aiMessage = computed(() => stream.messages.value.find(AIMessage.isInstance));
const rawSpec = computed(() => aiMessage.value?.tool_calls?.[0]?.args);
`&lt;/script&gt;`
svelte
<script lang="ts">
  import { useStream } from "@langchain/svelte";
  import { AIMessage } from "langchain";

  const stream = useStream<typeof myAgent>({
    apiUrl: "http://localhost:2024",
    assistantId: "generative_ui",
  });

  const aiMessage = $derived(stream.messages.find((m) => AIMessage.isInstance(m)));
  const rawSpec = $derived(aiMessage?.tool_calls?.[0]?.args);
`&lt;/script&gt;`
ts
import { Component } from "@angular/core";
import { injectStream } from "@langchain/angular";
import { AIMessage } from "langchain";

@Component({
  selector: "app-generative-ui",
  template: `...`,
})
export class GenerativeUIComponent {
  stream = injectStream<typeof myAgent>({
    apiUrl: "http://localhost:2024",
    assistantId: "generative_ui",
  });

  get rawSpec() {
    const ai = this.stream.messages().find(AIMessage.isInstance);
    return ai?.tool_calls?.[0]?.args;
  }
}

流式传输与渐进式渲染 ​

在流式传输过程中,规范是增量构建的。元素逐个到达,最初可能缺少 type 或 props。过滤出完整的元素并将 loading={true} 传递给 Renderer,这会告知它静默跳过尚未到达的子元素。UI 会逐个组件地构建出来:

tsx
/*
 * 过滤流式传输的规范,仅包含具有有效 type/props 的元素,
 * 随着 AI 响应的构建实现渐进式渲染。将 loading={true}
 * 传递给 Renderer 会告知其静默跳过缺失的子元素。
 */
const spec = (() => {
  if (!rawSpec?.root || !rawSpec?.elements) return null;
  const rootEl = rawSpec.elements[rawSpec.root];
  if (!rootEl?.type || rootEl?.props == null) return null;

  const safeElements = {};
  for (const [key, el] of Object.entries(rawSpec.elements)) {
    if (el?.type && el?.props != null) {
      safeElements[key] = el;
    }
  }
  return { root: rawSpec.root, elements: safeElements };
})();

return (
  <>
    {spec && (
      <JSONUIProvider registry={registry}>
        <Renderer spec={spec} registry={registry} loading={stream.isLoading} />
      </JSONUIProvider>
    )}
  </>
);

NOTE

必须使用 JSONUIProvider 来设置 json-render 的内部上下文提供者(状态、可见性、验证、操作)。Renderer 组件必须在它内部进行渲染。

规范格式 ​

AI Agent 会生成一个扁平的 JSON 规范,其中的 root 键指向根元素,elements 映射包含所有组件:

json
{
  "root": "login-card",
  "elements": {
    "login-card": {
      "type": "Card",
      "props": { "title": "Login" },
      "children": ["login-stack"]
    },
    "login-stack": {
      "type": "Stack",
      "props": { "direction": "vertical", "gap": "md" },
      "children": ["email-input", "password-input", "submit-btn"]
    },
    "email-input": {
      "type": "TextInput",
      "props": { "label": "Email", "placeholder": "Enter your email", "type": "email" },
      "children": []
    },
    "password-input": {
      "type": "TextInput",
      "props": { "label": "Password", "placeholder": "Enter your password", "type": "password" },
      "children": []
    },
    "submit-btn": {
      "type": "Button",
      "props": { "label": "Sign In", "variant": "primary", "fullWidth": true },
      "children": []
    }
  }
}

每个元素通过 ID 引用其子元素,而叶子元素(如 TextInput 和 Button)的 children 数组为空。

A2UI:另一种声明式规范 ​

声明式描述界面的一种方法是 json-render。A2UI 是另一种:Google 提出的声明式、流式优先的生成式 UI 规范,通过 CopilotKit 进行集成。与 json-render 类似,它也是从你注册的组件中编排界面,因此 Agent 会始终停留在你定义的安全护栏之内。A2UI 有两种变体:

  • 动态 Schema(Dynamic schema):由辅助模型根据对话生成完整的界面,包括 Schema、数据和布局,以提供最大的灵活性。
  • 固定 Schema(Fixed schema):组件树在前端定义,Agent 仅向其中流式传输数据,以实现最快且最可预测的渲染。

详情请参阅 CopilotKit 关于 A2UI、动态 Schema 和 固定 Schema 的文档。要将 CopilotKit 连接到 LangGraph 部署,请参阅 CopilotKit。

最佳实践 ​

  • 使用具有描述性的组件描述:AI 利用这些描述来理解何时使用各个组件。清晰的描述有助于生成更好的 UI。
  • 渲染前进行验证:在传递给 Renderer 之前,始终检查元素是否具有有效的 type 和非空的 props,因为流式传输传递的是部分数据。
  • 针对流式传输进行设计:在流式传输期间传入 loading={true},以便 Renderer 优雅地处理尚未到达的子元素。用户可以实时看到 UI 逐步构建,而无需等待完整响应。
  • 使用设计令牌(Design Tokens)进行样式设置:使用 CSS 自定义属性,以便渲染的组件自动适应浅色和深色主题。
  • 使用 JSONUIProvider 包裹:Renderer 必须位于 JSONUIProvider 内部,才能访问 json-render 用于状态、可见性和操作的内部上下文。

另请参阅 ​