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 组件,挂载时调用 useAssistantToolUIrender 注册到 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 响应式原语(tapStatetapEffecttapCallback)管理状态,遍历 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;
}

渲染优先级总结

优先级来源说明
1tools.Override全局覆盖所有工具调用渲染
2tools.by_name[toolName]用户在 components props 中指定的按名匹配
3tools.Fallback用户指定的兜底组件
4store.tools[toolName]通过 makeAssistantToolUI / useAssistantTool / <Tools> 注册的渲染器
5store.mcpApp.renderMCP 应用 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 继续

三种注册方式的区别

对比表

makeAssistantToolUIuseAssistantTool<Tools>
注册 UI 渲染器
注册工具定义到 model context
形式工厂函数,返回 React 组件React hook声明式 resource
适用场景只管渲染,工具定义在别处注册一次性完成定义+渲染批量注册多个工具
底层调用useAssistantToolUIuseAssistantToolUI + 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.tsmakeAssistantToolUI有(后端执行)
show_location后端 route.tsmakeAssistantToolUI有(后端执行)
select_date前端 useAssistantTool同一个 hook 内无(用户提交)
collect_contact前端 useAssistantTool同一个 hook 内无(用户提交)

后端工具在 route.ts 中通过 tool() 定义,有 execute 函数,LLM 调用时服务端自动执行。前端工具的 toolNamedescriptionparameters 会被序列化后发给后端,后端转成 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 的请求里,只有两条路:

  1. 后端在 streamText({ tools: { ... } }) 中定义
  2. 前端通过 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 特有的。常见解法:

  1. 动态注册:根据对话内容只发送相关工具,而不是全量。useAssistantTool 配合条件渲染可以实现——只在需要时挂载组件触发注册。
  2. 工具合并:把多个细粒度工具合并成一个通用工具,用 type 字段区分。比如 select_date + select_time + select_location 合并成 collect_user_input
  3. 后端路由:发给 LLM 的只有路由工具,实际执行时分发到具体实现。
  4. 两阶段调用:先让 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 ?? []),
  );
};

核心步骤:

  1. 字符串节点 → 直接返回文本
  2. 对象节点 → 用 component 字段查白名单
  3. 找到组件 → 递归渲染 children,然后 createElement
  4. 未找到且无 Fallback → 抛出 GenerativeUIRenderError
  5. 未找到但有 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 UIGenerative 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 的操作”。


关键设计点

  1. 消息不可变:parts 一旦完成就锁定,只能追加不能修改。这是对话式消息流的基础假设。
  2. 前端工具 vs 后端工具:前端工具没有 execute,通过 UI 组件的 addResult 由用户提交结果;后端工具在服务端自动执行,结果通过流返回。
  3. 流式友好args 在流式期间是部分解析的,渲染组件可以渐进显示。Generative UI Spec 同样支持流式——部分 spec 可以渐进渲染。
  4. 增量渲染:每个 tool-call part 都会触发前端渲染管线,有无渲染器决定是否显示。多条 tool-call 共存于同一消息的 content 数组中。
  5. 自动清理:注册通过 React useEffect 返回的 unsubscribe 函数,组件卸载时自动移除渲染器。
  6. 多重注册:同一 toolName 可以有多个渲染器(存储为数组),取第一个匹配。useInlineRender 支持运行时热替换组件而不丢失挂载状态。
  7. 安全边界:两条路径都有安全机制。Tool Call UI 的渲染器只能通过显式注册使用;Generative UI Spec 通过白名单限制可渲染的组件名。

关键源码文件索引

文件职责
packages/core/src/react/primitives/generativeUI/GenerativeUI.tsxSpec 驱动渲染核心(renderNode、GenerativeUIRender)
packages/core/src/types/message.tsGenerativeUISpec / ToolCallMessagePart 数据结构
packages/core/src/react/model-context/makeAssistantToolUI.tsToolUI 工厂函数
packages/core/src/react/model-context/useAssistantToolUI.tsToolUI 注册 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.tsToolsState 类型定义
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运行时组件热替换