数据截至 (上游 commit 2822885e57e7)
TanStack AI 客户端/UI 层(agent-ui)— 架构与原理
30 秒导读: TanStack AI 的前端半边是一台框架无关的流式聊天引擎。你后端吐出一串 标准化的 AG-UI 事件(
TEXT_MESSAGE_CONTENT、TOOL_CALL_START…),前端一个纯 TypeScript 的ChatClient状态机把这些事件收敛成一份响应式的、以parts为单位的UIMessage[];每个框架 (React / Vue / Solid / Svelte / Preact / Angular)只写一层几十行的 hook,把这份 class 的回调 桥进自己的响应式系统。工具调用、人工审批、agent 多轮续跑、持久化、多标签页实时同步——全在那台 引擎里,一次实现,所有框架复用。
本文是 agent-ui 子库的总索引:先讲清"这是什么、大盘怎么转",再给你一张阅读地图通往各深入章节。
所有源码引用锚定在 commit 1c0415b。
1. 这是什么(零基础也能懂)
-
一句话定义: 一套只管"聊天前端"的库——把 LLM 的流式输出,变成一个你能直接渲染的、会自己 更新的消息列表;并管好工具调用、审批、重试这些交互。它不含任何 UI 样式,也不绑定任何框架。
-
解决什么问题 / 给谁用: 假设你在做一个 AI 聊天界面。麻烦事从来不是"显示气泡",而是:
- 后端是一个字一个字流式吐 token 的,你得实时拼接;
- 中途模型会调用工具(查天气、改数据库),工具参数也是流式拼出来的 JSON;
- 有些工具**危险,要用户点"批准"**才能跑;
- 工具跑完得把结果再发回模型、让它接着说(agent 多轮循环);
- 用户刷新页面,对话不能丢;开两个标签页,要同步。
这些跨越"网络传输 → 状态管理 → 框架渲染"三层,过去每个团队都要重写一遍。TanStack AI 把它们 一次性收进
@tanstack/ai-client,让 React/Vue/Solid 开发者只需useChat(...)。 -
它能做什么(功能):
能力 说明 流式渲染 把 token 流实时拼成消息,边到边显示 parts 化消息 一条消息由 text/tool-call/tool-result/thinking/structured-output等部件组成客户端工具 工具可在浏览器里执行( .client()),结果自动回传人工审批 危险工 具暂停,等用户批准/拒绝再继续(human-in-the-loop) agent 续跑 工具跑完自动把结果发回模型,进入下一轮,直到无工具可跑 结构化输出 流式解析部分 JSON, partial边填边给,final校验后给持久化 对话存本地存储,刷新不丢 多端实时 订阅式连接,多标签页/多设备看到同一个"正在生成"状态 跨框架 同一引擎,官方适配 React/Vue/Solid/Svelte/Preact/Angular -
用起来什么样: 一个最小的 React 例子——注意开发者只碰 hook,碰不到状态机:
// 示意,非源码;真实签名见 packages/ai-react/src/use-chat.ts:36import { useChat } from '@tanstack/ai-react'import { fetchServerSentEvents } from '@tanstack/ai-client'function ChatBox() {// connection 说"去哪拿流",其余全自动const { messages, sendMessage, isLoading } = useChat({connection: fetchServerSentEvents('/api/chat'),})return (<div>{messages.map((m) => (<div key={m.id}>{/* 一条消息 = 一串 parts,按类型渲染 */}{m.parts.map((p, i) =>p.type === 'text' ? <span key={i}>{p.content}</span> : null,)}</div>))}<button onClick={() => sendMessage('你好')}>发送</button></div>)}或者用无样式组件版本,连
map都不用写(packages/ai-react-ui/src/chat.tsx:65):// 示意,非源码<Chat connection={fetchServerSentEvents('/api/chat')}><ChatMessages>{(message) => <ChatMessage message={message} />}</ChatMessages><ChatInput /></Chat> -
一句话直觉/类比: 把它当成聊天界面的 "React 的 reconciler,但对象是 LLM 流"。后端事件像一串 DOM 补丁,
ChatClient像 reconciler:吸收补丁、维护一棵"消息树"(UIMessage[])、每次变动吐出一份 新快照;各框架 hook 只是把这份快照喂给自己的useState/ref/signal。
本节到此不碰底层代码。你现在应该知道:这是聊天前端的引擎,不管样式、不挑框架。
2. 顶层全景(它大概怎么转)
2.1 全景图
先说怎么读这张图:从上到下是一次消息发送的数据流,左边是"你写的代码/框架",中间是这套库的
三个核心层,右边是它们各自维护的数据。核心洞察是:中间那根 ChatClient 柱子是框架无关的
纯 class,所有框架共用它。
你的组件 (React / Vue / Solid / Svelte / Preact / Angular)
│ sendMessage("你好")
▼
┌─────────────────────────────────────────────────────────┐
│ 薄框架适配层 useChat / createChat / injectChat │ ← 每框架 ~几十行
│ (ai-react 等) 只做一件事:把 class 回调桥进响应式系统 │
└─────────────────────────────────────────────────────────┘
│ new ChatClient({ onMessagesChange: setMessages, ... })
▼
┌─────────────────────────────────────────────────────────┐
│ headless 核心 ChatClient (框架无关的状态机) │ 维护:
│ (ai-client) · 发送 / 中止 / 重载 / 续跑 / 审批 │ loading / status
│ · 编排持久化、客户端工具、多端订阅 │ error / 订阅态
└─────────────────────────────────────────────────────────┘
│ ①送出请求 ▲ ③喂 chunk
▼ │
┌──────────────────────┐ ┌──────────────────────────┐
│ 连接适配器 │ │ StreamProcessor │ 维护:
│ connection-adapters │ │ (@tanstack/ai 里) │ UIMessage[]
│ UIMessage[] → 线格式 │ │ chunk 事件 → parts 补丁 │ (以 parts 为单位)
│ RunAgentInput │ └──────────────────────────┘
└──────────────────────┘ │ ④每次变动 emit [...messages]
│ ②HTTP/SSE 发出 ▼
▼ onMessagesChange → 回到框架 setState
你的后端 (吐 AG-UI 事件流) ───────────────┘
一次问答的闭环:① ChatClient 把当前 UIMessage[] 交给连接适配器打包成 AG-UI RunAgentInput
发出;② 后端流式返回 AG-UI 事件;③ 每个 chunk 喂给 StreamProcessor;④ processor 把
chunk 折进消息树,吐出新数组快照,经 onMessagesChange 回调一路回到框架的 setState,组件重渲染。
2.2 部件一句话职责
| 层 | 包 | 干什么 | 核心符号 · 位置 |
|---|---|---|---|
| 薄框架适配 | @tanstack/ai-react(及 vue/solid/svelte/preact/angular) | 把 ChatClient 的回调桥进框架响应式;暴露 messages / sendMessage 等 | useChat · packages/ai-react/src/use-chat.ts:36 |
| 无样式 UI | @tanstack/ai-react-ui(及 solid/vue) | render-prop 组合件,按 part.type 分派渲染,内置审批 UI | Chat · packages/ai-react-ui/src/chat.tsx:65 |
| headless 核心 | @tanstack/ai-client | 状态机:发送/中止/重载/续跑/审批/持久化编排/多端订阅 | ChatClient · packages/ai-client/src/chat-client.ts:306 |
| 传输 / 线协议 | @tanstack/ai-client(connection-adapters) | UIMessage[] ↔ AG-UI RunAgentInput;SSE/HTTP 流解析成 StreamChunk | fetchServerSentEvents · packages/ai-client/src/connection-adapters.ts:1259 |
| chunk→parts 引擎 | @tanstack/ai(共享实现,client 层 re-export) | 消费 AG-UI 事件,增量维护 UIMessage[],变动即 emit | StreamProcessor · packages/ai/src/activities/chat/stream/processor.ts:182 |
注意一个反直觉点:
StreamProcessor及消息类型UIMessage不住在 client 包里,而在核心@tanstack/ai包,由@tanstack/ai-client原样 re-export(packages/ai-client/src/index.ts末尾export { StreamProcessor, ... } from '@tanstack/ai/client')。这让服务端和客户端共用同一台 chunk 折叠引擎,拼消息的规则两端一致。
2.3 主线走一遍(高层,不进代码)
以"用 户发一句话、模型调一个工具、再作答"为例,端到端追一遍:
- 发送。 组件调
sendMessage("...")→ hook 转调ChatClient.sendMessage(packages/ai-client/src/chat-client.ts:1920),它把用户消息塞进StreamProcessor,再进入streamResponse(chat-client.ts:2132)。 - 打包上线。
streamResponse取出当前UIMessage[]和已声明的客户端工具,交给连接适配器。 适配器把消息转成 AG-UI 线格式,连同threadId/runId/tools组装成一个RunAgentInput对象(buildRunAgentInputBody·connection-adapters.ts:1193)发出 HTTP/SSE。 - 接流。 后端流式返回 AG-UI 事件文本,适配器逐行解析成
StreamChunk,推进一个订阅队列。 - 折叠。
ChatClient的订阅循环把每个 chunk 喂给StreamProcessor.processChunk(processor.ts:538)。文本事件追加进textpart;工具事件先建tool-callpart、再流式拼它的 参数 JSON。每折一次,processoremit一份新UIMessage[]→ 组件实时重渲染。 - 工具与续跑。 若模型调了客户端工具,
ChatClient执行它、把输出写成tool-resultpart;若工具 需要审批,则暂停等addToolApprovalResponse。当这一轮所有工具都到终态 (areAllToolsComplete·processor.ts:414),checkForContinuation(chat-client.ts:2690)自动 再发一轮请求,把工具结果带回模型——这就是 agent 循环。 - 收尾。 模型不再调工具、
RUN_FINISHED到达,状态回ready,onFinish触发,持久化落盘。
目标:你现在能讲清"大盘"——三层引擎 + 一条 AG-UI 事件流 + 一份不断被折叠的消息树。
3. 阅读地图(往下钻哪一章)
本子库较复杂,拆成 6 章由浅入深。建议按顺序读;想直取某机制可跳。
| 顺序 | 章节 | 讲什么 | 什么时候读 |
|---|---|---|---|
| 0 | 总览:这是什么 / 全景图 / 主线 / 阅读地图 / 巧妙之处 / 代码地图(本文) | 全局认知 + 导航 | 先读 |
| 1 | headless 核心:ChatClient 状态机与流式生命周期 | ChatClient 的字段、sendMessage/streamResponse/stop/reload 生命周期、状态与回调模型 | 想懂"引擎本体怎么转" |
| 2 | 传输与线协议:connection adapters 与 AG-UI RunAgentInput | ConnectionAdapter 两种形态、SSE/HTTP 流解析、RunAgentInput 线格式、连接归一化 | 想接自定义后端/协议 |
| 3 | chunk → parts 引擎:StreamProcessor 如何拼出 UIMessage | AG-UI 事件 switch、增量拼 part、chunk 切分策略、部分 JSON 解析、不可变 emit | 想懂"消息树怎么 长出来" |
| 4 | 薄适配层:useChat 如何把 class 状态机桥进框架 | useChat 的 memo/回调桥接、six 框架同构、activeClientRef 防串扰 | 想懂"为什么能跨框架" |
| 5 | 无样式 UI 组件:ai-react-ui 的 render-prop 组合件 | Chat/ChatMessages/ChatMessage/ChatInput/ToolApproval 的 render-prop 组合 | 想快速搭 UI 或定制渲染 |
| 6 | 跨切面流程:客户端工具、审批、续跑、持久化与多端生成态 | 客户端工具执行、审批暂停/恢复、续跑判定、ChatPersistor、订阅式多端同步 | 想懂 agent 交互与高级特性 |
4. 巧妙之处(可带走的精华)
这几条是这套设计里最值得学的取舍。每条先讲"妙在哪",再给 file:line。
4.1 用一个纯 class 做"框架无关的状态机",框架只写桥接
所有逻辑(流式、工具、审批、续跑、持久化、订阅)都在 ChatClient 这个不 import 任何框架的 class
里(packages/ai-client/src/chat-client.ts:306)。框架适配层只干一件事:构造它时把
onMessagesChange: setMessages 之类回调塞进去。React 版 useChat 全文 ~630 行,真正逻辑几乎为零
(packages/ai-react/src/use-chat.ts:36);Vue/Solid/Svelte 版是同一套结构换个响应式原语
(packages/ai-vue/src/use-chat.ts:103、packages/ai-svelte/src/create-chat.svelte.ts:116)。
妙在:核心一次实现、六端复用,新框架接入成本极低。
4.2 消息是 "parts 数组",不是一坨字符串
一条 UIMessage 由异构 parts 组成——text / tool-call / tool-result / thinking /
structured-output …(packages/ai/src/types.ts:504 的 MessagePart,:560 的 UIMessage)。
于是"文字 + 工具卡片 + 思考过程 + 结构化结果"能在同一条助手消息里并存且各自流式更新,渲染层
只需按 part.type 分派。妙在:把"流式多模态回复"建模成可增量打补丁的树,而非难以局部更新的长文本。
4.3 每次变动 emit 一份全新数组,天然驱动框 架 diff
StreamProcessor 每折进一个 chunk,就 onMessagesChange?.([...this.messages]) ——浅拷贝出新引用
(packages/ai/src/activities/chat/stream/processor.ts:2324-2326)。React 的 setMessages、Vue 的 ref、
Solid 的 signal 都靠"引用变了"判定要重渲染,一份新数组正好命中。妙在:用不可变快照当"发布协议",
把"何时重渲染"这件跨框架的难题,统一成一条最朴素的规则。
4.4 对齐 AG-UI 线协议,后端可插拔
前端从不假设后端是谁,只认一套标准事件枚举(EventType:TEXT_MESSAGE_* / TOOL_CALL_* /
RUN_* …,packages/ai/src/client.ts:192)和一个标准请求体 RunAgentInput(threadId / runId /
messages / tools / forwardedProps,connection-adapters.ts:1193)。妙在:只要后端说 AG-UI
"方言",任何框架、任何传输(SSE / HTTP chunk / 自定义)都能接;传输细节被 ConnectionAdapter
抽象(connection-adapters.ts:965)。
4.5 agent 多轮循环收敛成一个"所有工具到终态就再发一轮"
不用显式编排 agent 步骤。判据就一条:最后一条助手消息里每个 tool-call 都到了终态
(有结果 / 被审批 / 完成),areAllToolsComplete(processor.ts:414)为真,checkForContinuation
(chat-client.ts:2690)就自动把工具结果带回模型再发一轮,直到没有待跑工具。妙在:把"agent 循环"
降维成一个可判定的状态谓词 + 一次递归发送,逻辑极简且可测。
4.6 审批 = 把"人"接进流里,而不打断流式模型
危险工具的 tool-call part 带 approval 元数据(packages/ai/src/types.ts:421-422),状态停在
approval-requested;UI 渲染出批准/拒绝按钮(packages/ai-react-ui/src/tool-approval.tsx),用户点选
后 addToolApprovalResponse 把决定写回 part,续跑逻辑再接管。妙在:human-in-the-loop 被建模成
"消息树上的一个待决 part",而非旁路的阻塞对话框——它和流式、持久化、多端同步天然共存。
5. 代码地图(导航索引)
给要读源码/让 agent 跳转的表。优先用符号名 grep(比行号抗上游漂移)。路径相对 clone 根
aiRef/repos/tanstack-ai/。
5.1 headless 核心 · @tanstack/ai-client
| 主题 | 文件 | 符号 |
|---|---|---|
| 状态机本体(字段/回调/生命周期) | packages/ai-client/src/chat-client.ts | ChatClient |
| 发送用户消息 | packages/ai-client/src/chat-client.ts | sendMessage |
| 一次请求的流式主循环 | packages/ai-client/src/chat-client.ts | streamResponse |
| agent 续跑判定与再发 | packages/ai-client/src/chat-client.ts | checkForContinuation / shouldAutoSend |
| 中止 / 重载 / 清空 | packages/ai-client/src/chat-client.ts | stop / reload / clear |
| 客户端工具结果回填 | packages/ai-client/src/chat-client.ts | addToolResult / addToolResultForClientTool |
| 多端订阅循环 | packages/ai-client/src/chat-client.ts | subscribe / consumeSubscription |
| 客户端包总导出(含 re-export) | packages/ai-client/src/index.ts | ChatClient / StreamProcessor(re-export) |
5.2 传输 / 线协议 · connection-adapters
| 主题 | 文件 | 符号 |
|---|---|---|
| 适配器联合类型(两种形态) | packages/ai-client/src/connection-adapters.ts | ConnectionAdapter / ConnectConnectionAdapter / SubscribeConnectionAdapter |
| 归一化为 subscribe/send | packages/ai-client/src/connection-adapters.ts | normalizeConnectionAdapter |
| AG-UI 请求体组装 | packages/ai-client/src/connection-adapters.ts | buildRunAgentInputBody / RunAgentInputContext |
| 内置 SSE / HTTP 流适配器 | packages/ai-client/src/connection-adapters.ts | fetchServerSentEvents / fetchHttpStream / xhrServerSentEvents |
| 截断流检测 | packages/ai-client/src/connection-adapters.ts | StreamTruncatedError / readStreamLines |
| AG-UI 事件枚举 | packages/ai/src/client.ts | EventType |
5.3 chunk → parts 引擎 · @tanstack/ai
| 主题 | 文件 | 符号 |
|---|---|---|
| chunk 折叠状态机 | packages/ai/src/activities/chat/stream/processor.ts | StreamProcessor |
| 事件分派入口 | packages/ai/src/activities/chat/stream/processor.ts | processChunk |
| 单事件处理器 | packages/ai/src/activities/chat/stream/processor.ts | handleTextMessageContentEvent / handleToolCallStartEvent / handleToolCallArgsEvent |
| 工具是否全部到终态 | packages/ai/src/activities/chat/stream/processor.ts | areAllToolsComplete |
| 不可变快照 emit | packages/ai/src/activities/chat/stream/processor.ts | emitMessagesChange |
| chunk 切分策略 | packages/ai/src/activities/chat/stream/strategies.ts | ImmediateStrategy / PunctuationStrategy / WordBoundaryStrategy / CompositeStrategy |
| 部分 JSON 解析(流式工具参数) | packages/ai/src/activities/chat/stream/json-parser.ts | PartialJSONParser / parsePartialJSON |
5.4 消息与部件类型 · @tanstack/ai
| 主题 | 文件 | 符号 |
|---|---|---|
| UI 消息 | packages/ai/src/types.ts | UIMessage |
| 部件联合 | packages/ai/src/types.ts | MessagePart |
| 各部件 | packages/ai/src/types.ts | TextPart / ToolCallPart / ToolResultPart / ThinkingPart / StructuredOutputPart |
5.5 薄框架适配层
| 框架 | 文件 | 符号 |
|---|---|---|
| React | packages/ai-react/src/use-chat.ts | useChat |
| Vue | packages/ai-vue/src/use-chat.ts | useChat |
| Solid | packages/ai-solid/src/use-chat.ts | useChat |
| Svelte | packages/ai-svelte/src/create-chat.svelte.ts | createChat |
| Preact | packages/ai-preact/src/use-chat.ts | useChat |
| Angular | packages/ai-angular/src/inject-chat.ts | injectChat |