无样式 UI 组件:ai-react-ui 的 render-prop 组合件
30 秒导读:
@tanstack/ai-react-ui是一层无样式(headless)的 React 组件。它不替你决定聊天界面长什么样——它只负责结构和行为:把消息按 part 类型分发渲染、把输入框接到发送逻辑、把工具审批按钮接到approve/deny。样式和最终呈现,它通过两个口子完全交还给你:render prop(你自己写 JSX)和data-*属性(你自己写 CSS)。
本章讲透 packages/ai-react-ui 的全部源码。它是第 4 章 useChat 之上、离终端用户最近的一层。第 4 章讲的是 hook 内部怎么把 class 状态机桥进 React;本章不重复那部分,只讲这层组件如何把 hook 的状态摆到屏幕上、又如何把控制权让出去。
1. 这是什么(零基础也能懂)
一句话定义
它是一组只有骨架、没有皮肤的聊天组件。你给它一个连接(connection),它帮你把「用户消息 / AI 回复 / 思考过程 / 工具调用 / 工具审批」这些结构摆好、把「输入即发送」「点击即审批」这些行为接好——但每一块具体长什么样、用什么颜色什么圆角,由你说了算。
解决什么问题 / 给谁用
假设你要做一个 AI 聊天页。AI 的回复不再是一段纯文本,而是混合了好几种"零件":一段思考(thinking)、一段正文(text)、一次工具调用(tool-call,可能还要你点"批准")、一段工具返回(tool-result)。手写这套分发逻辑很烦,但市面上"开箱即用"的聊天组件又往往带死了一套设计——你想换个风格就得跟它的 CSS 打架。
这层库走第三条路:结构和行为我给你写好,样式一行不塞(默认渲染只给最朴素的兜底)。你要么用 className + data-* 属性写 CSS,要么用 render prop 把整块 JSX 换成你自己的。给谁用:想要完全掌控外观、又不想重写消息分发/流式/审批逻辑的前端工程师。
它能做什么
- 按 part 类型分发渲染一条消息:text / thinking / tool-call / tool-result 各有各的渲染分支。
- 原生支持工具审批:渲染 approve / deny 按钮,点击直接回写到聊天状态。
- 流式友好:思考中的 part 会在正文出现后自动折叠。
- 每一处都能被接管:从"换一个 text 渲染器"到"整个输入框自己画",都有 render prop。
- Markdown 正文:内置 GFM + 语法高亮 + XSS 消毒,且插件链可扩展/可整条替换。
用起来什么样
最小用法就三个组件叠在一起(示意,基于 src/index.ts:14-24 的文档示例):
import { Chat, ChatMessages, ChatInput, ChatMessage } from '@tanstack/ai-react-ui'
// <Chat> 建立共享状态;<ChatMessages> 渲染列表;<ChatInput> 收发消息
<Chat connection={fetchServerSentEvents('/api/chat')}>
<ChatMessages>
{(message) => <ChatMessage message={message} />}
</ChatMessages>
<ChatInput />
</Chat>
三个组件是平级的兄弟,靠 <Chat> 提供的 React context 串起来——不需要你手动把 messages、sendMessage 一层层往下传。
一句话直觉
把它想成"毛坯房 + 水电已通"。 墙、地、门框都立好了(结构),开关一按灯就亮(行为),但刷什么漆、铺什么地板由你定(样式)。它给的"默认装修"只是让你能立刻住进去看效果,随时可以整面墙推倒重装(render prop)。
2. 顶层全景(headless 哲学 + 组件全景图)
核心哲学:headless / bring-your-own-UI
这层库的每一个设计,都围绕一条主线:组件只拥有"结构 + 行为",绝不拥有"最终呈现"。 它用两种互补的机制把呈现权让出去:
| 让权机制 | 是什么 | 给谁用 | 典型场景 |
|---|---|---|---|
data-* 属性 | 每个默认渲染的 DOM 都挂满语义化 data-* 钩子(如 data-message-role、data-tool-state) | 想保留默认 DOM 结构、只用 CSS 选择器上样式的人 | 快速主题化,不写 JSX |
| render prop | 传一个函数 child,库把状态与回调交给你,你返回自己的 JSX | 想完全替换某块 DOM 的人 | 用自己的设计系统/组件库重画 |
换句话说:默认渲染是"可有可无的兜底",不是这层库的价值所在。价值在于它替你算好了「这条消息有哪些 part、每个 part 是什么状态、审批按钮该回写什么」——这些逻辑你不用再写。
组件全景图
从数据流角度看,<Chat> 是唯一的"状态源",其余组件都是从 context 里取状态的"消费者":
connection (SSE / HTTP stream …)
│
▼
┌─────────────────────────────────────┐
│ <Chat> │
│ └─ useChat() ← 第4章的 hook │
│ └─ ChatContext.Provider (状态源) │
└─────────────────────────────────────┘
│ context: {messages, sendMessage,
│ isLoading, error, reload,
│ addToolApprovalResponse}
┌────┴───────────────┬──────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ (任意组件都能
│ <ChatMessages>│ │ <ChatInput> │ useChatContext()
│ 列表 + 滚动 │ │ 受控输入+发送 │ 取到同一状态)
└───────┬───────┘ └───────────────┘
│ 每条消息
▼
┌───────────────────────────────────────────┐
│ <ChatMessage> 按 part.type 分发: │
│ text → thinking → tool-call → tool-result │
└───────┬─────────┬──────────┬────────────────┘
▼ ▼ ▼
<TextPart> <ThinkingPart> <ToolApproval>
(markdown) (可折叠) (approve/deny)
怎么读这张图: 上到下是"状态从哪来、流到哪去"。<Chat> 在顶端调用 useChat 拿到全部状态,塞进 context;下面所有组件都通过 useChatContext() 就近取用,不靠 props 逐层传递。<ChatMessage> 是分发中枢,把一条消息拆成若干 part 各自渲染。
各文件一句话职责
| 文件 | 干什么 | 关键符号 |
|---|---|---|
src/index.ts | 公共导出面:组件 + 类型 + 转发 useChat | — |
src/chat.tsx | 根组件 + context + useChatContext | Chat、ChatContext、useChatContext |
src/chat-messages.tsx | 消息列表容器:空/加载/错误态、自动滚动、逐条渲染 | ChatMessages |
src/chat-message.tsx | 单条消息:按 part.type 分发渲染 | ChatMessage、MessagePart |
src/chat-input.tsx | 受控输入 + 提交 | ChatInput |
src/tool-approval.tsx | 审批按钮 → 回写审批结果 | ToolApproval |
src/thinking-part.tsx | 可折叠的思考区块 | ThinkingPart |
src/text-part.tsx | Markdown 正文渲染 | TextPart |
src/markdown-plugins.ts | remark/rehype 插件链的解析规则 | resolveMarkdownPlugins |
src/tool-result-content.ts | 把工具结果的多模态 content 压成字符串 | toolResultContentToString |
3. 导出面:一张公共 API 地图(src/index.ts)
先看这层库对外暴露什么,能快速建立"有哪些零件"的心智。src/index.ts:28-49 导出三类东西:
- 组件 + 各自的 props 类型:
Chat/ChatMessages/ChatMessage/ChatInput/ToolApproval/TextPart/ThinkingPart,以及 render-prop 的参数类型(ToolCallRenderProps