assistant-ui 中的生成式 UI 全链路解析
assistant-ui 的”生成式 UI”有两条路径:
- 路径一:Tool Call UI(工具调用渲染):LLM 调用预定义工具,前端根据 toolName 查找对应的渲染组件。一对一绑定,每个工具有专属 UI。
- 路径二:Generative UI Spec(JSON 规范驱动渲染):LLM 直接输出一棵组件树(JSON spec),前端根据白名单解析并渲染。LLM 自由组合组件,灵活性更高。
路径一:Tool Call UI
核心思想
Tool Call UI 的本质是一个 toolName → React Component 的注册表 + 消息 part 的条件渲染机制。LLM 在流式响应中输出 tool-call 类型的 message part,框架根据 toolName 从注册表中查找对应的 React 组件来渲染。
1. 注册阶段:将渲染组件存入 Store
有三种注册方式,都最终调用同一个底层 aui.tools().setToolUI(toolName, render)。
方式 A:makeAssistantToolUI — 工厂函数(最常用)
// packages/core/src/react/model-context/makeAssistantToolUI.ts
const DatePickerToolUI = makeAssistantToolUI<Args, Result>({
toolName: "select_date",
render: ({ args, result, addResult }) => {
// 渲染逻辑
},
});
内部创建一个 React 组件,挂载时调用 useAssistantToolUI 将 render 注册到 store。卸载时自动清除。
返回的 DatePickerToolUI 本身也是一个 React 组件,需要在组件树中渲染才能触发注册:
<AssistantRuntimeProvider aui={aui} runtime={runtime}>
<DatePickerToolUI /> {/* 挂载 → 注册渲染器 */}
<Thread />
</AssistantRuntimeProvider>
方式 B:useAssistantTool — 同时注册工具定义 + 渲染器
// packages/core/src/react/model-context/useAssistantTool.ts
useAssistantTool({
toolName: "get_weather",
description: "获取城市天气",
parameters: z.object({ city: z.string() }),
render: WeatherToolUI, // 可选
});
与 makeAssistantToolUI 不同的是,它还把工具定义注册到 model context,让 LLM 知道可以调用这个工具。
方式 C:<Tools> resource — 批量声明式注册
// packages/core/src/react/client/Tools.ts
<Tools toolkit={{
get_weather: { description, parameters, render: WeatherUI },
select_date: { description, parameters, render: DatePickerUI },
}} />
内部使用 tap 响应式原语(tapState、tapEffect、tapCallback)管理状态,遍历 toolkit 中的每个工具调用 setToolUI。
底层存储结构
// packages/core/src/react/types/scopes/tools.ts
type ToolsState = {
tools: Record<string, ToolCallMessagePartComponent[]>; // toolName → 渲染组件数组
mcpApp?: McpAppResourceOutput; // MCP 应用渲染器
};
每个 toolName 对应一个数组(支持多个渲染器叠加)。setToolUI 返回一个 unsubscribe 函数用于移除:
const setToolUI = (toolName: string, render: ToolCallMessagePartComponent) => {
setToolsState((prev) => ({
tools: {
...prev.tools,
[toolName]: [...(prev.tools[toolName] ?? []), render],
},
}));
return () => {
setToolsState((prev) => ({
tools: {
...prev.tools,
[toolName]: prev.tools[toolName]?.filter((r) => r !== render) ?? [],
},
}));
};
};
2. 数据阶段:LLM 流式输出 tool-call part
// packages/core/src/types/message.ts
type ToolCallMessagePart<TArgs = JSONObject, TResult = unknown> = {
type: "tool-call";
toolCallId: string; // 本次调用的唯一 ID
toolName: string; // 工具名(匹配注册表 key)
args: TArgs; // LLM 传入的参数(流式时可能是部分解析)
result?: TResult; // 工具执行结果(完成后才有)
isError?: boolean; // 是否执行错误
argsText: string; // 原始 JSON 参数文本
interrupt?: { type: "human"; payload: unknown }; // 人类输入中断
messages?: readonly ThreadMessage[]; // 子代理对话
};
流式解析由 packages/assistant-stream/src/core/modules/tool-call.ts 中的 createToolCallStream 处理,将 SSE 流中的 tool-call chunks 组装成完整的 ToolCallMessagePart。
3. 分发阶段:消息 part 渲染器匹配
packages/core/src/react/primitives/message/MessageParts.tsx
当 MessagePrimitive.Parts 遍历消息的所有 part 时,遇到 type === "tool-call" 的 part:
// MessagePartComponent (line 379-392)
if (type === "tool-call") {
const addResult = aui.part().addToolResult;
const resume = aui.part().resumeToolCall;
// 优先级 1:全局覆盖
if ("Override" in tools)
return <tools.Override {...part} addResult={addResult} resume={resume} />;
// 优先级 2:用户指定的按名匹配
const Tool = tools.by_name?.[part.toolName] ?? tools.Fallback;
// 优先级 3:从 store 注册表中查找
return (
<ToolUIDisplay
{...part}
Fallback={Tool}
addResult={addResult}
resume={resume}
/>
);
}
ToolUIDisplay(line 305-318)从 store 中查找已注册的渲染器:
const ToolUIDisplay = ({ Fallback, ...props }) => {
const Render = useAuiState((s) => {
const Render = s.tools.tools[props.toolName] ?? Fallback;
if (Array.isArray(Render)) return Render[0] ?? Fallback;
return Render;
});
if (!Render) return null;
return <Render {...props} />;
};
还有一条独立路径 RegisteredToolUI(line 581-597),用于 DefaultPartFallback——当用户在 children render function 中返回 null 时,仍然自动渲染已注册的 tool UI:
const RegisteredToolUI = () => {
const aui = useAui();
const part = useAuiState((s) => s.part);
const Render = useAuiState((s) =>
s.part.type === "tool-call" ? resolveToolRender(s.tools, s.part) : null,
);
if (!Render || part.type !== "tool-call") return null;
return <Render {...part} addResult={aui.part().addToolResult} resume={aui.part().resumeToolCall} />;
};
resolveToolRender 支持 MCP 应用 URI 的额外匹配:
function resolveToolRender(toolsState, part) {
const entry = toolsState.tools[part.toolName];
const named = Array.isArray(entry) ? entry[0] ?? null : entry ?? null;
if (named) return named;
if (isMcpAppUri(part.mcp?.app?.resourceUri) && toolsState.mcpApp) {
return toolsState.mcpApp.render;
}
return null;
}
渲染优先级总结
| 优先级 | 来源 | 说明 |
|---|---|---|
| 1 | tools.Override | 全局覆盖所有工具调用渲染 |
| 2 | tools.by_name[toolName] | 用户在 components props 中指定的按名匹配 |
| 3 | tools.Fallback | 用户指定的兜底组件 |
| 4 | store.tools[toolName] | 通过 makeAssistantToolUI / useAssistantTool / <Tools> 注册的渲染器 |
| 5 | store.mcpApp.render | MCP 应用 URI 匹配 |
4. 渲染阶段:ToolUI 组件接收 props 并渲染
渲染组件接收的 props(ToolCallMessagePartProps):
type ToolCallMessagePartProps = MessagePartState & ToolCallMessagePart & {
addResult: (result: TResult | ToolResponse<TResult>) => void;
resume: (payload: unknown) => void;
};
典型前端工具渲染模式(日期选择器示例):
// examples/with-generative-ui/components/date-picker-tool-ui.tsx
export const DatePickerToolUI = makeAssistantToolUI<DatePickerArgs, DatePickerResult>({
toolName: "select_date",
render: function DatePickerUI({ args, result, addResult }) {
const [value, setValue] = useState("");
if (result) {
return (
<div className="my-2 flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 p-3">
<CheckCircle2Icon className="size-4 text-green-600" />
<span className="text-green-800 text-sm">
Selected: {new Date(result.date).toLocaleDateString()}
</span>
</div>
);
}
return (
<div className="my-2 rounded-lg border p-4">
<div className="mb-3 flex items-center gap-2">
<CalendarIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{args.prompt}</span>
</div>
<div className="flex items-center gap-2">
<input type="date" value={value} min={args.minDate} max={args.maxDate}
onChange={(e) => setValue(e.target.value)} />
<button disabled={!value}
onClick={() => addResult({ date: new Date(value).toISOString() })}>
Confirm
</button>
</div>
</div>
);
},
});
addResult 将结果写回消息流,LLM 收到后继续对话。
完整使用示例
// examples/with-generative-ui/app/page.tsx
function FrontendTools() {
useAssistantTool({
toolName: "select_date",
description: "Ask the user to select a date.",
parameters: z.object({ prompt: z.string() }),
});
return null;
}
export default function Home() {
const runtime = useChatRuntime({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
});
const aui = useAui({ ... });
return (
<AssistantRuntimeProvider aui={aui} runtime={runtime}>
<FrontendTools /> {/* 注册工具定义(让 LLM 能调用) */}
<DatePickerToolUI /> {/* 注册工具 UI 渲染器 */}
<ChartToolUI />
<LocationToolUI />
<ContactFormToolUI />
<main className="h-full">
<Thread />
</main>
</AssistantRuntimeProvider>
);
}
数据流图
用户注册 LLM 流式输出
│ │
▼ ▼
makeAssistantToolUI assistant-stream
useAssistantTool createToolCallStream
<Tools toolkit={...}> │
│ │
▼ ▼
aui.tools().setToolUI() ToolCallMessagePart {
│ type: "tool-call",
▼ toolName: "select_date",
ToolsState { args: { prompt: "..." }
tools: { }
"select_date": [DatePickerUI]
} │
} ▼
MessagePrimitive.Parts
│
▼ type === "tool-call"?
MessagePartComponent
│
┌────────────────┼────────────────┐
▼ ▼ ▼
tools.Override by_name[name] ToolUIDisplay
│
▼
resolveToolRender
(查注册表 / MCP)
│
▼
<DatePickerUI
args={...}
result={...}
addResult={fn}
/>
│
用户交互 → addResult()
│
▼
结果写回消息 → LLM 继续
三种注册方式的区别
对比表
makeAssistantToolUI | useAssistantTool | <Tools> | |
|---|---|---|---|
| 注册 UI 渲染器 | 是 | 是 | 是 |
| 注册工具定义到 model context | 否 | 是 | 是 |
| 形式 | 工厂函数,返回 React 组件 | React hook | 声明式 resource |
| 适用场景 | 只管渲染,工具定义在别处注册 | 一次性完成定义+渲染 | 批量注册多个工具 |
| 底层调用 | useAssistantToolUI | useAssistantToolUI + aui.modelContext().register() | setToolUI + aui.modelContext().register()(tap 响应式) |
注册范围不同
工具在 assistant-ui 中被拆成两半:
- 工具定义(toolName + description + parameters):让 LLM 知道有哪些工具可调用。注册到 model context,随对话请求发给 LLM。
- 工具渲染(React 组件):LLM 调用工具后,前端怎么显示这个调用过程。
三种方式的区别就是你管哪一半:
makeAssistantToolUI:只管渲染。工具定义在后端注册(比如 AI SDK 的tools配置),前端只负责”LLM 调了这个工具后怎么显示”。useAssistantTool:两端都管。既把定义告诉 LLM,又注册前端渲染器。用于前端工具——没有后端 execute,用户在 UI 里操作后通过addResult提交结果。<Tools>:和useAssistantTool本质相同,只是批量声明式写法。
它们最终都走到同一个存储 ToolsState.tools[toolName]。
前端工具 vs 后端工具
以 with-generative-ui 示例为例:
| 工具 | 定义位置 | 渲染注册 | 有 execute |
|---|---|---|---|
generate_chart | 后端 route.ts | makeAssistantToolUI | 有(后端执行) |
show_location | 后端 route.ts | makeAssistantToolUI | 有(后端执行) |
select_date | 前端 useAssistantTool | 同一个 hook 内 | 无(用户提交) |
collect_contact | 前端 useAssistantTool | 同一个 hook 内 | 无(用户提交) |
后端工具在 route.ts 中通过 tool() 定义,有 execute 函数,LLM 调用时服务端自动执行。前端工具的 toolName、description、parameters 会被序列化后发给后端,后端转成 AI SDK 格式传给 LLM:
// route.ts
const { tools: clientTools } = await req.json();
const frontendToolDefs = Object.entries(clientTools).map(([name, def]) => ({
description: def.description,
inputSchema: jsonSchema(def.parameters),
}));
streamText({
tools: {
...frontendToolDefs, // 前端工具(没有 execute)
generate_chart: ..., // 后端工具(有 execute)
},
});
常见问题
只用 makeAssistantToolUI,后端也没写工具,会怎样?
LLM 完全不知道这些工具的存在。makeAssistantToolUI 只在 React 端注册了渲染器到 ToolsState.tools,不会把 toolName、description、parameters 发送给 LLM。
要让 LLM 知道并能调用工具,工具定义必须出现在发给 LLM 的请求里,只有两条路:
- 后端在
streamText({ tools: { ... } })中定义 - 前端通过
useAssistantTool注册,框架会自动把定义序列化发给后端
如果两边都没注册工具定义,渲染器永远不会被触发——LLM 不会输出对应的 tool-call part。等于写了一段没人调用的 UI 代码。
makeAssistantToolUI 的正确用法是配合后端已有的工具定义——后端定义了 generate_chart,前端用 makeAssistantToolUI 配上渲染器。缺一不可。
一个组件对应一个工具吗?
makeAssistantToolUI / useAssistantTool 是一对一的,一个调用只注册一个 toolName 的渲染器。
但也可以用一个组件处理多个 toolName 的调用,通过 components.tools 配置:
components={{
tools: {
Override: MyGlobalToolUI, // 一个组件覆盖所有工具调用
by_name: {
get_weather: WeatherUI, // 按名匹配(一对一)
select_date: DatePickerUI,
},
Fallback: GenericToolUI, // 没匹配上时的兜底(一对多)
},
}}
另外 store 中 tools[toolName] 是数组,同一 toolName 可以注册多个渲染器,但只取 Array[0]。useInlineRender 利用这个机制实现运行时热替换。
几十个工具会不会过多占用 LLM 上下文?
会。每个工具定义(toolName + description + parameters JSON Schema)都占 token,几十个工具可能吃掉几千 token 的上下文。
这是 function calling 的通用问题,不是 assistant-ui 特有的。常见解法:
- 动态注册:根据对话内容只发送相关工具,而不是全量。
useAssistantTool配合条件渲染可以实现——只在需要时挂载组件触发注册。 - 工具合并:把多个细粒度工具合并成一个通用工具,用 type 字段区分。比如
select_date+select_time+select_location合并成collect_user_input。 - 后端路由:发给 LLM 的只有路由工具,实际执行时分发到具体实现。
- 两阶段调用:先让 LLM 意图分类(少量工具),再根据分类结果注入对应工具集做第二次调用。
主流模型(GPT、Claude)支持 128+ 工具不会明显降级,但如果 description 和 parameters schema 复杂,几十个工具确实会挤压可用上下文。建议控制在 10-20 个以内,或按场景动态裁剪。
路径二:Generative UI Spec
核心思想
与 Tool Call UI 的”一个工具对应一个组件”不同,Generative UI Spec 让 LLM 自己组合 UI。LLM 在消息中输出一棵 JSON 组件树,前端用一个白名单(allowlist)映射组件名到真实 React 组件,然后递归渲染整棵树。
关键区别:
- Tool Call UI:LLM 只决定调用哪个工具,UI 形态是固定的
- Generative UI Spec:LLM 决定用哪些组件、怎么组合、传什么 props,UI 形态由 LLM 动态构建
1. 数据格式:GenerativeUISpec
LLM 在流式响应中输出 generative-ui 类型的 message part:
// packages/core/src/types/message.ts
type GenerativeUIMessagePart = {
readonly type: "generative-ui";
readonly spec: GenerativeUISpec;
readonly id?: string;
};
type GenerativeUISpec = {
readonly root: GenerativeUINode | readonly GenerativeUINode[];
};
type GenerativeUINode =
| string // 纯文本叶子节点
| {
readonly component: string; // 组件名(在白名单中查找)
readonly props?: Record<string, unknown>; // 传给组件的 props(必须是 JSON 可序列化的)
readonly children?: readonly GenerativeUINode[]; // 子节点(递归)
readonly key?: string; // 可选的 React 稳定 key
};
一个实际的 spec 示例:
{
type: "generative-ui",
spec: {
root: [
{
component: "Card",
props: { title: "Welcome", description: "Agent 描述的卡片" },
children: [
{
component: "Stack",
props: { direction: "row", gap: "md" },
children: [
{ component: "Stat", props: { label: "Revenue", value: "$124k" } },
{ component: "Stat", props: { label: "Users", value: "8.2k" } },
{ component: "Button", props: { label: "Get started", variant: "primary" } },
],
},
],
},
{
component: "Card",
props: { title: "Stats" },
children: [
"这是一段纯文本,直接作为子节点渲染",
],
},
],
},
}
纯 JSON 格式,LLM 很容易生成,也方便在服务端做校验。
2. 白名单:安全边界
白名单是一个 Record<string, ComponentType>,定义了 LLM 允许使用哪些组件。不在白名单中的组件名会抛出 GenerativeUIRenderError:
// examples/with-generative-ui/components/gui/index.tsx
export const componentsAllowlist = {
Card, // 卡片容器
Button, // 按钮
Stack, // 布局
Heading, // 标题
Text, // 文本
Stat, // 统计数字
} as const;
白名单中的每个组件都是普通的 React 组件,接收 LLM 传来的 props:
const Card: ComponentType<PropsWithChildren<{ title?: string; description?: string }>> =
({ title, description, children }) => (
<div className="rounded-xl border bg-card p-4 shadow-sm">
{title ? <div className="font-semibold text-base">{title}</div> : null}
{description ? <div className="mt-1 text-muted-foreground text-sm">{description}</div> : null}
{children ? <div className="mt-3">{children}</div> : null}
</div>
);
安全要点:
- 白名单控制的是”哪些组件能渲染”,不是”传什么 props”
- LLM 可以给白名单中的组件传任意 props(只要 JSON 可序列化)
- 因此白名单中的组件必须把 props 当作不可信输入处理——不要用
dangerouslySetInnerHTML,校验href/src等字段,避免将 props 传入可执行上下文 - 最安全的白名单组件只接收展示型 props(字符串、数字)
3. 渲染核心:renderNode 递归
packages/core/src/react/primitives/generativeUI/GenerativeUI.tsx
渲染过程是纯递归树遍历:
const renderNode = (
node: GenerativeUINode | undefined,
components: GenerativeUIComponentRegistry, // 白名单
Fallback: GenerativeUIRenderProps["Fallback"],
path: string, // 用于 React key
): ReactNode => {
// 基础情况
if (node === undefined || node === null) return null;
if (typeof node === "string") return node; // 纯文本直接返回
// 验证节点结构
if (!isObjectNode(node) || !("component" in node)) {
console.warn(`[generative-ui] Skipping malformed node at ${path}:`, node);
return null;
}
const { component, props, children, key } = node;
// 查白名单
const Resolved = components[component];
if (!Resolved) {
if (Fallback) {
return <Fallback key={key ?? path} component={component} props={props} />;
}
throw new GenerativeUIRenderError(component); // 不在白名单 → 抛错
}
// 递归渲染子节点
const renderedChildren = children?.length
? children.map((child, i) => renderNode(child, components, Fallback, `${path}/${i}`))
: undefined;
// 创建 React 元素
return createElement(
Resolved,
{ ...(props ?? {}), key: key ?? path },
...(renderedChildren ?? []),
);
};
核心步骤:
- 字符串节点 → 直接返回文本
- 对象节点 → 用
component字段查白名单 - 找到组件 → 递归渲染 children,然后
createElement - 未找到且无 Fallback → 抛出
GenerativeUIRenderError - 未找到但有 Fallback → 渲染 Fallback 组件
4. 使用方式
方式 A:通过 MessagePrimitive.Parts 的 components 配置
// 最常用,和其他 part 类型统一配置
<MessagePrimitive.Parts
components={{
generativeUI: {
components: componentsAllowlist,
Fallback: ({ component }) => <span>unknown: {component}</span>,
},
tools: { by_name: { ... } },
}}
/>
当 MessagePartComponent 遇到 type === "generative-ui" 的 part 时:
// MessageParts.tsx line 422-443
case "generative-ui": {
if (!generativeUI?.components) {
// 没配置白名单 → 警告并跳过
console.warn("received a generative-ui part but no allowlist was provided.");
return null;
}
return (
<GenerativeUIRender
spec={part.spec}
components={generativeUI.components}
Fallback={generativeUI.Fallback}
/>
);
}
方式 B:使用 MessagePrimitive.GenerativeUI 独立原语
// 手动处理每个 part 类型
<MessagePrimitive.Parts>
{({ part }) => {
if (part.type === "generative-ui") {
return (
<MessagePrimitive.GenerativeUI
components={componentsAllowlist}
Fallback={UnknownComponentFallback}
/>
);
}
return null;
}}
</MessagePrimitive.Parts>
MessagePrimitive.GenerativeUI 从 store 中读取当前 part 的 spec:
// GenerativeUI.tsx line 160-182
const MessagePrimitiveGenerativeUI = ({ components, spec, Fallback }) => {
const storeSpec = useAuiState((s) => {
const part = s.part as { type?: string; spec?: GenerativeUISpec };
return part?.type === "generative-ui" ? part.spec : undefined;
});
const partSpec = spec ?? storeSpec;
if (!partSpec) return null;
return <GenerativeUIRender spec={partSpec} components={components} Fallback={Fallback} />;
};
方式 C:直接使用 GenerativeUIRender(不需要消息流)
// 脱离消息流,直接渲染一个 spec
<GenerativeUIRender
spec={exampleSpec}
components={componentsAllowlist}
Fallback={UnknownComponentFallback}
/>
适用于预览、demo、静态渲染等场景。
5. 流式渲染
Generative UI Spec 是流式友好的:
- spec 是 JSON,流式传输时可以渐进解析
- 已到达的节点立即渲染,未到达的部分不显示
- 已挂载的子组件不会因为后续节点的到达而丢失状态(因为
key是基于路径的)
// 部分到达时
{ root: { component: "Card", props: { title: "loading" } } }
// → 渲染一个只有标题的卡片
// 完整到达后
{ root: { component: "Card", props: { title: "Stats" }, children: [
{ component: "Stat", props: { label: "Revenue", value: "$124k" } }
] } }
// → 渲染完整的卡片 + 统计数字
normalizeRoot 函数处理流式情况下的 null/undefined/array 边界:
const normalizeRoot = (spec: GenerativeUISpec | undefined): readonly GenerativeUINode[] => {
if (!spec || spec.root === undefined || spec.root === null) return [];
return Array.isArray(root) ? root : [root];
};
6. 错误处理
// 方式 1:Error Boundary 捕获 GenerativeUIRenderError
<ErrorBoundary fallback={<div>渲染失败</div>}>
<MessagePrimitive.GenerativeUI components={allowlist} />
</ErrorBoundary>
// 方式 2:Fallback 组件优雅降级
<MessagePrimitive.GenerativeUI
components={allowlist}
Fallback={({ component }) => (
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
unknown component: {component}
</span>
)}
/>
GenerativeUIRenderError 是一个有类型的 Error 子类:
class GenerativeUIRenderError extends Error {
public readonly componentName: string;
constructor(componentName: string, message = `Component "${componentName}" is not in the allowlist.`) {
super(message);
this.name = "GenerativeUIRenderError";
this.componentName = componentName;
}
}
两条路径对比
| Tool Call UI | Generative UI Spec | |
|---|---|---|
| 触发方式 | LLM 调用 function calling 工具 | LLM 在消息中输出 JSON spec |
| LLM 的自由度 | 只能选”调哪个工具”,UI 形态固定 | 自由组合组件、传 props、嵌套 |
| 组件绑定 | 一个 toolName 绑定一个组件 | 多个组件名映射到一个白名单 |
| 安全边界 | 只能触发已注册的渲染器 | 只能使用白名单中的组件名 |
| 用户交互 | addResult 让用户提交结果 | 纯展示(或通过 onClickPrompt 触发对话) |
| 适用场景 | 表单、选择器、确认对话框等固定交互 | 仪表盘、状态面板、动态卡片组合等灵活布局 |
| 数据来源 | tool-call part(有 args + result) | generative-ui part(有 spec) |
| 粒度 | 一个工具一个组件 | 一棵组件树 |
Tool Call UI 适合交互型场景:LLM 调用工具,用户在 UI 中操作,通过 addResult 返回结果给 LLM。每个工具有固定的 UI 形态。
Generative UI Spec 适合展示型场景:LLM 从一个组件库中自由组合,生成仪表盘、状态面板、多步骤流程等动态布局。不需要预定义每个组合,LLM 在运行时决定。
消息更新机制:增量追加,不可修改
消息内容是增量追加的
消息的 content 数组在流式过程中只增不减。updateMessage 的核心逻辑:
// local-thread-runtime-core.ts:316-363
const initialContent = message.content; // 记住本轮开始时的 parts
const updateMessage = (m: Partial<ChatModelRunResult>) => {
message = {
...message,
// 新 content = 初始 parts + 新到达的 parts,不是替换
...(m.content
? { content: [...initialContent, ...(m.content ?? [])] }
: undefined),
};
this.repository.addOrUpdateMessage(parentId, message);
};
每条消息对象整体替换(不可变更新模式),但 content 数组是累积增长的。
一个完整的消息可能是:
content: [
{ type: "text", text: "让我帮你查一下天气和股票。" },
{ type: "tool-call", toolName: "get_weather", toolCallId: "1", args: { city: "北京" } },
{ type: "tool-call", toolName: "get_stock", toolCallId: "2", args: { symbol: "AAPL" } },
{ type: "text", text: "北京今天晴,25°C。苹果股票 $198。" },
]
MessagePrimitive.Parts 遍历数组,每个 part 独立渲染,互不覆盖。用户看到的效果像打字机:文字出现 → 工具 A 的 UI 弹出 → 工具 B 的 UI 弹出 → 结果文字出现。
每个 tool-call 都会触发前端渲染
LLM 每输出一个 tool-call part,都会流到前端进入渲染管线,不管有没有注册渲染器:
| 情况 | 结果 |
|---|---|
| 有注册渲染器 | 显示 ToolUI 组件 |
| 没注册渲染器,没配置 Fallback | 不显示任何东西(渲染 null) |
| 没注册渲染器,配置了 Fallback | 显示 Fallback 组件 |
即使是纯后端工具(比如查数据库),tool-call part 也会到达前端。不注册渲染器的话用户看不到中间过程,只会看到最终文本回复。
已渲染的内容不可被 LLM 修改
消息 parts 一旦完成就不可变。LLM 不能回头修改已有 part 的 args 或 spec。
如果 LLM 想”改颜色”,只能发一个新的 tool-call 或新的 generative-ui part——这是两个独立的 part,不会替换第一个。
渲染组件内部可以有 React state,实现用户交互式的 UI 变化(比如点击按钮改颜色),但这属于组件自身的行为,不是 LLM 驱动的。
Generative UI Spec 的流式完善
Spec 在流式阶段可以逐步填充——先传 { component: "DatePicker" },再补全 { component: "DatePicker", props: { color: "blue" } }。但这只发生在同一条消息的流式传输期间,消息完成后 spec 就锁定了。
适用边界:对话式消息 vs 低代码画布
assistant-ui 的设计定位
assistant-ui 的核心模型是对话式消息流:
- 消息是线性的、追加的、不可变的
- 每个 part 占一个位置,只增不改
- LLM 每轮产出,用户消费
- 渲染组件可以有内部 state,但 LLM 无法驱动已有组件的属性变更
与低代码画布的本质区别
| 对话式消息(assistant-ui) | 低代码画布 | |
|---|---|---|
| 内容生命周期 | 产出即锁定 | 持续编辑、反复修改 |
| 更新粒度 | 追加新 part | 修改已有元素的任意属性 |
| 空间模型 | 线性列表(一条接一条) | 二维画布(位置、层级、嵌套) |
| 驱动方式 | LLM 每轮产出,用户消费 | LLM 作为操作者,画布作为被操作对象 |
| 状态归属 | 消息不可变,渲染组件有内部 state | 画布状态需要集中管理、可回滚 |
| LLM 输出内容 | 最终 UI(spec)或固定交互(tool-call) | 操作指令(diff/patch) |
低代码画布的合适架构
如果要用 LLM 驱动低代码画布,assistant-ui 的 Generative UI Spec 不合适——它是一次性的,输出完就固化在消息里。
更合适的架构:
- 画布状态:独立的 JSON 数据结构(描述组件树、属性、布局),存在一个可读写的 store 中
- LLM 角色:输出操作指令(如
{ op: "update", target: "button-1", props: { color: "blue" } }),而不是输出最终 UI - 渲染层:监听画布 store 变化,重新渲染画布。修改的是同一棵组件树,不是追加新的
核心区别:assistant-ui 让 LLM 输出的是”UI 本身”,低代码画布需要 LLM 输出的是”对已有 UI 的操作”。
关键设计点
- 消息不可变:parts 一旦完成就锁定,只能追加不能修改。这是对话式消息流的基础假设。
- 前端工具 vs 后端工具:前端工具没有
execute,通过 UI 组件的addResult由用户提交结果;后端工具在服务端自动执行,结果通过流返回。 - 流式友好:
args在流式期间是部分解析的,渲染组件可以渐进显示。Generative UI Spec 同样支持流式——部分 spec 可以渐进渲染。 - 增量渲染:每个 tool-call part 都会触发前端渲染管线,有无渲染器决定是否显示。多条 tool-call 共存于同一消息的 content 数组中。
- 自动清理:注册通过 React
useEffect返回的 unsubscribe 函数,组件卸载时自动移除渲染器。 - 多重注册:同一 toolName 可以有多个渲染器(存储为数组),取第一个匹配。
useInlineRender支持运行时热替换组件而不丢失挂载状态。 - 安全边界:两条路径都有安全机制。Tool Call UI 的渲染器只能通过显式注册使用;Generative UI Spec 通过白名单限制可渲染的组件名。
关键源码文件索引
| 文件 | 职责 |
|---|---|
packages/core/src/react/primitives/generativeUI/GenerativeUI.tsx | Spec 驱动渲染核心(renderNode、GenerativeUIRender) |
packages/core/src/types/message.ts | GenerativeUISpec / ToolCallMessagePart 数据结构 |
packages/core/src/react/model-context/makeAssistantToolUI.ts | ToolUI 工厂函数 |
packages/core/src/react/model-context/useAssistantToolUI.ts | ToolUI 注册 hook |
packages/core/src/react/model-context/useAssistantTool.ts | 同时注册工具定义 + 渲染器 |
packages/core/src/react/client/Tools.ts | 批量声明式注册(tap 响应式) |
packages/core/src/react/primitives/message/MessageParts.tsx | 消息 part 分发与渲染(两条路径的统一入口) |
packages/core/src/react/types/scopes/tools.ts | ToolsState 类型定义 |
packages/core/src/react/types/MessagePartComponentTypes.ts | 渲染组件 props 类型 |
packages/assistant-stream/src/core/modules/tool-call.ts | 流式 tool-call 解析 |
packages/core/src/react/model-context/useInlineRender.ts | 运行时组件热替换 |