Skip to content
assistant-ui 架构分析:现代 AI Chat UI 的组件设计
概述:assistant-ui 的定位与设计目标
assistant-ui 是一个开源 TypeScript/React 库,用于构建 AI 聊天界面。它提供的不是单个大而全的 <Chat> 组件,而是一组可组合的原语:Thread(线程)、Message(消息)、Composer(输入区)、ThreadList(线程列表)、ActionBar(操作栏)等。这些原语覆盖了 AI 聊天界面的常见需求:流式输出、自动滚动、重试、附件、Markdown 渲染、代码高亮、语音听写、键盘快捷键和无障碍支持。[1]
从架构位置看,assistant-ui 位于 LLM API 与用户界面之间。后端 AI 服务通过适配器(adapter)接入,前端通过 hooks 获得状态,组件消费这些状态完成渲染。
领域模型:消息、线程与运行状态
Message 与 Thread
在 AI 助理场景中,Thread(线程)是会话的容器,Message(消息)是线程内由用户或助手创建的通信单元。这两个概念与 OpenAI Assistants API 中的 Thread 和 Message 存在对应关系,但 assistant-ui 作为 UI 组件库,其领域模型不等同于任何特定后端 API。[6]
一个消息的简化结构如下:
typescript
interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: ContentPart[];
}content 不是纯字符串,而是内容分段(ContentPart)的数组。在 OpenAI Assistants API 中,消息内容同样由类型化分段构成,[6] assistant-ui 沿用这一思路,并把工具调用与工具结果也建模为分段。下面是一个简化的联合类型,展示分段如何按 type 判别:
typescript
type ContentPart =
| { type: 'text'; text: string }
| { type: 'tool-call'; toolName: string; args: string }
| { type: 'tool-result'; toolName: string; result: string };例如,一条助手消息可能包含一段文本、一次工具调用和对应的工具结果。UI 在渲染时遍历 content 数组,根据每个分段的 type 决定渲染方式:文本分段渲染为普通文本,工具调用分段渲染为卡片式组件。
Run、Step 与流式状态
Run 是对线程的一次调用。在一次 Run 中,助手可能生成一条或多条消息,也可能执行工具调用。Run 的状态包括排队(queued)、进行中(in_progress)、已完成(completed)等。[6]
注意:Run 处于 in_progress 时线程被锁定,不能添加新消息或创建新 Run,这是 OpenAI Assistants API 的后端约束。[6] 如果后端采用该约束,UI 需要相应处理:将输入框置为禁用,或阻止重复提交。
Run Step 是 Run 内部执行步骤的详细列表,可以包含消息创建步骤或工具调用步骤。[6] UI 组件库不一定渲染全部 Step,但需要展示“助手正在思考”或工具执行进度时,Step 是重要的数据来源。
包结构与模块边界:core 与 React 绑定
assistant-ui 采用 monorepo 管理。核心包 @assistant-ui/core 是框架无关的核心层,定义共享类型、运行时接口与适配器契约;实际使用时,多数应用不应直接安装 core,而应使用与目标平台匹配的分发包:[2]
@assistant-ui/react:Web 平台@assistant-ui/react-native:React Native 平台@assistant-ui/react-ink:终端 / Ink 应用
这种划分使核心逻辑与 UI 框架解耦:协议与类型定义位于 core,React 绑定层消费这些接口,并经由 @assistant-ui/react 等分发包对外暴露。
根据官方架构说明,assistant-ui 正在从旧的 legacy runtime 迁移到新的 tap-only 架构(基于 @assistant-ui/tap 反应式原语的新核心)。[3] 新核心构建在两种运行时之上:
useExternalStoreRuntime:消息来自外部源useLocalRuntime+ChatModelAdapter:无服务端线程状态
多线程列表场景可使用 useRemoteThreadListRuntime 包装。运行时状态通过 createRuntimeExtras 暴露给访问器 hooks,而不是手写的 Symbol 品牌。[3]
依赖方向可以概括为:
core(类型与契约) ← React 绑定(hooks 与组件) ← adapter(对接后端)状态管理:服务端状态同步与数据流
AI 聊天界面的核心复杂度在于:后端异步生成内容,前端需要同步展示。assistant-ui 的状态管理围绕这个目标设计。
流式消息的更新流程
一个典型的流式数据流如下:
- 用户提交消息,前端调用后端的流式接口
- 后端返回流,通过 SSE、ReadableStream 等方式分段推送
- 前端将流中的数据转换为消息分段(text / tool-call 等)
- 运行时状态更新,组件重新渲染
官方示例项目 assistant-ui-sync-server 展示了流式场景中的一个关键技术:通过 ReadableStream.tee() 将后端响应分叉为多条流。[5]
- 第一条流内部缓冲所有数据,用于后续恢复调用,在内存压力下可能被驱逐
- 第二条流管理生命周期,跟踪完成状态并调度垃圾回收
- 第三条流发送给原始客户端
这个示例说明了一个约束:流的消费是单向的,一旦消费就无法回头。如果同时需要存储、管理和转发,必须提前分叉。
外部存储模式
在消息已经存在于服务端的场景中,可使用 useExternalStoreRuntime。此模式下,服务端是消息的唯一事实来源,客户端不直接写入消息。[3]
tsx
import { useExternalStoreRuntime } from '@assistant-ui/react';
function ChatRuntime({ messages, onSend }) {
const runtime = useExternalStoreRuntime({
messages,
onNew: async (message) => {
await onSend(message);
},
});
return runtime;
}useExternalStoreRuntime 接收当前消息列表和 onNew 回调。用户提交消息时,onNew 被调用;外部状态管理逻辑完成发送后,将新的消息数组传回 messages,运行时据此更新 UI。
本地运行模式
useLocalRuntime 适用于无服务端线程状态的场景。它接收一个 ChatModelAdapter,由 adapter 定义消息如何发送给模型:[3]
tsx
import { useLocalRuntime, type ChatModelAdapter } from '@assistant-ui/react';
const adapter: ChatModelAdapter = {
async run({ messages, tools }) {
return myModel.streamResponse(messages, tools);
},
};
function App() {
const runtime = useLocalRuntime(adapter);
return <Thread runtime={runtime} />;
}run 方法接收会话中的 messages 和可用 tools,返回符合运行时约定的异步迭代流或可读流。assistant-ui 不关心后端的具体实现,只要 adapter 返回的流符合规范即可。
核心 Hooks:useThread 与 useMessages
在组件层,assistant-ui 通过访问器 hooks 暴露运行时状态。useThread 和 useMessages 是两个常用入口,分别用于读取当前线程上下文和消息列表。
注:访问器 hooks 在版本迭代中调整过多次,字段名在不同版本间可能有差异。下面的示例展示使用模式,字段名以所安装版本的类型定义为准。
tsx
import { useThread } from '@assistant-ui/react';
function ThreadStatus() {
const thread = useThread();
const isRunning = thread.isRunning;
return <div>{isRunning ? '正在生成' : '空闲'}</div>;
}tsx
import { useMessages } from '@assistant-ui/react';
function MessageCount() {
const messages = useMessages();
return <div>{messages.length} 条消息</div>;
}从实现原理看,这些访问器 hooks 从 AssistantRuntimeProvider 提供的运行时上下文中读取状态。它们的工作方式与 React 的 useContext 类似,但带有选择器语义:当被读取的状态片段变化时,组件重新渲染。
在较新的架构中,运行时状态通过 createRuntimeExtras 暴露,访问器 hooks 不再依赖手写的 Symbol 品牌。[3] 这意味着运行时可以附加额外的自定义状态,而 hooks 仍然能类型安全地读取。
组件系统与 Headless 设计
Thread 的组合结构
<Thread> 是聊天界面的顶层容器,内部组合多个子组件:[1]
Composer:输入区,负责文本输入、附件上传和提交MessageList:消息列表,支持自动滚动ActionBar:操作栏,提供复制、重试、朗读等操作
这些组件都可以替换或重新组合。assistant-ui 的定位接近 headless UI 库:默认提供完整样式,但允许开发者脱离默认 UI,只使用 hooks 和状态原语构建自定义界面。[1]
受控与非受控模式
与表单组件类似,assistant-ui 同时支持受控与非受控模式。
非受控模式下,开发者只提供 runtime,消息状态由运行时内部管理:
tsx
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>受控模式下,开发者完全控制消息数组和更新逻辑,适合与外部状态管理工具(如 Redux、Server State)集成:
tsx
const runtime = useExternalStoreRuntime({
messages: myMessagesFromStore,
onNew: dispatchNewMessage,
});自定义消息渲染
由于消息内容由 ContentPart 组成,自定义消息渲染需要处理不同类型的分段。若直接将整个 content 当字符串渲染,会丢失工具调用等结构化信息。
以文本分段为例:
tsx
function MyTextPart({ part }: { part: { type: 'text'; text: string } }) {
return <p className="my-text">{part.text}</p>;
}自定义整个消息的渲染时,需要遍历 content 数组:
tsx
function MyMessage({ message }) {
return (
<div>
{message.content.map((part, index) => {
switch (part.type) {
case 'text':
return <MyTextPart key={index} part={part} />;
case 'tool-call':
return <MyToolCallCard key={index} part={part} />;
default:
return null;
}
})}
</div>
);
}message.content 是分段数组,需要逐段处理。角色(role)决定消息泡风格,分段的 type 决定消息内部结构。在无障碍方面,文本分段建议使用原生 <p> 或 <div> 元素渲染,以保留屏幕阅读器可识别的语义;工具调用卡片则应提供可访问的名称和状态描述。
工具调用 UI 建模
工具调用是 AI 聊天界面的特殊场景。当模型决定调用工具时,消息中出现 tool-call 分段。UI 需要展示:
- 工具名称
- 参数(通常是 JSON 字符串形式)
- 执行状态(等待中 / 已完成 / 失败)
工具调用的生命周期通常跨越多个状态:模型返回工具调用 → 客户端执行 → 返回结果。assistant-ui 将工具调用建模为 ContentPart,因此工具调用的状态自然嵌入消息流中。对 UI 实现者而言,工具调用分段的渲染应独立于文本分段,避免混淆。
Provider 与 Adapter:接入后端 AI 服务
AssistantRuntimeProvider
AssistantRuntimeProvider 是 React 上下文提供者,将 runtime 注入组件树。所有访问器 hooks 和组件都依赖它:
tsx
import { AssistantRuntimeProvider, Thread } from '@assistant-ui/react';
import { useLocalRuntime } from '@assistant-ui/react';
function App() {
const runtime = useLocalRuntime(myAdapter);
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
);
}与 Vercel AI SDK 的集成
@assistant-ui/react-ai-sdk 是 Vercel AI SDK 的集成包。[4] useChatRuntime 默认使用 AssistantChatTransport,将前端系统消息和工具定义转发到后端。也可以传入配置的 AssistantChatTransport 自定义 API URL、缓存等,或使用 DefaultChatTransport 退出转发。[4]
tsx
import { useChatRuntime } from '@assistant-ui/react-ai-sdk';
import { AssistantRuntimeProvider, Thread } from '@assistant-ui/react';
function App() {
const runtime = useChatRuntime();
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
);
}transport 与 runtime 解耦,使同一个 runtime 可以对接不同类型的后端传输,而不需要修改组件层代码。
耦合策略
对于宿主提供的上游 SDK(如 react-langchain、react-langgraph、react-google-adk、react-pi、react-hook-form),assistant-ui 保持宽泛的 peerDependency 下限,使其低于开发时测试固定的版本。仅当代码确实需要更新 API 时才提高下限;可选的依赖在 peerDependenciesMeta 中标记为 optional。[3]
协议层面的耦合在仓库内实现和版本化(assistant-stream、react-a2a、react-generative-ui),上游变更作为增量解码器分支处理。[3] 这种策略允许 assistant-ui 在不破坏 consumer 的情况下适配变化中的 AI 生态。
工程实践:TypeScript、性能与无障碍
TypeScript 类型约束
assistant-ui 的公开包表面是 append-only 的:若移动导出应重指向新文件,但绝不删除已发布的导出。仓库内部审计无法看到 npm 消费者的使用情况,因此即使看似无用的类型删除也属于破坏性变更;行为变更应作为独立的 PR 发布。[3]
这个策略对库使用者是友好的:升级版本时不必担心导出的静默消失。但对维护者意味着额外的克制:新类型的引入需要评估与旧版本的兼容性。
性能:长列表与虚拟列表
AI 对话会随使用增长为长列表。全量渲染数千条消息会导致明显的性能退化。assistant-ui 支持虚拟列表,只渲染视口附近的消息,显著减少 DOM 节点数。虚拟列表引入的额外约束是:每个消息项需要稳定的高度(或可动态计算),滚动位置需要精心管理。自动滚动与虚拟列表配合使用时,当新消息到达,若用户处于底部附近则自动滚动到底部;若用户向上翻阅历史消息,则不打断阅读位置。
无障碍
动态更新的聊天界面需要无障碍支持:
- 实时区域(aria-live)声明,让屏幕阅读器感知新消息的到达
- 键盘快捷键,例如提交消息、停止生成、复制消息文本
- 焦点管理,例如工具调用卡片展开/收起后保持焦点位置
assistant-ui 官方宣称支持键盘快捷键和无障碍。[1] 在接入自定义消息渲染时,需要保留这些语义。
总结:架构要点与进一步阅读
assistant-ui 的架构体现了一种清晰的职责分离:
- 核心包定义协议,不绑定 UI 框架
- React 绑定层消费核心协议,提供 hooks 和组件
- adapter 层负责接入不同后端
- 运行时管理服务端状态同步与流式更新
理解这个架构的关键在于几个概念:Thread 是会话容器,Message 是通信单元,Run 是一次调用,ContentPart 是消息内容的结构化分解。runtime 是连接后端和 UI 的桥梁,hooks 是组件读取状态的窗口。
进一步阅读官方仓库的 AGENTS.md [3]、@assistant-ui/core 的 npm 页面 [2],以及 assistant-ui-sync-server 中关于流式恢复的实现 [5];关于 Assistants API 的领域模型与 Run 状态机,可阅读 OpenAI 官方 Deep Dive [6] 和 Azure AI 文档 [7]。
参考链接
- [1] https://github.com/assistant-ui/assistant-ui
- [2] https://www.npmjs.com/package/@assistant-ui/core
- [3] https://github.com/assistant-ui/assistant-ui/blob/main/AGENTS.md
- [4] https://github.com/assistant-ui/assistant-ui/blob/main/packages/react-ai-sdk/README.md
- [6] https://github.com/assistant-ui/assistant-ui-sync-server
- [7] https://developers.openai.com/api/docs/assistants/deep-dive
