外观
生成式界面让 AI 能够从自然语言提示词生成完整的用户界面。AI 输出就是界面,而不是在聊天气泡中渲染文本回复:表单、卡片、仪表盘等。开发者定义哪些组件可用("目录"),AI 将它们组合成有效的界面树。
此模式使用 json-render(生成式界面框架)来定义组件目录、让 AI 生成规范,并在 React、Vue、Svelte 和 Angular 中安全地渲染它们。
import { PatternEmbed } from "/snippets/pattern-embed.jsx"
工作原理
- 定义目录:声明 AI 可以使用的组件,并带有类型化的 props
- 向 AI 发出提示:用自然语言描述你想要的界面
- AI 生成规范:一个描述组件树的 JSON 文档
- 安全渲染: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 }) => (
{props.title && <h2>{props.title}</h2>}
{children}
),
Stack: ({ props, children }) => (
{children}
),
TextInput: ({ props }) => (
{props.label && <label>{props.label}</label>}
<input type={props.type ?? "text"} placeholder={props.placeholder} />
),
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),
},
});
</script>连接到智能体
智能体使用结构化输出来返回一个 json-render 规范。使用你智能体的 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);
</script>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);
</script>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。只过滤出完整的元素,并向 Renderer 传递 loading={true},这会告诉它静默跳过尚未到达的子元素。界面会一个组件一个组件地构建起来:
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>
)}
</>
);INFO
JSONUIProvider 是必需的,用于设置 json-render 的内部上下文提供者(状态、可见性、验证、动作)。Renderer 组件必须在其内部渲染。
规范格式
AI 智能体生成一个扁平 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 数组为空。
最佳实践
- 使用描述性的组件描述:AI 会使用这些描述来理解何时使用每个组件。清晰的描述会带来更好的界面生成。
- 渲染前进行验证:由于流式传输会交付部分数据,因此在传递给 Renderer 之前,务必检查元素是否具有有效的
type和非空props。 - 为流式输出而设计:在流式传输期间传递
loading={true},以便 Renderer 优雅地处理尚未到达的子元素。用户会实时看到界面构建起来,而不是等待完整的回复。 - 使用设计 token 来设置样式:使用 CSS 自定义属性,让渲染出的组件自动适配浅色和深色主题。
- 使用 JSONUIProvider 包裹:
Renderer必须位于JSONUIProvider内部,才能访问 json-render 用于状态、可见性和动作的内部上下文。