TanStack AI 的一条 UIMessage 是若干 part:text、thinking、tool-call / tool-result、
image / audio / video / document、structured-output……
TranscriptParts 按 part.type 把每一个交给本页的内置部件,
每个部件也都可以脱离 Transcript 单独用——它们只收 TanStack 的 part 对象,不碰 useChat。
要换掉其中某一种,在 工具渲染器 里按名注册;内置部件的文案集中在
默认文案。
本页 demo 全部走 脚本化传输,不需要密钥。
使用
import {
ApprovalActions,
ApprovalApprove,
ApprovalDeny,
definePartRenderers,
Markdown,
MediaPart,
PartRenderersProvider,
Reasoning,
Sources,
StructuredOutput,
ToolCallCard,
ToolCallGroup,
ToolCallGroupTrigger,
} from '@gedatou/cadenza-ai'// Inside a Transcript nothing is needed: TranscriptParts dispatches by part.type.
// Standalone, each part takes the TanStack part object:
<Markdown content={part.content} streaming />
<Reasoning content={part.content} complete>Thought for </Reasoning>
<ToolCallCard part={part} result={result} />组成
TranscriptParts 分发出来的树:
TranscriptParts 按 part.type 分发;注册表命中的类型用你的渲染器
├─ Markdown text
├─ Reasoning thinking;complete 由 isThinkingComplete 判定
├─ ToolCallCard tool-call(同 id 的 tool-result 一并交给它)
│ └─ ApprovalActions state 为 approval-requested 且有活中断时
│ ├─ ApprovalApprove
│ └─ ApprovalDeny
├─ ToolCallGroup 连续 ≥ 2 个 tool-call 折成一组
│ ├─ ToolCallGroupTrigger 文案 labels.toolGroup(n)
│ └─ ToolCallCard × n
├─ MediaPart image / audio / video / document
├─ StructuredOutput structured-output
└─ Sources 末尾;sourcesOf(message) 非空时tool-result 与 ui-resource 两种 part 没有内置部件:前者已经并进对应的 ToolCallCard,
后者只有注册了 renderers.uiResource 才渲染。
推理
thinking part 流式时展开、闪烁,完成后自己折起来并报出耗时。
Reasoning 是一个 Collapsible:触发器是 Marker
里的一个真 <button>(键盘与 aria-expanded 都在),面板里是 Markdown。
它只做三件事:
- 开合的默认值跟
complete走:首次渲染时未完成就展开、已完成就收起(只在首帧生效, 之后是普通的非受控状态)。complete从false翻成true的那一刻自动收起一次, 回调收到reason: 'none';读者手动开合过就不再自动收起(第二轮发送可以验证)。 - 耗时是数据,标签是文案:完成瞬间记下
Math.round((Date.now() - startedAt) / 1000), 至少 1 秒,渲染成data-slot="reasoning-duration"的Ns;前面的「Thought for 」来自labels.thought,未完成时是labels.thinking。startedAt默认是部件挂载的时刻, 也就是这段推理到达的时刻;恢复的历史可以传真实时间戳。 complete由调用方判定。TranscriptParts用isThinkingComplete(message, index, status): 运行不在streaming、provider 签过名(part.signature)、或后面已经出现 text / tool-call part, 三者任一成立即为完成。
工具调用
一次工具调用是一张可折叠的卡片:标题只有工具名和状态图标,展开是输入输出的 JSON。
ToolCallCard 把 TanStack 的七个 ToolCallState 折成五个名称型属性,标题图标跟着走:
面板内容依次是:输入(part.input;参数还在流式时用 parsePartialJSON(part.arguments)
兜底,解析不了就显示原文)、输出(part.output ?? result.content)、错误行
(result.error,data-slot="tool-call-error")、children。两段 JSON 都是 Markdown
渲染的 ```json 代码块,所以自带高亮与复制;输入块在 streaming 且 pending 时走流式解析。
卡片默认收起,open / defaultOpen / onOpenChange 三件套同 Collapsible,
onOpenChange 的 details.reason 是 'trigger-press' 或 'none'。
工具渲染器
按工具名给某个工具一张自己的卡片,其余回退到默认;自己的卡片不折进工具组。
PartRenderersProvider 是可选的 context:不放它,TranscriptParts 用全套默认;放了,
renderers 里写了哪种 part 就换哪种。嵌套的 provider 与外层合并(toolCall 再按工具名合并一层),
所以内层只想换 text 不会连带丢掉外层注册的工具渲染器——和 labels 同一条规矩。toolCall 按工具名索引,default 兜住没点名的工具;
每个渲染器收 ToolRendererProps:
自定义卡片用 toolInput(part) 读参数:参数流完取 part.input,流式途中取 arguments 的残缺 JSON,
什么都还解析不出时是 {};顶层的 null 当作没给——模型常把想省略的可选参数写成 null,而解构默认值
只对 undefined 生效。泛型收你的参数形状:const { tz } = toolInput<{ tz: string }>(part)。
按工具名注册了渲染器的调用不参与分组,一条一行平铺。其他键——text、thinking、toolResult、image / audio / video / document、
structuredOutput、uiResource——各收自己的 part(以及 text / 媒体的 message、thinking 的 complete、
text 的 streaming)。definePartRenderers() 只是带类型检查的恒等函数,让注册表可以写在组件外面。
两条合并规则都是与上层合并:labels 只写要改的键;renderers 同样,toolCall 再按工具名合并一层。usePartRenderers() 返回 { renderers, labels },
自定义部件用它取当前文案。
审批
带 needsApproval 的工具让运行停在中断上,卡片底部出现批准 / 拒绝;改过参数再批准,改的参数替代原参数发出去。
流程全在 TanStack 一侧:工具定义 needsApproval: true → 服务端(或脚本)以中断收尾 →
part 进入 approval-requested,useChat().interrupts 里出现一条 kind: 'tool-approval' 的中断。
TranscriptParts 从 TranscriptProvider.interrupts 里按
toolCallId 找到它,把 ApprovalActions 放进 ToolCallCard 的 children:
ApprovalApprove调interrupt.resolveInterrupt(true, { editedArgs }),ApprovalDeny调resolveInterrupt(false);两者都是 Button(size="sm",拒绝是outline), 文案由 children 给——默认渲染用labels.approve/labels.deny。interrupt.status离开'pending'后两个按钮都禁用;读者的选择写到行上:data-approved/data-denied。- 传了
onClick且preventDefault(),就不解决中断。 - 要编辑参数就得自定义渲染器(demo 就是):默认卡片只有两个按钮;自定义卡片从
interrupt.originalArgs取原参数,把改好的对象交给ApprovalApprove editedArgs。
没有活中断的 approval-requested part(恢复的历史)走另一条路:part.approval 在且
TranscriptProvider 传了 addToolApprovalResponse,就渲染一对普通按钮调
addToolApprovalResponse({ id: part.approval.id, approved })——这对按钮同样是 data-slot="approval-actions",
但没有 data-approved / data-denied。
客户端工具
.client() 的工具在浏览器里执行,结果自动回传;没有执行函数的工具,用 addToolResult 手动回。
import { toolDefinition, useChat } from '@gedatou/cadenza-ai'
import { z } from 'zod'
const getViewport = toolDefinition({ name: 'get_viewport', inputSchema: z.object({}) })
.client(() => ({ width: window.innerWidth, height: window.innerHeight }))
const chat = useChat({ fetcher, tools: [getViewport] })
// Manual path, for a tool declared without .client():
await chat.addToolResult({ toolCallId, tool: 'get_viewport', output: { width: 1280, height: 720 } })两条路径对卡片是一样的:调用到达时 data-pending,结果回来后 data-complete。
addToolResult 还收 state: 'output-error' 与 errorText,那样卡片进入 data-error。
脚本侧用 tool(name, input, { client: true }) 发出客户端工具中断,下一轮用 clientResultOf(ctx, toolCallId)
读回浏览器算出的值。
分组
连续多个工具调用折成一组,只露一个触发器。
TranscriptParts 扫到连续的 tool-call(中间夹着的 tool-result 不打断)时,把它们塞进一个
ToolCallGroup,触发器文案是 labels.toolGroup(n)(默认 Ran 3 tools)。
两种调用不进组,一条一行平铺,并且会打断当前的连续段:
- provider 执行的调用(如 DeepSeek 的联网搜索):不是这里跑的,「Ran N tools」不属实,折起来还会藏掉 模型去搜过的唯一痕迹。
- 按工具名注册了渲染器的调用(
renderers.toolCall[name]):那张卡是你做来给人看的——预约、摘要、提醒—— 折进「Ran 3 tools」就要多点一下才看得到。default兜底渲染器替代的是内置卡片,照样折叠。
ToolCallCard、ToolCallGroup 与 Reasoning 的面板都带 keepMounted——这不是性能优化,是正确性
所需。Base UI 在面板被揭开的那一瞬量它的 scrollHeight 并把高度钉住,过渡结束才交回 auto;
而 Markdown 的代码块是挂载后第二遍才高亮的,所以「开的时候才挂载」会量到半成品:面板先动到错的
高度,200ms 后再瞬间校正——多了就是一片空白,少了就是内容被裁。提前挂载,量之前内容已经定型。
代价是折叠状态下内容也会渲染。
厂商侧执行的调用不进组:metadata.providerExecuted 为真的(Anthropic 与 DeepSeek 的服务端
搜索)一律平铺,一行一个,也不会打断旁边普通工具的分组。理由有二:Ran n tools 的主语是「我们跑了
n 个工具」,而这些一个都不是我们跑的;再者折起来会把「模型确实去搜了」这唯一的痕迹藏掉。单独用时按位置组合,
同 DialogTrigger:第一个 child 是触发器,其余进面板。
<ToolCallGroup count={calls.length}>
<ToolCallGroupTrigger>{`Ran ${calls.length} tools`}</ToolCallGroupTrigger>
{calls.map(([part, result]) => (
<ToolCallCard key={part.id} part={part} result={result} />
))}
</ToolCallGroup>组默认收起,data-count 镜像 count;ToolCallGroupTrigger 就是 CollapsibleTrigger,收它的全部 props。
demo 里第一条消息上方的日期行不是包内部件:Transcript 是一个 role="log" 的列表,消息之间可以放任何行,
那一行是 Marker variant="separator",由你按自己的分日规则自绘。
附件
用户消息里的图片、文档、音频按类型渲染。
MediaPart 收 image / audio / video / document 四种 part,source.type === 'data'
时拼成 data: URL,否则直接用 source.value:
data-type 镜像 part.type。怎么把文件变成这些 part,见 输入区 → 附件。
来源
provider 自己执行的搜索带回的链接,收在一条消息末尾的折叠列表里。
sourcesOf(message) 只看 tool-call part:metadata.sources 数组直接收;
metadata.providerExecuted === true 且工具名匹配 /search/i 的,再从 part.output
(数组或 { results: [...] })里收。每一项要有 url 字符串,title / snippet 可选,
按 url 去重。脚本里这样造:
import { text, tool } from '@gedatou/cadenza-ai/mock'
tool('web_search', { q: 'Philharmonie de Paris capacity' }, {
providerExecuted: true,
output: { results: [{ url: 'https://philharmoniedeparis.fr', title: 'Philharmonie de Paris', snippet: '2,400 seats.' }] },
})
text('The Philharmonie seats 2,400.')Sources 默认收起,触发器文案来自 children(默认渲染用 labels.sources(n)),面板是有序列表,
链接 target="_blank" rel="noreferrer";data-count 镜像条数。
结构化输出
useChat({ outputSchema }) 的回复多一个 structured-output part:流式中是逐步补全的 JSON,完成后是校验过的数据。
const chat = useChat({ fetcher, outputSchema: z.object({ works: z.array(z.string()), minutes: z.number() }) })
// chat.partial / chat.final are typed by the schema; the message carries the part as well.StructuredOutput 按 part.status 切换:streaming 时渲染 part.partial 的 JSON 块
(流式解析)加一个 Spinner,complete 时渲染 part.data,error 时渲染 part.errorMessage
(data-slot="structured-output-error")。脚本里用 structured(object) 发一段。
自定义事件
自定义事件不是 part:它不进消息,由 useChat 的回调交给你。
const chat = useChat({
fetcher,
onCustomEvent: (name, data, { toolCallId }) => {
if (name === 'progress')
setProgress(data as { done: number, total: number })
},
})脚本用 custom(name, value) 发出;TranscriptParts 对它无事可做。
structured-output.start / structured-output.complete 两个名字由 TanStack 保留给结构化输出。
默认文案
内置部件的全部可见字符串在 PartLabels 里,通过 PartRenderersProvider labels={…} 按键覆盖,
与上层合并;DEFAULT_PART_LABELS 也从 root 入口导出。中文界面直接用 ZH_PART_LABELS:
<PartRenderersProvider labels={ZH_PART_LABELS}>…</PartRenderersProvider>UsageStats、ContextUsage、ByokKeyDialog 的文案同样各有一份 ZH_*_LABELS,与 DEFAULT_*_LABELS 并列导出。
后七个键留给自定义工具卡片用 usePartRenderers().labels 读取,使文案仍然集中在一处。
aria-label 类的英文默认(按钮图标等)不在这张表里,按部件 props 覆盖。
状态与 className
状态是名称型属性:布尔状态在场时属性存在、值为空串;计数与类型是值型。
className 在这些部件上都是 string:Markdown 的落在 streamdown 自己的根上(data-slot="markdown"
的外壳没有 className),四个折叠部件的落在 Collapsible 根上,ApprovalActions 的落在行上,
MediaPart 的落在 Attachment 根或媒体元素上。
Props
顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。
Markdown
streamdown 加 code / math / CJK 插件:代码块与表格带复制、
链接一律新开页;shiki 主题 github-light-default / vesper。
Reasoning
一段推理的折叠块。
ReasoningState:{ complete, open }。
ToolCallCard
一次工具调用的折叠卡片。
ToolCallCardState:{ pending, approvalRequested, approvalResponded, complete, error }。
ToolCallGroup
连续工具调用的折叠组;第一个 child 是触发器。
ToolCallGroupTrigger
CollapsibleTrigger 加 data-slot="tool-call-group-trigger";收
CollapsibleTrigger 的全部 props,文案由你写。
ApprovalActions
批准 / 拒绝的那一行,role="group";通过中断解决审批。
ApprovalApprove
Button size="sm";按下调 interrupt.resolveInterrupt(true, { editedArgs })。
收 Button 的全部 props,另加:
ApprovalDeny
Button size="sm" variant="outline";按下调 interrupt.resolveInterrupt(false)。
收 Button 的全部 props;disabled / onClick 的规则同 ApprovalApprove。
MediaPart
按类型渲染一个媒体 part。
Sources
来源链接的折叠列表。
StructuredOutput
structured-output part:流式中的 partial、完成后的 data、失败时的错误信息。
视图类型一并导出:MarkdownProps / ReasoningProps / ReasoningState / ReasoningChangeEventDetails /
ToolCallCardProps / ToolCallCardState / ToolCallChangeEventDetails / ToolCallGroupProps /
ToolCallGroupTriggerProps / ApprovalActionsProps / ApprovalApproveProps / ApprovalDenyProps /
MediaPartProps / SourcesProps / SourcesChangeEventDetails / StructuredOutputProps;
注册表一侧:PartLabels / PartRenderers / PartRenderersProviderProps / PartRenderersContextValue /
ToolRenderer / ToolRendererProps / AnyToolApprovalInterrupt / Source,
以及 DEFAULT_PART_LABELS、ZH_PART_LABELS、definePartRenderers、usePartRenderers、isThinkingComplete、sourcesOf、toolInput、parsePartialJSON。