跳到主要内容

第 5 章 · primitives、生成式 UI 与人在环

本章讲什么: 你最终摆在页面上的那些零件长什么样、怎么组合;以及两个高级能力——生成式 UI(把模型产出的 JSON 渲染成 React 组件)和人在环审批

5.1 primitives:无头、按作用域分族

assistant-ui 不给你一个大而全的 <ChatGPT />,而是给一堆零件,你拼装、上样式。这是 Radix 哲学(README「Customization」:「不是单一巨型组件,而是组合 primitives、自带样式」)。

零件按作用域分族,和第 4 章的 client scope 一一对应。看 packages/react/src/primitives/ 下的家族:

primitive 家族负责 UI 的哪块
ThreadPrimitive整个对话区:视口、消息列表、空态、自动滚动、建议
MessagePrimitive单条消息:它的各个片段、附件、错误、引用
MessagePartPrimitive单个内容片段(文本/工具调用/生成式 UI…)
ComposerPrimitive输入框:打字、发送、取消、附件、听写、队列
BranchPickerPrimitive同一句话多个回答之间切换
ActionBarPrimitive消息下方的操作条(复制、重试、反馈…)
ThreadListPrimitive / ThreadListItemPrimitive多线程的线程列表
ChainOfThoughtPrimitive推理 / 思维链展示
SuggestionPrimitive后续建议

这些 barrel 文件很薄,本身就是「零件清单」。例如 thread 家族的导出(packages/react/src/primitives/thread.ts):RootViewportMessagesEmptyScrollToBottomSuggestion……message 家族(packages/react/src/primitives/message.ts):RootPartsAttachmentsErrorGenerativeUIQuote……

新旧两套并存提醒: 不少 primitive 现在从 @assistant-ui/core/react re-export(如 message.ts 里的 QuoteGenerativeUIGroupedParts),这是第 0 章提到的「legacy → tap-only」迁移的痕迹(AGENTS.md:23)。读源码时别被「同一个零件两处定义」绕进去。

5.2 零件怎么读到数据:靠 context provider 注入作用域

零件本身不知道「我在渲染第几条消息」。是上层 provider 把作用域注进 context,零件再用 useAui/useAuiState 读。

packages/core/src/react/providers/ 这些 provider:MessageByIndexProviderPartByIndexProviderAttachmentByIndexProviderThreadListItemByIndexProvider…… 它们的套路就是第 4 章 useAui(clients) 高级重载:给当前子树挂上一个新作用域(比如「message = 第 i 条消息」),于是这个子树里所有 MessagePrimitiveaui.message() 就拿到第 i 条。

这样,ThreadPrimitive.Messages 遍历消息时,给每条包一个 MessageByIndexProvider,内部的零件就自动「锚定」到对应消息——零件可复用、数据靠 context 定位

5.3 生成式 UI:把模型的 JSON 安全渲染成组件

「生成式 UI」= 让模型不只吐文字,还能指定要渲染哪些组件。assistant-ui 的做法很克制也很安全。

模型产出一个 GenerativeUIMessagePart(packages/core/src/types/message.ts:114-121),里面是一棵 GenerativeUISpec——节点要么是字符串,要么是 { component, props, children }(message.ts:85-104)。

安全核心在 message.ts:76-96 的注释里写得很明确:

「消费方提供一个组件白名单(allowlist)来解析 component 名字。任何引用了不在白名单里的组件,都会被带类型的错误拒绝——白名单就是默认同源渲染路径下的安全边界。」

渲染时用 <MessagePrimitive.GenerativeUI components={...} />,把允许的组件传进去(message.ts:106-120 的注释)。它还流式友好:部分流入的 spec 会渐进渲染。

// 示意,非源码:生成式 UI 的安全渲染
<MessagePrimitive.GenerativeUI
components={{ WeatherCard, StockChart }} // 白名单:只有这些能被渲染
/>
// 模型若要求一个不在白名单里的组件 → 抛带类型错误,而不是渲染未知东西

妙处:模型只能说「渲染叫 X 的组件」,不能注入任意代码;X 必须在你给的白名单里。这把「让模型驱动 UI」的风险收敛成一个可控的名字解析。

5.4 人在环审批:数据 + 循环判定的合奏

第 2、3 章已埋好伏笔,这里合起来看。「人在环」= 模型要做某动作前,先让用户批准。它没有专门的子系统,而是两个已有机制的配合:

  1. 数据侧: ToolCallMessagePart.approval(message.ts:204-216)记录审批门状态——approved/reason/可选的 options(允许哪些决定)/resolution(被取消或过期)。注释强调 respondToApproval 只能在 approved 未定且无 resolution 时调用
  2. 循环侧: shouldContinue(should-continue.ts:15-22)一看到「有待审批的工具」就返回 false,暂停工具循环、把控制权还给 UI(见第 3 章)。

审批选项还挺细:ToolApprovalOption(message.ts:144-161)支持 allow-once/allow-always/reject-once/reject-alwayskind,可带 grants(这次批准会持久化哪些规则,提交前展示给用户)、可要求 confirm 二次确认。

设计取舍:把「人在环」拆成**「数据里有个 approval 字段」+「循环判定里有个分支」**,而不是一套独立引擎。这让它天然和工具调用、流式、UI 渲染共用一条主线——也是这个库反复出现的风格:用已有的最小机制拼出高级能力

5.5 横向对比(同 shelf 的取舍)

  • 相比直接用某个后端 SDK 自带的 UI(如 Vercel AI SDK 的 useChat + 自己写 JSX),assistant-ui 多给了组合式 primitives + 框架无关 core,代价是多一层抽象、要理解它的运行时模型。
  • 相比「黑盒聊天组件」,它走 Radix 路线:更难上手但天花板高(每个像素你都能改)。
  • 它最独特的资产是 tap(第 1 章):用「无头 hooks」做运行时,这在同类聊天 UI 库里很少见——多数库要么绑死 React 组件状态,要么外挂一个独立状态库。

5.6 代码地图

主题文件符号
primitive 家族清单packages/react/src/primitives/thread.tsmessage.tscomposer.tsactionBar.ts 等 barrel
作用域注入 providerpackages/core/src/react/providers/MessageByIndexProviderPartByIndexProviderThreadListItemByIndexProvider
生成式 UI 数据与安全边界packages/core/src/types/message.tsGenerativeUIMessagePartGenerativeUISpecGenerativeUINode
生成式 UI 渲染 primitivepackages/core/src/react/primitives/generativeUI/MessagePrimitiveGenerativeUI
审批数据packages/core/src/types/message.tsToolCallMessagePart.approvalToolApprovalOptionToolApprovalResponse
审批暂停循环packages/core/src/runtimes/local/should-continue.tsshouldContinue