跳到主要内容

TanStack AI 客户端/UI 层(agent-ui)— 架构与原理

30 秒导读: TanStack AI 的前端半边是一台框架无关的流式聊天引擎。你后端吐出一串 标准化的 AG-UI 事件(TEXT_MESSAGE_CONTENTTOOL_CALL_START…),前端一个纯 TypeScript 的 ChatClient 状态机把这些事件收敛成一份响应式的、以 parts 为单位的 UIMessage[];每个框架 (React / Vue / Solid / Svelte / Preact / Angular)只写一层几十行的 hook,把这份 class 的回调 桥进自己的响应式系统。工具调用、人工审批、agent 多轮续跑、持久化、多标签页实时同步——全在那台 引擎里,一次实现,所有框架复用。

本文是 agent-ui 子库的总索引:先讲清"这是什么、大盘怎么转",再给你一张阅读地图通往各深入章节。 所有源码引用锚定在 commit 80dad77


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:26
    import { 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 / sendMessageuseChat · packages/ai-react/src/use-chat.ts:26
无样式 UI@tanstack/ai-react-ui(及 solid/vue)render-prop 组合件,按 part.type 分派渲染,内置审批 UIChat · packages/ai-react-ui/src/chat.tsx:65
headless 核心@tanstack/ai-client状态机:发送/中止/重载/续跑/审批/持久化编排/多端订阅ChatClient · packages/ai-client/src/chat-client.ts:92
传输 / 线协议@tanstack/ai-client(connection-adapters)UIMessage[] ↔ AG-UI RunAgentInput;SSE/HTTP 流解析成 StreamChunkfetchServerSentEvents · packages/ai-client/src/connection-adapters.ts:490
chunk→parts 引擎@tanstack/ai(共享实现,client 层 re-export)消费 AG-UI 事件,增量维护 UIMessage[],变动即 emitStreamProcessor · packages/ai/src/activities/chat/stream/processor.ts:155

注意一个反直觉点: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 主线走一遍(高层,不进代码)

以"用户发一句话、模型调一个工具、再作答"为例,端到端追一遍:

  1. 发送。 组件调 sendMessage("...") → hook 转调 ChatClient.sendMessage (packages/ai-client/src/chat-client.ts:771),它把用户消息塞进 StreamProcessor,再进入 streamResponse(chat-client.ts:849)。
  2. 打包上线。 streamResponse 取出当前 UIMessage[] 和已声明的客户端工具,交给连接适配器。 适配器把消息转成 AG-UI 线格式,连同 threadId / runId / tools 组装成一个 RunAgentInput 对象(buildRunAgentInputBody · connection-adapters.ts:425)发出 HTTP/SSE。
  3. 接流。 后端流式返回 AG-UI 事件文本,适配器逐行解析成 StreamChunk,推进一个订阅队列。
  4. 折叠。 ChatClient 的订阅循环把每个 chunk 喂给 StreamProcessor.processChunk (processor.ts:502)。文本事件追加进 text part;工具事件先建 tool-call part、再流式拼它的 参数 JSON。每折一次,processor emit 一份新 UIMessage[] → 组件实时重渲染。
  5. 工具与续跑。 若模型调了客户端工具,ChatClient 执行它、把输出写成 tool-result part;若工具 需要审批,则暂停等 addToolApprovalResponse。当这一轮所有工具都到终态 (areAllToolsComplete · processor.ts:381),checkForContinuation(chat-client.ts:1326)自动 再发一轮请求,把工具结果带回模型——这就是 agent 循环。
  6. 收尾。 模型不再调工具、RUN_FINISHED 到达,状态回 ready,onFinish 触发,持久化落盘。

目标:你现在能讲清"大盘"——三层引擎 + 一条 AG-UI 事件流 + 一份不断被折叠的消息树。


3. 阅读地图(往下钻哪一章)

本子库较复杂,拆成 6 章由浅入深。建议按顺序读;想直取某机制可跳。

顺序章节讲什么什么时候读
0总览:这是什么 / 全景图 / 主线 / 阅读地图 / 巧妙之处 / 代码地图(本文)全局认知 + 导航先读
1headless 核心:ChatClient 状态机与流式生命周期ChatClient 的字段、sendMessage/streamResponse/stop/reload 生命周期、状态与回调模型想懂"引擎本体怎么转"
2传输与线协议:connection adapters 与 AG-UI RunAgentInputConnectionAdapter 两种形态、SSE/HTTP 流解析、RunAgentInput 线格式、连接归一化想接自定义后端/协议
3chunk → parts 引擎:StreamProcessor 如何拼出 UIMessageAG-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:92)。框架适配层只干一件事:构造它时把 onMessagesChange: setMessages 之类回调塞进去。React 版 useChat 全文才 ~350 行,真正逻辑几乎为零 (packages/ai-react/src/use-chat.ts:26);Vue/Solid/Svelte 版是同一套结构换个响应式原语 (packages/ai-vue/src/use-chat.ts:82packages/ai-svelte/src/create-chat.svelte.ts:96)。 妙在:核心一次实现、六端复用,新框架接入成本极低。

4.2 消息是 "parts 数组",不是一坨字符串

一条 UIMessage 由异构 parts 组成——text / tool-call / tool-result / thinking / structured-output …(packages/ai/src/types.ts:436MessagePart,:455UIMessage)。 于是"文字 + 工具卡片 + 思考过程 + 结构化结果"能在同一条助手消息里并存且各自流式更新,渲染层 只需按 part.type 分派。妙在:把"流式多模态回复"建模成可增量打补丁的树,而非难以局部更新的长文本。

4.3 每次变动 emit 一份全新数组,天然驱动框架 diff

