Tambo 生成式 UI 实现原理
本文整理对 Tambo 生成式 UI 的讨论, 重点解释组件如何变成大模型可调用的工具, 数据如何在前后端流转, 组件如何绑定接口数据, 以及后续对已生成组件的修改是如何发生的。
核心结论:
- Tambo 不是让大模型直接输出 JSX.
- Tambo 是把已注册 React 组件转换成 LLM tool, 工具名类似
show_component_WeatherCard. - 大模型调用这个 UI tool, tool arguments 就是组件 props.
- 后端把 tool arguments 的流式 JSON 转成
tambo.component.*事件. - 前端把事件累计成
message.content[type="component"], 再用ComponentRenderer渲染真实 React 组件.
整体链路:
React 组件 + propsSchema
-> TamboRegistryProvider 注册到 componentList
-> client 发送 availableComponents[]
-> API 转成内部 AvailableComponent
-> backend 转成 show_component_* UI tools
-> LLM 调用 UI tool, 并流式生成 JSON props
-> ComponentStreamTracker 生成 tambo.component.* 事件
-> SSE / AG-UI stream 发给前端
-> event accumulator 写入 message.content[type="component"]
-> ComponentRenderer 查 registry
-> React.createElement(Component, props)
1. 组件清单会变成工具, 不是普通提示词
开发者先注册一个普通 React 组件:
import { z } from "zod";
const WeatherCardPropsSchema = z.object({
city: z.string().describe("City name to display, e.g. Tokyo"),
temperature: z.number().describe("Temperature in Celsius"),
condition: z.string().describe("Weather condition"),
humidity: z.number().optional().describe("Humidity percentage"),
});
type WeatherCardProps = z.infer<typeof WeatherCardPropsSchema>;
function WeatherCard({
city,
temperature,
condition,
humidity,
}: WeatherCardProps) {
return (
<section>
<h2>{city}</h2>
<p>
{temperature} C, {condition}
</p>
{humidity !== undefined && <p>Humidity: {humidity}%</p>}
</section>
);
}
注册到 TamboProvider:
<TamboProvider
components={[
{
name: "WeatherCard",
description:
"Displays current weather for a city. Use when the user asks about weather.",
component: WeatherCard,
propsSchema: WeatherCardPropsSchema,
},
]}
>
<App />
</TamboProvider>
注册后, 前端 registry 里大致保存:
componentList["WeatherCard"] = {
name: "WeatherCard",
description: "Displays current weather for a city...",
component: WeatherCard,
props: WeatherCardPropsJsonSchema,
contextTools: [],
};
这里有一个关键点: 真实的 WeatherCard React 函数只保存在前端. 后端不会拿到 React 组件代码, 只会拿到组件元数据:
{
"name": "WeatherCard",
"description": "Displays current weather for a city...",
"propsSchema": {
"type": "object",
"properties": {
"city": { "type": "string" },
"temperature": { "type": "number" },
"condition": { "type": "string" },
"humidity": { "type": "number" }
},
"required": ["city", "temperature", "condition"]
}
}
后端再把这个组件转成 LLM function tool:
{
"type": "function",
"function": {
"name": "show_component_WeatherCard",
"description": "Show the WeatherCard UI component the user...",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"city": { "type": "string" },
"temperature": { "type": "number" },
"condition": { "type": "string" },
"humidity": {
"anyOf": [{ "type": "null" }, { "type": "number" }]
}
},
"required": ["city", "temperature", "condition", "humidity"],
"additionalProperties": false
}
}
}
所以模型不是输出:
<WeatherCard city="Tokyo" temperature={24} />
而是调用工具:
{
"tool": "show_component_WeatherCard",
"arguments": {
"city": "Tokyo",
"temperature": 24,
"condition": "Cloudy",
"humidity": 63
}
}
提示词仍然有作用: 它会告诉模型 show_component_* 是 UI tools, 调用后会在用户屏幕上显示组件. 但组件清单本身的可执行约束主要来自 tool definitions, 包括组件名, 描述和 props schema.
2. 一次生成式 UI 的完整数据流
以用户输入为例:
What's the weather in Tokyo?
步骤 1: 前端发送消息和组件清单
前端发送给 API 的数据大致是:
{
"message": {
"role": "user",
"content": "What's the weather in Tokyo?"
},
"availableComponents": [
{
"name": "WeatherCard",
"description": "Displays current weather for a city...",
"propsSchema": {
"type": "object",
"properties": {
"city": { "type": "string" },
"temperature": { "type": "number" },
"condition": { "type": "string" },
"humidity": { "type": "number" }
},
"required": ["city", "temperature", "condition"]
}
}
],
"tools": []
}
步骤 2: 后端转成 UI tool
后端把 WeatherCard 转成 show_component_WeatherCard 工具.
availableComponents[]
-> convertComponentsToUITools()
-> show_component_WeatherCard
步骤 3: 模型选择工具并生成 props
模型看到用户问天气, 也看到有一个能显示天气的 UI tool, 于是调用:
{
"tool": "show_component_WeatherCard",
"arguments": {
"city": "Tokyo",
"temperature": 24,
"condition": "Cloudy",
"humidity": 63
}
}
步骤 4: 后端把 tool arguments 转成组件事件
因为 tool name 以 show_component_ 开头, 后端不会把它当普通工具暴露给前端, 而是转成组件流事件:
{
"type": "CUSTOM",
"name": "tambo.component.start",
"value": {
"messageId": "msg_1",
"componentId": "comp_1",
"componentName": "WeatherCard"
}
}
然后不断发送 props 增量:
{
"type": "CUSTOM",
"name": "tambo.component.props_delta",
"value": {
"componentId": "comp_1",
"operations": [
{ "op": "add", "path": "/city", "value": "Tokyo" }
],
"streamingStatus": {
"city": "started"
}
}
}
结束时:
{
"type": "CUSTOM",
"name": "tambo.component.end",
"value": {
"componentId": "comp_1",
"finalProps": {
"city": "Tokyo",
"temperature": 24,
"condition": "Cloudy",
"humidity": 63
}
}
}
步骤 5: 前端把事件累计成 message content
前端收到 tambo.component.start 后, 在 assistant message 里创建一个 component block:
{
"type": "component",
"id": "comp_1",
"name": "WeatherCard",
"props": {},
"streamingState": "started"
}
收到 props_delta 后应用 JSON Patch:
{
"type": "component",
"id": "comp_1",
"name": "WeatherCard",
"props": {
"city": "Tokyo",
"temperature": 24
},
"streamingState": "streaming"
}
收到 end 后:
{
"type": "component",
"id": "comp_1",
"name": "WeatherCard",
"props": {
"city": "Tokyo",
"temperature": 24,
"condition": "Cloudy",
"humidity": 63
},
"streamingState": "done"
}
步骤 6: ComponentRenderer 渲染真实 React 组件
消息渲染器遇到 component block:
<ComponentRenderer
key={content.id}
content={content}
threadId={threadId}
messageId={message.id}
fallback={<div>Unknown component: {content.name}</div>}
/>
ComponentRenderer 做的事情:
content.name = "WeatherCard"
-> 从前端 registry 查 WeatherCard React component
-> 解析 content.props
-> 校验 props schema
-> React.createElement(WeatherCard, props)
3. 数据由谁获取: 后端取数还是前端组件取数
以天气为例, 有两种常见模式.
模式 A: 前端工具调用接口, 结果交给模型, 再生成组件 props
工具定义在前端:
const getWeatherTool = {
name: "get_weather",
description: "Fetch current weather for a city",
tool: async ({ city }: { city: string }) => {
const res = await fetch(`/api/weather?city=${encodeURIComponent(city)}`);
if (!res.ok) throw new Error(`Failed to fetch weather: ${res.status}`);
return await res.json();
},
inputSchema: z.object({
city: z.string().describe("City name"),
}),
outputSchema: z.object({
city: z.string(),
temperature: z.number(),
condition: z.string(),
}),
};
数据流:
用户: What's the weather in Tokyo?
-> 后端 LLM 决定调用 get_weather({ city: "Tokyo" })
-> 前端执行 get_weather, 本地 fetch /api/weather
-> 前端把 tool_result 发回后端
-> LLM 看到 weather 数据
-> LLM 调 show_component_WeatherCard({ city, temperature, condition })
-> 前端渲染 WeatherCard
这个模式适合:
- 模型需要根据接口返回做判断.
- 模型需要总结数据.
- 模型要根据数据选择组件或生成图表 props.
例如:
如果东京下雨, 就推荐室内活动; 如果不下雨, 推荐户外活动.
这种场景模型必须看到天气数据, 所以应该使用工具取数.
模式 B: 组件自己调用接口, 模型只生成查询参数
组件 props 只包含查询参数:
const WeatherCardPropsSchema = z.object({
city: z.string().describe("City to load weather for"),
});
type WeatherCardProps = z.infer<typeof WeatherCardPropsSchema>;
组件内部 fetch:
function WeatherCard({ city }: WeatherCardProps) {
const { data, isLoading, error } = useQuery({
queryKey: ["weather", city],
queryFn: async () => {
const res = await fetch(`/api/weather?city=${encodeURIComponent(city)}`);
if (!res.ok) throw new Error(`Failed to fetch weather: ${res.status}`);
return await res.json();
},
enabled: !!city,
});
if (isLoading) return <WeatherSkeleton />;
if (error) return <WeatherError city={city} />;
return <WeatherCardView data={data} />;
}
数据流:
用户: What's the weather in Tokyo?
-> LLM 调 show_component_WeatherCard({ city: "Tokyo" })
-> 后端只流式返回组件事件和 city prop
-> 前端 ComponentRenderer 渲染 WeatherCard
-> WeatherCard 自己 fetch /api/weather?city=Tokyo
-> React Query / SWR / 组件状态绑定接口数据
-> UI 更新
这个模式适合:
- 模型只需要决定展示哪个组件和查询参数.
- 数据量大, 不适合塞进模型上下文.
- 数据敏感, 不希望模型看到完整数据.
- 数据需要实时刷新.
- 组件本身就是一个数据视图, 例如订单详情, 股票图表, 用户 profile.
4. 模式 B 下接口是动态的怎么办
如果某个组件调用的接口不是写死的, 不建议让模型直接生成任意 URL. 更稳的做法是让模型生成业务语义, 例如 source, resourceId, filters, 组件或后端再把它解析成真实接口.
方案 1: props 传 dataSourceId, 组件内部白名单映射
schema:
const WeatherCardPropsSchema = z.object({
source: z
.enum(["currentWeather", "weatherForecast", "airQuality"])
.describe("Which weather data source to use"),
city: z.string().describe("City name"),
});
组件:
type WeatherCardProps = z.infer<typeof WeatherCardPropsSchema>;
const weatherFetchers = {
currentWeather: async ({ city }: { city: string }) => {
const res = await fetch(
`/api/weather/current?city=${encodeURIComponent(city)}`,
);
if (!res.ok) throw new Error("Failed to fetch current weather");
return await res.json();
},
weatherForecast: async ({ city }: { city: string }) => {
const res = await fetch(
`/api/weather/forecast?city=${encodeURIComponent(city)}`,
);
if (!res.ok) throw new Error("Failed to fetch forecast");
return await res.json();
},
airQuality: async ({ city }: { city: string }) => {
const res = await fetch(
`/api/weather/air-quality?city=${encodeURIComponent(city)}`,
);
if (!res.ok) throw new Error("Failed to fetch air quality");
return await res.json();
},
} as const;
function WeatherCard({ source, city }: WeatherCardProps) {
const fetcher = weatherFetchers[source];
const { data, isLoading, error } = useQuery({
queryKey: ["weather-card", source, city],
queryFn: async () => fetcher({ city }),
});
if (isLoading) return <WeatherCardSkeleton />;
if (error) return <WeatherCardError city={city} />;
return <WeatherCardView data={data} source={source} />;
}
数据流:
用户: What's the weather in Tokyo?
-> LLM 调 show_component_WeatherCard({
source: "currentWeather",
city: "Tokyo"
})
-> 前端渲染 WeatherCard
-> WeatherCard 从 weatherFetchers.currentWeather 找到 fetcher
-> fetch /api/weather/current?city=Tokyo
-> data 绑定到 UI
优点是模型只能在 enum 允许的数据源里选, 不会随便编 URL.
方案 2: 不同接口参数不同, 使用 discriminated union
如果不同数据源参数不一样:
const WeatherCardPropsSchema = z.discriminatedUnion("source", [
z.object({
source: z.literal("currentWeather"),
city: z.string(),
}),
z.object({
source: z.literal("weatherForecast"),
city: z.string(),
days: z.number().min(1).max(10),
}),
z.object({
source: z.literal("weatherByCoordinates"),
lat: z.number(),
lon: z.number(),
}),
]);
组件:
function WeatherCard(props: WeatherCardProps) {
const query = useQuery({
queryKey: ["weather-card", props],
queryFn: async () => {
switch (props.source) {
case "currentWeather":
return fetchCurrentWeather({ city: props.city });
case "weatherForecast":
return fetchWeatherForecast({
city: props.city,
days: props.days,
});
case "weatherByCoordinates":
return fetchWeatherByCoordinates({
lat: props.lat,
lon: props.lon,
});
}
},
});
return <WeatherCardView result={query} source={props.source} />;
}
如果不同 source 的 UI 差异很大, 不要把所有逻辑塞进一个大组件, 直接拆成多个组件:
CurrentWeatherCard
ForecastCard
AirQualityCard
然后让模型选择组件.
方案 3: 接口由页面, 租户, 环境决定, 用 Context 注入
如果接口不是模型选择, 而是当前用户, workspace, tenant 或环境决定, 不要把接口放进 props. 用 React context 注入 fetcher.
interface DataSourceRegistry {
fetchWeather: (input: { city: string }) => Promise<WeatherData>;
}
const DataSourceContext = createContext<DataSourceRegistry | null>(null);
function useDataSources() {
const value = useContext(DataSourceContext);
if (!value) throw new Error("DataSourceContext is missing");
return value;
}
页面层:
<DataSourceContext.Provider
value={{
fetchWeather: async ({ city }) => {
const res = await fetch(
`/api/tenant/${tenantId}/weather?city=${encodeURIComponent(city)}`,
);
if (!res.ok) throw new Error("Failed to fetch tenant weather");
return await res.json();
},
}}
>
<TamboProvider components={[weatherCardComponent]}>
<App />
</TamboProvider>
</DataSourceContext.Provider>
组件:
function WeatherCard({ city }: { city: string }) {
const { fetchWeather } = useDataSources();
const query = useQuery({
queryKey: ["weather", city],
queryFn: async () => fetchWeather({ city }),
});
return <WeatherCardView result={query} />;
}
数据流:
用户消息
-> LLM 只生成 { city: "Tokyo" }
-> WeatherCard 渲染
-> WeatherCard 从 Context 拿当前租户/页面注入的 fetchWeather
-> 调动态接口
-> UI 更新
方案 4: 组件只拿 resourceId, 真实接口由后端解析
如果接口涉及密钥, 权限, 跨域, 用户私有数据, 组件应调用固定后端代理, 由后端解析真实动态接口.
schema:
const WeatherCardPropsSchema = z.object({
resourceId: z.enum(["weather.current", "weather.forecast"]),
city: z.string(),
});
组件:
function WeatherCard({ resourceId, city }: WeatherCardProps) {
const query = useQuery({
queryKey: ["resource", resourceId, city],
queryFn: async () => {
const res = await fetch("/api/data-source/query", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resourceId, params: { city } }),
});
if (!res.ok) throw new Error("Failed to query data source");
return await res.json();
},
});
return <WeatherCardView result={query} />;
}
数据流:
LLM 生成 resourceId + params
-> 前端组件 POST /api/data-source/query
-> 后端校验权限
-> 后端调用真实动态接口
-> 返回数据
-> 组件渲染
5. 能不能对大模型说: 天气接口是 /api/weather/xxx
可以, 但要分清楚: 只在 prompt 里告诉大模型接口地址, 不会自动让组件去调用接口. 组件必须有代码使用这个信息.
如果组件 schema 只有:
propsSchema: z.object({
city: z.string(),
})
你在 prompt 里说:
天气调用的接口是 /api/weather/xxx
模型最多知道这个信息, 但渲染时 WeatherCard 还是只拿到:
{ "city": "Tokyo" }
它不会凭空调用接口, 除非组件实现里写了 fetch 逻辑.
可以把接口路径变成受控 props:
const WeatherCardPropsSchema = z.object({
apiPath: z.enum(["/api/weather/current", "/api/weather/forecast"]),
city: z.string(),
});
组件:
function WeatherCard({ apiPath, city }: WeatherCardProps) {
const query = useQuery({
queryKey: ["weather", apiPath, city],
queryFn: async () => {
const res = await fetch(`${apiPath}?city=${encodeURIComponent(city)}`);
if (!res.ok) throw new Error("Failed to fetch weather");
return await res.json();
},
});
return <WeatherCardView result={query} />;
}
模型可以调用:
{
"tool": "show_component_WeatherCard",
"arguments": {
"apiPath": "/api/weather/current",
"city": "Tokyo"
}
}
但更推荐让模型传业务 ID, 而不是直接传 URL:
const WeatherCardPropsSchema = z.object({
source: z.enum(["current", "forecast"]),
city: z.string(),
});
组件里映射:
const weatherApiPaths = {
current: "/api/weather/current",
forecast: "/api/weather/forecast",
} as const;
function WeatherCard({ source, city }: WeatherCardProps) {
const apiPath = weatherApiPaths[source];
// fetch apiPath...
}
这样更稳:
- 模型只能选允许的接口.
- 不会编出错误路径.
- 不会生成危险路径.
- 不会把底层网络细节暴露为模型可自由控制的输入.
6. 流式渲染和增量编译
这里要区分两个概念:
流式渲染 props: Tambo 支持.
运行时增量编译 JSX/React 代码: Tambo 当前不是这个架构, 也不建议.
6.1 流式渲染 props
模型调用 show_component_WeatherCard 后, tool arguments JSON 是分片返回的:
{"city":"Tokyo"
,"temperature":24
,"condition":"Cloudy"
,"humidity":63}
后端会把分片 JSON 转成 JSON Patch:
[
{ "op": "add", "path": "/city", "value": "Tokyo" }
]
后续:
[
{ "op": "add", "path": "/temperature", "value": 24 }
]
再后续:
[
{ "op": "add", "path": "/condition", "value": "Cloudy" }
]
前端持续应用 patch:
{}
变成:
{ "city": "Tokyo" }
再变成:
{ "city": "Tokyo", "temperature": 24 }
最后变成:
{
"city": "Tokyo",
"temperature": 24,
"condition": "Cloudy"
}
因此组件要能处理 props 尚未完整的状态:
function WeatherCard({ city, temperature, condition }: WeatherCardProps) {
return (
<section>
<h2>{city ?? "Loading city..."}</h2>
<p>
{temperature === undefined
? "Loading temperature..."
: `${temperature} C`}
</p>
<p>{condition ?? "Loading condition..."}</p>
</section>
);
}
6.2 组件内部也可以二次流式取数
如果组件自己调用一个流式接口:
LLM 流式生成 { source, city }
-> WeatherCard 渲染
-> WeatherCard 调 /api/weather/stream
-> 组件内部持续 setState / query update
-> UI 增量更新
这属于组件内部的数据流, 和 Tambo 的 props streaming 是两层流式.
6.3 不建议运行时增量编译 JSX
如果所谓增量编译是:
LLM 边生成 JSX / React 代码
-> 前端边编译
-> 边挂载新组件
那不是 Tambo 当前模式, 也不建议作为默认架构.
原因:
- 需要浏览器端编译器或 runtime evaluator.
- 安全风险高.
- 依赖解析复杂.
- 类型不可控.
- 权限边界难管.
- hydration, bundle, sandbox 都复杂.
Tambo 采用的是:
预编译组件 + 流式 JSON props
如果想要动态 UI 结构, 更推荐使用安全 DSL, 而不是编译代码.
例如注册一个通用组件 DynamicDashboard, 让模型生成受控 UI spec:
const DynamicDashboardPropsSchema = z.object({
blocks: z.array(
z.discriminatedUnion("type", [
z.object({
type: z.literal("metric"),
title: z.string(),
value: z.string(),
}),
z.object({
type: z.literal("chart"),
title: z.string(),
source: z.enum(["weather", "sales", "traffic"]),
}),
]),
),
});
组件内部解释 DSL:
function DynamicDashboard({ blocks }: DynamicDashboardProps) {
return (
<div>
{blocks.map((block, index) => {
switch (block.type) {
case "metric":
return (
<MetricCard
key={index}
title={block.title}
value={block.value}
/>
);
case "chart":
return (
<ChartBlock
key={index}
title={block.title}
source={block.source}
/>
);
}
})}
</div>
);
}
这样可以做到模型动态生成页面结构, 但仍然是解释安全 schema, 不是编译 JSX.
7. 后续让大模型修改刚生成的组件
例如第一轮:
用户: 生成一个天气卡片
模型调用:
{
"tool": "show_component_WeatherCard",
"arguments": {
"city": "Tokyo",
"backgroundColor": "white"
}
}
前端消息里得到:
{
"type": "component",
"id": "comp_1",
"name": "WeatherCard",
"props": {
"city": "Tokyo",
"backgroundColor": "white"
},
"streamingState": "done"
}
第二轮用户说:
把刚生成的 Card 背景色改为红色
这里有两种情况.
情况 A: 普通 generative component
普通生成式组件的语义是: 每条消息生成一个新组件实例.
因此模型通常会重新调用一次:
{
"tool": "show_component_WeatherCard",
"arguments": {
"city": "Tokyo",
"backgroundColor": "red"
}
}
线程里会出现新的 component block:
{
"type": "component",
"id": "comp_2",
"name": "WeatherCard",
"props": {
"city": "Tokyo",
"backgroundColor": "red"
}
}
也就是说, 对普通生成式组件来说:
“改成红色” = 通常新生成一个组件 JSON / 组件实例
而不是原地修改 comp_1.
情况 B: interactable component
如果想原地修改已有 Card, 应把它设计成 interactable.
Interactable component 会给已有组件实例自动注册更新工具:
update_component_props_<componentId>
update_component_state_<componentId>
这时第二轮用户说:
把刚生成的 Card 背景色改为红色
模型可以调用:
{
"tool": "update_component_props_card_123",
"arguments": {
"componentId": "card_123",
"newProps": {
"backgroundColor": "red"
}
}
}
前端本地更新已有组件实例:
{
"id": "card_123",
"props": {
"city": "Tokyo",
"backgroundColor": "red"
}
}
数据流:
已有 Card 实例
-> interactable provider 注册 update_component_props_card_123
-> 用户: 改成红色
-> LLM 调 update_component_props_card_123({ newProps: { backgroundColor: "red" } })
-> 前端执行 tool
-> 同一个 Card 实例 props 更新
-> React 重渲染
所以:
普通生成式组件:
后续修改 = 通常新生成一个组件 JSON.
可交互组件:
后续修改 = 调 update_component_props/state 工具, 原地修改已有实例.
如果产品体验需要“修改刚才生成的 UI”, 应把可变字段设计成 props 或 state:
const CardSchema = z.object({
title: z.string(),
body: z.string(),
backgroundColor: z.enum(["white", "red", "blue", "green"]),
});
然后让这个 Card 具备 interactable/update 能力. 这样模型修改的是受控字段, 不是重新编译组件代码.
8. JSON Patch 是部分更新吗
是. JSON Patch 是部分更新, 但要注意它主要用于“当前这一次组件生成过程中的流式 props 增量更新”.
例如模型正在生成:
{
"city": "Tokyo",
"temperature": 24,
"condition": "Cloudy"
}
后端不会等完整 JSON 都出来才发给前端, 而是发 patch:
[
{ "op": "add", "path": "/city", "value": "Tokyo" }
]
然后:
[
{ "op": "add", "path": "/temperature", "value": 24 }
]
再然后:
[
{ "op": "add", "path": "/condition", "value": "Cloudy" }
]
前端把这些 patch 应用到当前 props:
{}
变成:
{ "city": "Tokyo" }
再变成:
{ "city": "Tokyo", "temperature": 24 }
最后变成:
{
"city": "Tokyo",
"temperature": 24,
"condition": "Cloudy"
}
但要分清楚:
tambo.component.props_delta / JSON Patch
= 当前这一次组件生成过程中的流式 props 增量更新
update_component_props_<id>
= 后续对一个已有 interactable 组件实例做部分更新
例如:
把刚才那个 Card 背景色改成红色
如果是 interactable, 调用的是:
{
"componentId": "card_123",
"newProps": {
"backgroundColor": "red"
}
}
这也是部分更新, 但它走的是 update_component_props_ 工具, 不是普通生成式组件那条 tambo.component.props_delta 流式事件.
9. 复杂布局如何实现
复杂布局一般不要靠大模型生成布局代码, 而是靠:
预注册布局组件
+ 受控 JSON 布局 schema
+ 前端 LayoutRenderer / DashboardLayout 解释 schema
核心思路:
LLM 不生成 JSX / CSS
LLM 生成 layout JSON
前端用预注册的 LayoutRenderer 解释 JSON
LayoutRenderer 渲染真实 React 组件
不要让模型输出:
<div className="grid grid-cols-3 gap-4">
<Card />
<Chart />
</div>
而是让模型调用一个布局组件:
{
"tool": "show_component_DashboardLayout",
"arguments": {
"title": "Tokyo Weather Overview",
"layout": "twoColumn",
"blocks": [
{
"type": "metric",
"title": "Temperature",
"value": "24 C",
"intent": "primary"
},
{
"type": "chart",
"title": "7-day forecast",
"source": "weatherForecast",
"query": {
"city": "Tokyo",
"days": 7
},
"span": 2
},
{
"type": "table",
"title": "Air quality",
"source": "airQuality",
"query": {
"city": "Tokyo"
}
}
]
}
}
前端注册的是一个复杂父组件:
const DashboardLayoutSchema = z.object({
title: z.string(),
layout: z.enum(["singleColumn", "twoColumn", "threeColumn"]),
blocks: z.array(
z.discriminatedUnion("type", [
z.object({
type: z.literal("metric"),
title: z.string(),
value: z.string(),
intent: z.enum(["primary", "success", "warning", "danger"]).optional(),
span: z.number().optional(),
}),
z.object({
type: z.literal("chart"),
title: z.string(),
source: z.enum(["weatherForecast", "salesTrend", "traffic"]),
query: z.record(z.unknown()),
span: z.number().optional(),
}),
z.object({
type: z.literal("table"),
title: z.string(),
source: z.enum(["airQuality", "orders", "users"]),
query: z.record(z.unknown()),
span: z.number().optional(),
}),
]),
),
});
组件内部解释 schema:
function DashboardLayout({ title, layout, blocks }: DashboardLayoutProps) {
return (
<section>
<h2>{title}</h2>
<div className={getGridClass(layout)}>
{blocks.map((block, index) => (
<div key={index} className={getSpanClass(block.span)}>
<DashboardBlock block={block} />
</div>
))}
</div>
</section>
);
}
function DashboardBlock({ block }: { block: DashboardBlock }) {
switch (block.type) {
case "metric":
return (
<MetricCard
title={block.title}
value={block.value}
intent={block.intent}
/>
);
case "chart":
return (
<ChartBlock
title={block.title}
source={block.source}
query={block.query}
/>
);
case "table":
return (
<TableBlock
title={block.title}
source={block.source}
query={block.query}
/>
);
}
}
数据流:
用户: 做一个东京天气仪表盘
-> LLM 调 show_component_DashboardLayout(...)
-> 后端流式发送 layout props
-> 前端形成 component content block
-> ComponentRenderer 渲染 DashboardLayout
-> DashboardLayout 解释 blocks
-> MetricCard / ChartBlock / TableBlock 分别渲染
-> ChartBlock / TableBlock 根据 source + query 调接口
9.1 复杂布局的三种模式
模式一: 组合型大组件
适合业务固定的复杂 UI:
WeatherDashboard
OrderDetailPanel
UserProfileOverview
SalesReport
ComparisonView
模型只负责生成业务 props:
{
"city": "Tokyo",
"sections": ["current", "forecast", "airQuality"]
}
组件内部决定布局. 优点是稳定, 缺点是灵活性较低.
模式二: Layout DSL 组件
适合模型动态组合页面的场景:
dashboard
report
analysis page
summary page
模型生成:
{
"layout": "threeColumn",
"blocks": [
{ "type": "metric", "title": "Revenue", "value": "$12,300" },
{ "type": "chart", "title": "Trend", "source": "salesTrend" },
{ "type": "table", "title": "Recent Orders", "source": "orders" }
]
}
前端解释:
block.type = metric -> MetricCard
block.type = chart -> ChartBlock
block.type = table -> TableBlock
这是最推荐的复杂布局方案: 灵活, 但仍然受控.
模式三: 多个 UI tool 连续调用
模型也可以连续调用多个组件:
show_component_SummaryCard
show_component_ForecastChart
show_component_AirQualityTable
这通常会在消息流里形成多个兄弟内容块. 它适合“聊天消息里连续展示多个结果”, 但不适合精确控制网格, 分栏, 嵌套布局.
如果要做真正复杂布局, 应该用一个父级布局组件包起来:
show_component_DashboardLayout({
layout,
blocks
})
9.2 复杂布局的约束
不要让模型生成任意 className:
{
"className": "absolute top-[-999px] ..."
}
不要让模型生成任意 CSS:
{
"style": "position: fixed; z-index: 999999"
}
不要让模型生成任意 URL:
{
"url": "/api/weather/forecast?city=Tokyo"
}
不要让模型生成 React 代码:
function CustomComponent() {
return <div>...</div>;
}
应该让模型只能选受控值:
layout: "singleColumn" | "twoColumn" | "threeColumn";
intent: "primary" | "success" | "warning" | "danger";
source: "weatherForecast" | "salesTrend" | "traffic";
span: 1 | 2 | 3;
接口绑定也应该通过 source + query:
{
"type": "chart",
"source": "weatherForecast",
"query": {
"city": "Tokyo",
"days": 7
}
}
而不是直接让模型控制完整 URL.
9.3 后续修改复杂布局
普通 generative layout:
用户: 把图表放到左边
-> 重新生成一个新的 DashboardLayout JSON
interactable layout:
用户: 把图表放到左边
-> 调 update_component_props/state 修改已有 layout
但是 update_component_props_<id> 通常更适合更新顶层 props. 如果要精确修改某个 block, 比如:
把第二个图表改成柱状图
更稳的做法是注册一个领域工具:
{
"tool": "update_dashboard_block",
"arguments": {
"componentId": "dashboard_1",
"blockId": "forecast",
"patch": {
"chartType": "bar"
}
}
}
也可以让布局 schema 给每个 block 一个稳定 id:
const DashboardBlockSchema = z.discriminatedUnion("type", [
z.object({
id: z.string(),
type: z.literal("metric"),
title: z.string(),
value: z.string(),
}),
z.object({
id: z.string(),
type: z.literal("chart"),
title: z.string(),
source: z.enum(["weatherForecast", "salesTrend"]),
chartType: z.enum(["line", "bar", "area"]),
}),
]);
这样后续修改可以基于稳定 blockId, 而不是依赖数组下标.
9.4 复杂布局总结
复杂布局不是让 Tambo 变成页面编译器, 而是注册一个布局解释器组件:
LLM 生成受控 JSON
-> DashboardLayout 解释 JSON
-> 映射到 MetricCard / ChartBlock / TableBlock
-> 每个 block 自己取数或接收 props
-> React 渲染真实 UI
推荐设计:
父级 Layout component:
负责 grid / section / tab / column / responsive 规则.
子级 Block components:
负责具体业务展示.
schema:
只暴露业务语义和有限布局枚举.
数据接口:
用 source/resourceId + query 白名单映射.
后续修改:
普通生成式组件重新生成 JSON.
interactable 组件用 update tool 原地修改.
10. 架构取舍总结
Tambo 的核心设计
不是: LLM 输出 JSX -> 前端编译 JSX
而是: LLM 调 UI tool -> 输出 JSON props -> 前端渲染预注册组件
组件清单的角色
components[]
-> availableComponents[]
-> show_component_* tools
组件清单主要变成工具定义, 不是单纯拼进提示词.
数据获取的选择
模型需要理解数据:
用 tool 获取数据, tool result 回到模型, 再生成组件 props.
模型只需要决定展示什么:
组件自己 fetch 数据, 模型只传 source/resourceId/filters.
动态接口的安全做法
不要让模型直接生成任意 URL.
让模型生成 source/resourceId.
前端或后端通过白名单解析真实接口.
流式与更新的区别
JSON Patch:
一次组件生成过程中的流式 props 增量更新.
update_component_props/state:
后续对已有 interactable 组件实例的原地部分更新.
重新 show_component_*:
普通生成式组件的新实例.
推荐模式
对大多数生产场景, 推荐:
预注册组件
+ 严格 props schema
+ source/resourceId 白名单
+ 组件内部 fetch 或后端代理
+ interactable 支持后续修改
+ JSON Patch 支持首轮流式渲染