跳到主要内容

无样式 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 串起来——不需要你手动把 messagessendMessage 一层层往下传。

一句话直觉

把它想成"毛坯房 + 水电已通"。 墙、地、门框都立好了(结构),开关一按灯就亮(行为),但刷什么漆、铺什么地板由你定(样式)。它给的"默认装修"只是让你能立刻住进去看效果,随时可以整面墙推倒重装(render prop)。


2. 顶层全景(headless 哲学 + 组件全景图)

核心哲学:headless / bring-your-own-UI

这层库的每一个设计,都围绕一条主线:组件只拥有"结构 + 行为",绝不拥有"最终呈现"。 它用两种互补的机制把呈现权让出去:

让权机制是什么给谁用典型场景
data-* 属性每个默认渲染的 DOM 都挂满语义化 data-* 钩子(如 data-message-roledata-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 + useChatContextChatChatContextuseChatContext
src/chat-messages.tsx消息列表容器:空/加载/错误态、自动滚动、逐条渲染ChatMessages
src/chat-message.tsx单条消息:按 part.type 分发渲染ChatMessageMessagePart
src/chat-input.tsx受控输入 + 提交ChatInput
src/tool-approval.tsx审批按钮 → 回写审批结果ToolApproval
src/thinking-part.tsx可折叠的思考区块ThinkingPart
src/text-part.tsxMarkdown 正文渲染TextPart
src/markdown-plugins.tsremark/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 的参数类型(ToolCallRenderPropsChatInputRenderPropsToolApprovalRenderProps)。
  • 转发的 hook:export { useChat } from '@tanstack/ai-react'(src/index.ts:49)——为了让你只装这一个包就能拿到 hook,不必再单独 import ai-react
  • 转发的类型:UIMessageMessagePartToolCallPart 等从 @tanstack/ai-client 再导出(src/index.ts:52-61),同样是为了 import 收敛到一处。

一句话:这个包把"组件 + hook + 类型"打包成一站式入口,但组件本身不依赖你怎么拿状态——它们只认 context。


4. 根组件与 context:一切协调的中枢(src/chat.tsx)

它要解决的小问题

ChatMessagesChatInputToolApproval 都需要同一份聊天状态(消息列表、发送函数、加载中标志……)。如果靠 props 一层层传,组合会很僵。解法是 React context:根组件建一个"状态源",子孙组件就近取。

ChatContext + useChatContext

context 的类型是 UseChatReturn | null(src/chat.tsx:13),初值 null。取用它的 useChatContext() 做了一件贴心事——<Chat> 外误用时直接抛出可读的错误,而不是让你收到一个 null 然后在别处炸掉:

// src/chat.tsx:19-27 useChatContext
const context = useContext(ChatContext)
if (!context) {
throw new Error(
"Chat components must be wrapped in <Chat>. ...",
)
}
return context

这行 if (!context) throw 是所有子组件安全取状态的前提。

Chat 根组件

Chat(src/chat.tsx:65-95)做三件事,一件不多:

  1. useChat({ connection, ... }) 拿到全部聊天状态(src/chat.tsx:77)——这一步的内部机制属于第 4 章,本章不展开
  2. 把回调 props(onResponse/onChunk/onFinish/onError)用"存在才透传"的写法转给 useChat(src/chat.tsx:78-86),避免把 undefined 显式塞进选项。
  3. <ChatContext.Provider> 把状态往下发,外面套一个挂了 data-chat-root<div>(src/chat.tsx:88-94)。

注意根组件只认 connection 这一个必填 prop(src/chat.tsx:34),其余都可选。它自己不渲染任何可见 UI——连那个 <div> 都只是为了挂 classNamedata-chat-root 钩子。这就是 headless:根组件是纯粹的"状态与结构容器"

一个坑:文档里的 <Chat.Messages/> 其实没接上

多处 JSDoc(如 src/index.ts:12src/chat.tsx:60-61)把这套组件称作 compound component(复合组件),并演示 <Chat.Messages/><Chat.Input/> 这种"点语法"。但源码里并没有把静态成员挂到 Chat——没有 Chat.Messages = ChatMessages,也没有 Object.assign。真正能跑的用法是平级的具名导入(ChatMessagesChatInput),靠 context 协调;仓库自带示例走的正是平级写法(如 Vue 版 examples/ts-vue-chat/src/views/VueUIView.vue:5import { Chat, ChatMessages, ChatInput })。

所以:"复合组件"是这层库的意图/叙述,当前源码用共享 context 的平级组件实现了同样的"零 props 钻取"效果,但 Chat.Messages 点语法尚未真正接线。 用平级具名组件即可。


5. 消息列表容器(src/chat-messages.tsx)

职责

ChatMessages(src/chat-messages.tsx:35-86)是"列表层":从 context 取 messages,处理三种边界态,自动滚到底,再逐条渲染。

三种状态 + 自动滚动

它从 context 解构 { messages, isLoading, error, reload }(src/chat-messages.tsx:43),然后按优先级短路:

情况条件渲染源码
错误error && errorStateerrorState({ error, reload }):54-56
加载中isLoading && messages.length === 0 && loadingStateloadingState:59-61
messages.length === 0 && emptyStateemptyState:64-66
正常否则消息列表:68-84

注意这三态都要求你传了对应的 slot(errorState/loadingState/emptyState)才会触发——不传就直接落到正常渲染。errorState 还是个 render prop:它把 reload 交给你,让"重试"按钮由你画(src/chat-messages.tsx:17-20)。

自动滚动是一个 effect:每当 messages 变化,就把容器的 scrollTop 顶到 scrollHeight(src/chat-messages.tsx:47-51),autoScroll 默认 true,可关。

逐条渲染:render prop 优先,否则兜底

src/chat-messages.tsx:75-83 的分发很能说明 headless 风格:

// 传了 children(render prop)就用你的;否则退回内置 <ChatMessage>
messages.map((message, index) =>
children ? (
<div key={message.id} data-message-id={message.id}>
{children(message, index)}
</div>
) : (
<ChatMessage key={message.id} message={message} />
),
)

外层容器还挂了 data-chat-messagesdata-message-count(src/chat-messages.tsx:72-73),方便 CSS/测试定位。


6. 分发中枢:按 part 类型渲染(src/chat-message.tsx)

这是本层最核心的一块。一条 AI 消息不是一段文本,而是一个 parts 数组;ChatMessage 的活就是把每个 part 按 type 派给对的渲染器

顶层:角色样式 + data 钩子

ChatMessage(src/chat-message.tsx:92-137)先按 role 拼 className(useruserClassName,否则用 assistantClassName,见 :104-107),再输出一个挂满 data-* 的容器:data-message-roledata-message-created 等(src/chat-message.tsx:110-115)。然后 message.parts.map(...) 把每个 part 交给内部的 MessagePart

一个巧妙细节:思考何时算"完成"

流式场景里,"思考"和"正文"是先后到达的。怎么判断某段 thinking 已经结束、该折叠了?这里用了一个朴素但有效的启发式:如果这个 thinking part 之后还存在任何 text part,就认为思考完成了

// src/chat-message.tsx:118-120 在 map 内计算
const isThinkingComplete =
part.type === 'thinking' &&
message.parts.slice(index + 1).some((p) => p.type === 'text')

这个布尔值往下传给 ThinkingPart,触发自动折叠(见 §8)。不需要额外的完成信号,靠 parts 的相对顺序就推断出来了。

分发表:四种 part × "自定义优先、内置兜底"

内部函数 MessagePart(src/chat-message.tsx:139-269)是一串 if (part.type === ...)。每个分支都是同一套模式:有自定义渲染器就用它,否则用内置的 data- 兜底。*

part.type自定义口子(prop)内置兜底源码
texttextPartRenderer({content})<div data-part-type="text">:157-166
thinkingthinkingPartRenderer({content,isComplete})<ThinkingPart>:169-183
tool-calltoolsRenderer[name]defaultToolRenderer内置带 data-tool-* 的卡片:186-239
tool-resulttoolResultRenderer({toolCallId,content,state})<div data-part-type="tool-result">:242-266

tool-call 分支最讲究,是两级 fallback:

part.type === 'tool-call'


toolsRenderer[part.name] 有? ──是──► 用这个具名渲染器
│否

defaultToolRenderer 有? ──是──► 用默认渲染器
│否

内置卡片(data-tool-name / data-tool-state / arguments / approval / output)

也就是说你可以按工具名精确定制(toolsRenderer.recommendGuitar 画一张吉他卡),没命中的工具再落到 defaultToolRenderer,最后才是内置兜底(src/chat-message.tsx:197-239)。内置兜底会把 part.approval.approved 渲染成 ✓ Approved / ✗ Denied / ⏳ Awaiting...(src/chat-message.tsx:223-231),把 part.outputJSON.stringify 展示(:232-236)。

tool-result 分支会先把可能是多模态数组的 content 压成字符串(toolResultContentToString,见 §10),再交给你的 toolResultRenderer 或内置 div(src/chat-message.tsx:242-266)。


7. 受控输入 + 提交(src/chat-input.tsx)

职责与行为

ChatInput(src/chat-input.tsx:59-174)从 context 取 { sendMessage, isLoading }(:66),内部用 useState 存输入值(:67)。核心行为在 handleSubmit:空白或加载中直接 return,否则发送并清空(src/chat-input.tsx:72-76):

const handleSubmit = () => {
if (!value.trim() || disabled) return // 防空、防重复提交
void sendMessage(value)
setValue('')
}

disabled 的口径是 disabledProp || isLoading(src/chat-input.tsx:70)——加载中自动禁用。

两种用法

  • render prop 全量接管:传 children 时,库把 { value, onChange, onSubmit, isLoading, disabled, inputRef } 交给你,你返回自己的 JSX(src/chat-input.tsx:78-90)。整块输入框由你画。
  • 默认实现:不传 children 时给一个带内联样式的 input + button(src/chat-input.tsx:93-173)。submitOnEnter 默认 true,回车即发(src/chat-input.tsx:110-114)。

一个 headless 的"破例": 默认输入框反常地带了内联样式(橙色主题、圆角、focus 光晕),这和"零样式"哲学略有出入。可理解为"默认实现要能直接看、别太丑"的折中;真要定制,走 render prop 就完全绕开这些内联样式。


8. 可折叠思考区块(src/thinking-part.tsx)

ThinkingPart(src/thinking-part.tsx:44-83)展示模型的推理过程,默认给一个可点击折叠的区块。它自己维护 isCollapsed 状态(:49),关键在这个 effect:

// src/thinking-part.tsx:52-56 思考完成时自动折叠
useEffect(() => {
if (isComplete) setIsCollapsed(true)
}, [isComplete])

配合 §6 算出的 isThinkingComplete,效果是:思考中展开、正文一出现就自动收起。折叠按钮带了 aria-expanded/aria-label(src/thinking-part.tsx:64-75),无障碍到位。这块是全库里唯一自带 Tailwind 风格 className 的组件(如 text-gray-400),同样可被上层 thinkingPartRenderer 整个替换。


9. Markdown 正文与插件链(src/text-part.tsx + src/markdown-plugins.ts)

TextPart

TextPart(src/text-part.tsx:61-97)用 react-markdown 渲染正文。它先按 role 拼 className(:72-78),再调 resolveMarkdownPlugins(...) 解析出插件链(:80-84),交给 <ReactMarkdown>(:88-92)。components prop 允许你覆盖 acode 等标签的渲染(src/text-part.tsx:29)。

插件链的"三明治"顺序(安全关键)

resolveMarkdownPlugins(src/markdown-plugins.ts:27-48)决定了 remark/rehype 插件怎么拼。默认插件分三组(src/markdown-plugins.ts:9-14):

插件作用
remark 默认remarkGfmGitHub 风格 Markdown(表格、删除线等)
rehype 前段rehypeRawrehypeHighlight处理原始 HTML、代码语法高亮
rehype 后段rehypeSanitizeXSS 消毒(必须最后跑)

用户插件被夹在中间插入(src/markdown-plugins.ts:42-46):

rehypeRaw → rehypeHighlight → [你的 rehype 插件] → rehypeSanitize
└── 永远最后,确保消毒兜底

这个顺序是有意为之:消毒永远排在最后,你插进来的插件无论做了什么,输出都还要过一遍 rehypeSanitize

一个逃生阀:disableDefaultPlugins: true整条替换掉默认链,只留用户插件(src/markdown-plugins.ts:33-38)。源码在 TextPartProps 的注释里明确警告——这会连内置 XSS 消毒一起关掉(src/text-part.tsx:31-37),风险自负。


10. 把工具结果压成字符串(src/tool-result-content.ts)

工具返回的 content 类型是 string | Array<ContentPart>——多模态结果会被上游归一成内容数组。但 ChatMessage 的渲染器只吃字符串,于是需要 toolResultContentToString(src/tool-result-content.ts:23-32)做一次拍平:

// src/tool-result-content.ts:23-32 只保留 text part 拼接,非文本(图/音/视频/文档)跳过
export function toolResultContentToString(content) {
if (typeof content === 'string') return content
return content
.filter((part) => part.type === 'text')
.map((part) => part.content)
.join('')
}

策略很直白:是字符串就原样返回;是数组就只挑 text part 拼接,非文本 part 直接丢弃。这与仓库别处对 string | Array<ContentPart> 的文本抽取行为保持一致(见源码注释 :13-22)。它被 chat-message.tsx:243 在渲染 tool-result 前调用。


11. 工具审批:行为直连状态(src/tool-approval.tsx)

职责

当某次工具调用需要人工放行时,ToolApproval(src/tool-approval.tsx:53-129)渲染 approve / deny 按钮,点击后直接把结果回写进聊天状态——这是本层"行为"的典型代表。

直连点:addToolApprovalResponse

它从 context 取 addToolApprovalResponse(src/tool-approval.tsx:61),两个 handler 各自带上 approval.id 和布尔决定回写(src/tool-approval.tsx:63-75):

const handleApprove = () =>
void addToolApprovalResponse({ id: approval.id, approved: true })
const handleDeny = () =>
void addToolApprovalResponse({ id: approval.id, approved: false })

"是否已回应"用 approval.approved !== undefined 判定(src/tool-approval.tsx:77)——未定义 = 还没决定

三条渲染路径

children(render prop) 有? ──是──► 你自己的 UI,拿到 onApprove/onDeny/hasResponded/approved
│否

hasResponded ? ──是──► 只显示结果:✓ Approved / ✗ Denied
│否

默认审批 UI:工具名 + JSON 入参 + [Approve][Deny] 按钮
  • render prop:src/tool-approval.tsx:79-91,把 { toolName, input, onApprove, onDeny, hasResponded, approved } 交给你。
  • 已回应分支:src/tool-approval.tsx:94-104,挂 data-approval-status="approved|denied"
  • 默认 UI:src/tool-approval.tsx:106-128,把 inputJSON.stringify 展示,配 data-approval-approve/data-approval-deny 钩子。

12. 一个最小组合示例(示意)

把前面的零件拼起来,展示"结构由库出、样式由你出"的完整姿态(示意,非源码):

import { Chat, ChatMessages, ChatMessage } from '@tanstack/ai-react-ui'

<Chat connection={myConnection}>
<ChatMessages
className="msg-list" // 用 CSS 上样式
emptyState={<p>开始聊天吧</p>} // 空态 slot
errorState={({ error, reload }) => ( // 错误态 render prop
<button onClick={reload}>出错了,重试:{error.message}</button>
)}
>
{(message) => (
<ChatMessage
message={message}
userClassName="bubble-user" // 角色样式
assistantClassName="bubble-ai"
// 按工具名精确定制某个工具的呈现
toolsRenderer={{
recommendGuitar: ({ arguments: args }) =>
<GuitarCard {...JSON.parse(args)} />, // 你的组件
}}
// 其余工具落到这里
defaultToolRenderer={() => null}
/>
)}
</ChatMessages>
{/* 输入框整块自己画 */}
<ChatInput>
{({ value, onChange, onSubmit, disabled }) => (
<form onSubmit={(e) => { e.preventDefault(); onSubmit() }}>
<MyFancyInput value={value} onChange={onChange} disabled={disabled} />
</form>
)}
</ChatInput>
</Chat>

重点看:没有一处样式来自库——className 接你的 CSS,toolsRenderer / render prop 接你的组件。库只保证"消息被正确分发、审批被正确回写、输入被正确发送"。


13. 巧妙之处(可借鉴的技术)

  • 顺序即状态:用相对位置推断"思考完成"。 不引入额外的完成事件,只看"后面有没有 text part"(src/chat-message.tsx:118-120)。流式 UI 里这类"用已有结构推断状态"的技巧很省心。
  • 两级 render prop fallback。 tool-call 走 toolsRenderer[name] → defaultToolRenderer → 内置兜底(src/chat-message.tsx:197-239),让"精确定制个别工具"和"批量兜底"并存,粒度刚好。
  • 消毒永远垫底的插件三明治。 用户插件夹在 rehypeHighlightrehypeSanitize 之间(src/markdown-plugins.ts:42-46),扩展性与 XSS 安全兼得;要关也逼你显式 disableDefaultPlugins 并在类型注释里警告(src/text-part.tsx:31-37)。
  • context 误用直接抛可读错误。 useChatContext<Chat> 外调用会抛带指引的 Error(src/chat.tsx:19-27),而不是让 null 在远处炸。
  • 一站式 re-export。useChat 和核心类型从底层包再导出(src/index.ts:49-61),使用者装一个包即可,import 收敛。

14. 边界与局限(诚实)

  • Chat.Messages 点语法未接线。 JSDoc 宣称的 compound component 点语法在源码中没有静态成员支撑;实际请用平级具名组件(见 §4 末)。
  • 默认样式不是纯 headless。 ChatInput 带内联样式(src/chat-input.tsx:118-168)、ThinkingPart 带 Tailwind className(src/thinking-part.tsx:66)。要彻底无样式,须走 render prop 绕开默认实现。
  • 多模态工具结果会丢信息。 toolResultContentToString 只保留 text,图/音/视频/文档一律跳过(src/tool-result-content.ts:26-31)——这些内容的呈现需要你自己在 toolResultRenderer 里处理原始 part.content
  • part.approval/ToolApprovalProps 等用了 anyinput: any(src/tool-approval.tsx:9)、approval?: any(src/chat-message.tsx:11),这层的类型安全弱于底层 client 包。
  • 流式续跑、审批回写的语义在别处。 本层只负责"渲染 + 触发";sendMessageaddToolApprovalResponse 的真正行为属于 第 6 章 与第 4 章。

15. 横向对比:ai-solid-ui / ai-vue-ui 是同构对应物

ai-react-ui 不是孤例。仓库为 Solid 和 Vue 提供了**同构(isomorphic)**的对应包,文件与概念一一对应:

概念ai-react-uiai-solid-uiai-vue-ui
根 + contextchat.tsxchat.tsxchat.vue
列表chat-messages.tsxchat-messages.tsxchat-messages.vue
单条分发chat-message.tsxchat-message.tsxchat-message.vue + message-part.vue
输入chat-input.tsxchat-input.tsxchat-input.vue
审批tool-approval.tsxtool-approval.tsxtool-approval.vue
思考/正文thinking-part.tsx/text-part.tsx同名thinking-part.vue/text-part.vue
插件链markdown-plugins.ts同名同名

Solid 版的导出面几乎逐行对应 React 版(packages/ai-solid-ui/src/index.ts:28-49,只是 useChat 转发自 @tanstack/ai-solid)。三者共享同一套 headless 哲学——结构与行为一致,只是绑定到各自框架的响应式原语。这正对应 第 4 章 讲的"薄适配层"思想在 UI 层的延伸:核心逻辑在 ai-client,每个框架只做一层薄壳。


16. 代码地图(导航索引)

主题文件路径符号名
公共导出面packages/ai-react-ui/src/index.ts
根组件packages/ai-react-ui/src/chat.tsxChat
共享 contextpackages/ai-react-ui/src/chat.tsxChatContextuseChatContext
消息列表 + 边界态packages/ai-react-ui/src/chat-messages.tsxChatMessages
part 分发中枢packages/ai-react-ui/src/chat-message.tsxChatMessageMessagePart
tool-call 渲染 propspackages/ai-react-ui/src/chat-message.tsxToolCallRenderProps
受控输入packages/ai-react-ui/src/chat-input.tsxChatInputChatInputRenderProps
工具审批packages/ai-react-ui/src/tool-approval.tsxToolApprovalToolApprovalRenderProps
可折叠思考packages/ai-react-ui/src/thinking-part.tsxThinkingPart
Markdown 正文packages/ai-react-ui/src/text-part.tsxTextPart
插件链解析packages/ai-react-ui/src/markdown-plugins.tsresolveMarkdownPlugins
工具结果拍平packages/ai-react-ui/src/tool-result-content.tstoolResultContentToString
Solid 同构对应packages/ai-solid-ui/src/同名文件
Vue 同构对应packages/ai-vue-ui/src/*.vue + use-chat-context.ts

延伸阅读: index(总览与阅读地图) · 01 headless 核心 ChatClient · 04 框架绑定 useChat(本章依赖的 hook) · 06 工具 / 审批 / 持久化(审批与发送的真正行为)。