StreamProcessor 每折进一个 chunk,就 onMessagesChange?.([...this.messages]) ——浅拷贝出新引用 (packages/ai/src/activities/chat/stream/processor.ts:1876)。React 的 setMessages、Vue 的 ref、 Solid 的 signal 都靠"引用变了"判定要重渲染,一份新数组正好命中。妙在:用不可变快照当"发布协议", 把"何时重渲染"这件跨框架的难题,统一成一条最朴素的规则。

4.4 对齐 AG-UI 线协议,后端可插拔

前端从不假设后端是谁,只认一套标准事件枚举(EventType:TEXT_MESSAGE_* / TOOL_CALL_* / RUN_* …,packages/ai/src/client.ts:1)和一个标准请求体 RunAgentInput(threadId / runId / messages / tools / forwardedProps,connection-adapters.ts:425)。妙在:只要后端说 AG-UI "方言",任何框架、任何传输(SSE / HTTP chunk / 自定义)都能接;传输细节被 ConnectionAdapter 抽象(connection-adapters.ts:244)。

4.5 agent 多轮循环收敛成一个"所有工具到终态就再发一轮"

不用显式编排 agent 步骤。判据就一条:最后一条助手消息里每个 tool-call 都到了终态 (有结果 / 被审批 / 完成),areAllToolsComplete(processor.ts:381)为真,checkForContinuation (chat-client.ts:1326)就自动把工具结果带回模型再发一轮,直到没有待跑工具。妙在:把"agent 循环" 降维成一个可判定的状态谓词 + 一次递归发送,逻辑极简且可测。

4.6 审批 = 把"人"接进流里,而不打断流式模型

危险工具的 tool-call part 带 approval 元数据(packages/ai/src/types.ts:349),状态停在 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.tsChatClient
发送用户消息packages/ai-client/src/chat-client.tssendMessage
一次请求的流式主循环packages/ai-client/src/chat-client.tsstreamResponse
agent 续跑判定与再发packages/ai-client/src/chat-client.tscheckForContinuation / shouldAutoSend
中止 / 重载 / 清空packages/ai-client/src/chat-client.tsstop / reload / clear
客户端工具结果回填packages/ai-client/src/chat-client.tsaddToolResult / addToolResultForClientTool
多端订阅循环packages/ai-client/src/chat-client.tssubscribe / consumeSubscription
客户端包总导出(含 re-export)packages/ai-client/src/index.tsChatClient / StreamProcessor(re-export)

5.2 传输 / 线协议 · connection-adapters

主题文件符号
适配器联合类型(两种形态)packages/ai-client/src/connection-adapters.tsConnectionAdapter / ConnectConnectionAdapter / SubscribeConnectionAdapter
归一化为 subscribe/sendpackages/ai-client/src/connection-adapters.tsnormalizeConnectionAdapter
AG-UI 请求体组装packages/ai-client/src/connection-adapters.tsbuildRunAgentInputBody / RunAgentInputContext
内置 SSE / HTTP 流适配器packages/ai-client/src/connection-adapters.tsfetchServerSentEvents / fetchHttpStream / xhrServerSentEvents
截断流检测packages/ai-client/src/connection-adapters.tsStreamTruncatedError / readStreamLines
AG-UI 事件枚举packages/ai/src/client.tsEventType

5.3 chunk → parts 引擎 · @tanstack/ai

主题文件符号
chunk 折叠状态机packages/ai/src/activities/chat/stream/processor.tsStreamProcessor
事件分派入口packages/ai/src/activities/chat/stream/processor.tsprocessChunk
单事件处理器packages/ai/src/activities/chat/stream/processor.tshandleTextMessageContentEvent / handleToolCallStartEvent / handleToolCallArgsEvent
工具是否全部到终态packages/ai/src/activities/chat/stream/processor.tsareAllToolsComplete
不可变快照 emitpackages/ai/src/activities/chat/stream/processor.tsemitMessagesChange
chunk 切分策略packages/ai/src/activities/chat/stream/strategies.tsImmediateStrategy / PunctuationStrategy / WordBoundaryStrategy / CompositeStrategy
部分 JSON 解析(流式工具参数)packages/ai/src/activities/chat/stream/json-parser.tsPartialJSONParser / parsePartialJSON

5.4 消息与部件类型 · @tanstack/ai

主题文件符号
UI 消息packages/ai/src/types.tsUIMessage
部件联合packages/ai/src/types.tsMessagePart
各部件packages/ai/src/types.tsTextPart / ToolCallPart / ToolResultPart / ThinkingPart / StructuredOutputPart

5.5 薄框架适配层

框架文件符号
Reactpackages/ai-react/src/use-chat.tsuseChat
Vuepackages/ai-vue/src/use-chat.tsuseChat
Solidpackages/ai-solid/src/use-chat.tsuseChat
Sveltepackages/ai-svelte/src/create-chat.svelte.tscreateChat
Preactpackages/ai-preact/src/use-chat.tsuseChat
Angularpackages/ai-angular/src/inject-chat.tsinjectChat

5.6 无样式 UI 组件 · @tanstack/ai-react-ui(solid-ui / vue-ui 同构)

主题文件符号
根组件 + contextpackages/ai-react-ui/src/chat.tsxChat / useChatContext
消息列表(render-prop)packages/ai-react-ui/src/chat-messages.tsxChatMessages
单条消息按 part 分派packages/ai-react-ui/src/chat-message.tsxChatMessage
输入框packages/ai-react-ui/src/chat-input.tsxChatInput
工具审批 UIpackages/ai-react-ui/src/tool-approval.tsxToolApproval

5.7 持久化 · 客户端工具类型

主题文件符号
对话持久化(存储/清空/去抖写)packages/ai-client/src/client-persistor.tsChatPersistor
客户端工具声明助手packages/ai-client/src/types.tsclientTools / createChatClientOptions