数据截至 (上游 commit 0c84ae09499b)
前端渲染:把流式 props 变成活的、可交互的组件
30 秒导读: 前面几章把模型的决策变成了一串事件、累积成了"某个组件叫什么、props 长什么样"的状态。这一章讲最后一公里:状态怎么变成屏幕上真正的 React 组件,以及用户在组件里的每一次点击/输入,怎么回流成模型下一轮能看到的东西。
本章接着 第3章(流式协议与累积器) 攒好的状态、第4章(一次消息的一生) 跑完的客户端循环往下讲。只讲"渲染 + 状态回流":注册细节看 第1章,后端怎么挑组件看 第2章。
1. 这节讲什么(先建直觉)
Tambo 的卖点是"生成式 UI":模型不是吐一段文字,而是说"渲染一张 WeatherCard,props 是 { city: '东京', temp: 22 }"。到了浏览器这边,要解决两个方向的问题:
- 下行(状态 → 屏幕): 拿到"组件名 + 一坨还在流的 props",怎么找到真正的 React 组件、把不完整的 props 喂进 去、边流边渲染,而且流完之前不闪不崩。
- 上行(交互 → 模型): 用户在渲染出来的组件里点了个开关、AI 通过工具改了个 prop——这些局部状态怎么同步到服务器,又怎么在下一轮对话里让模型"看见"。
一句话类比:把它当成一个双向数据绑定框架,只不过绑定的另一端不是普通后端,而是一个会读你状态、也会改你状态的 LLM。
用起来什么样——应用侧代码其实很薄。你从主 hook 拿消息,组件内容块自带一个渲染好的 React 元素:
// 示意,非源码:应用侧只需渲染 content.renderedComponent
function MessageList() {
const { messages } = useTambo();
return messages.map((m) => (
<div key={m.id}>
{m.content.map((c) =>
c.type === "component" ? c.renderedComponent : /* 文本等其它块 */ null,
)}
</div>
));
}
那个 renderedComponent 是谁造的、里面发生了什么,就是本章的主线。
2. 顶层全景(下行 + 上行一张图)
先给一句"怎么读这张图":左半边是下行(状态一路变成屏幕上的组件),右半边是上行(组件里的状态一路回到模型的下一轮决策)。中间那个 <你的组件> 是两条路的交汇点。
下行:状态 → 屏幕
streamState ┌─────────────────────────────────────────────┐
(第3章攒的) ──────▶ │ ① useTambo(): 遍历消息, 给每个 component 块 │
│ 造一个 <ComponentRenderer> 并缓存 │
└───────────────────┬─────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ ② ComponentRenderer: │
│ · 按 name 从注册表取组件(第1章注册的) │
│ · partial-json 解析半截 props │
│ · Standard Schema 容错校验 │
│ · React.createElement(组件, props) │
└───────────────────┬─────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ ③ ComponentContentProvider 包一层 │
│ (注入 componentId / threadId 上下文) │
└───────────────────┬─────────────────────────┘
▼
┌───────────────┐
│ <你的组件> │◀── 用户点击/输入
└───────┬───────┘
│ useTamboComponentState(key, init)
上行:交互 → 模型 ▼
┌─────────────────────────────────────────────┐
│ ④ setState → 防抖 → POST │
│ threads/:id/components/:id/state │
└───────────────────┬─────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ ⑤ 下一轮 run: 后端把状态注入成 │
│ <component_state>{...}</component_state> │
│ → 模型看得见 → 影响它下一步决策(第2章) │
└─────────────────────────────────────────────┘
各部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
useTambo | 遍历流式消息,给每个组件块挂上现成的 renderedComponent 元素,并做缓存 | react-sdk/src/v1/hooks/use-tambo-v1.ts |
useTamboMessages | 更轻的消息表面:只读消息 + 过滤视图,不做渲染 | react-sdk/src/v1/hooks/use-tambo-v1-messages.ts |
ComponentRenderer | 按名取组件、解析半截 props、校验、createElement | react-sdk/src/v1/components/v1-component-renderer.tsx |
ComponentContentProvider | 给渲染出的组件注入"我是谁"(componentId/threadId) | react-sdk/src/v1/utils/component-renderer.tsx |
useTamboComponentState | 组件状态的双向绑定:本地即时 + 防抖回写 + 服务器对账 | react-sdk/src/v1/hooks/use-tambo-v1-component-state.ts |
TamboInteractableProvider | 追踪"页面上现成的可交互组件",替它们注册改 props/state 的工具 | react-sdk/src/providers/tambo-interactable-provider.tsx |
withTamboInteractable | 把任意组件包成 interactable 的 HOC | react-sdk/src/hoc/with-tambo-interactable.tsx |
TamboProvider | 把上面所有层组装进 React 树 | react-sdk/src/v1/providers/tambo-v1-provider.tsx |
3. 核心原理(逐个机制)
3.1 边流边渲染:ComponentRenderer
要解决的小问题: 状态里只有"组件叫 WeatherCard、props 是一串还在流入的 JSON"。React 需要一个真实的组件函数和一份完整的 props 对象——两样都得现凑。
思路: 每次 props 更新就重跑三步——查表 → 解析 → 校验 → 造元素,用 useMemo 保证 props 没变就不重造。
难点一:props 是半截的 JSON。 流式期间 props 可能是 {"city":"东 这种残缺串。直接 JSON.parse 会炸,所以用 partial-json 的宽容解析:
react-sdk/src/v1/components/v1-component-renderer.tsx:15 import { parse } from "partial-json"
react-sdk/src/v1/components/v1-component-renderer.tsx:89 const propsJson = JSON.stringify(content.props ?? {})
react-sdk/src/v1/components/v1-component-renderer.tsx:90 const parsedProps = parse(propsJson) // 半截也能解析
难点二:按名找到组件。 从注册表(第1章 TamboRegistryProvider 建的)按 content.name 取回组件定义:
react-sdk/src/v1/components/v1-component-renderer.tsx:83 getComponentFromRegistry(content.name, registry.componentList)
难点三:边流边校验但不能崩。 如果注册时给了 Standard Schema(Zod/Valibot 等),就调它的 ~standard.validate;校验失败只 console.warn,仍然用原始 props 渲染——因为流到一半的 props 本来就"不合法",不能因此白屏:
react-sdk/src/v1/components/v1-component-renderer.tsx:98-116
if (isStandardSchema(registeredComponent.props)) {
const result = registeredComponent.props["~standard"].validate(parsedProps);
if ("value" in result) validatedProps = result.value; // 成功: 用净化后的
else console.warn(...); // 失败: 警告但继续
}
最后 React.createElement 造出元素,并用 content.id 做 key —— 只要 key 稳定,React 的协调就保住组件实例,流式更新时不会重挂载、不丢内部状态:
react-sdk/src/v1/components/v1-component-renderer.tsx:118 React.createElement(registeredComponent.component, validatedProps)
react-sdk/src/v1/components/v1-component-renderer.tsx:131-139 useMemo 依赖 [content.id, content.name, content.props, content.streamingState, ...]
content.streamingState 是 "started" | "streaming" | "done"(见 packages/client/src/types/message.ts:47),渲染器把它列进 memo 依赖,好让状态翻转时重算。
坑: 整段渲染包在 try/catch 里,任何异常(取不到组件、造元素失败)都被吞成 null,再落到 fallback(v1-component-renderer.tsx:119-143)。好处是单个坏组件不会拖垮整条消息;代价是错误只进 console.error,应用侧看不到。
3.2 消息表面:useTambo 的组件缓存 vs useTamboMessages
要解决的小问题: 应用不想自己写 <ComponentRenderer>。所以主 hook 直接在每个组件内容块上挂一个造好的元素 renderedComponent,应用渲染它即可。
useTambo 遍历原始消息,对 type === "component" 的块造 ComponentRenderer 元素:
react-sdk/src/v1/hooks/use-tambo-v1.ts:319 rawMessages.map(...) // 逐条转换
react-sdk/src/v1/hooks/use-tambo-v1.ts:381-386 React.createElement(ComponentRenderer, { key, content, threadId, messageId })
关键细节:元素级缓存。 流式期间这个转换每来一个 token 就跑一次。若每次都造新元素,React 会认成新节点、反复重挂。于是用 componentCacheRef 按 component.id 缓存,props 的 JSON 没变就复用上次那个元素引用:
react-sdk/src/v1/hooks/use-tambo-v1.ts:220 componentCacheRef = useRef(new Map<string, ComponentCacheEntry>())
react-sdk/src/v1/hooks/use-tambo-v1.ts:368-378 const propsJson = JSON.stringify(...); if (cached?.propsJson === propsJson) return cached.element
这是两层缓存:useTambo 缓存"元素引用"(避免重挂),ComponentRenderer 内部再用 useMemo 缓存"渲染结果"(避免重算)。
同一个 useTambo 还顺手给工具调用块算好展示状态(hasCompleted、statusMessage),并剥掉 _tambo_* 内部 prop(use-tambo-v1.ts:319-360)——这些是给聊天 UI 显示"正在调用 X / 已调用 X"用的。
轻量替身 useTamboMessages。 如果只想读消息、不需要渲染,用它:只从 stream 状态取某个 thread 的消息,给几个过滤视图(user/assistant)和计数,不造任何元素:
react-sdk/src/v1/hooks/use-tambo-v1-messages.ts:75-93 messages / lastMessage / userMessages / assistantMessages / hasMessages
3.3 组件怎么知道"我是谁":ComponentContentProvider
要解决的小问题: 渲染出来的组件里若调 useTamboComponentState('count', 0),这个 hook 得知道自己属于哪个组件、哪个 thread,才能把状态写回正确的地址。可组件本身并不接收这些 id 当 prop。
思路: 用 React Context 隐式下传。ComponentRenderer 在组件外面包一层 ComponentContentProvider,把 componentId / threadId / messageId / componentName 塞进 context:
react-sdk/src/v1/components/v1-component-renderer.tsx:146-155 <ComponentContentProvider componentId={content.id} threadId={threadId} ...>
react-sdk/src/v1/utils/component-renderer.tsx:36-54 ComponentContentProvider(memo 化 value)
组件里的 hook 用两个读取器拿它:
| 读取器 | 行为 | 用途 |
|---|---|---|
useComponentContent() | 拿不到就抛错 | 明确必须在渲染组件内使用的场景 |
useComponentContentOptional() | 拿不到返回 null | 允许"还没被 provider 包住"的过渡态 |
react-sdk/src/v1/utils/component-renderer.tsx:62-70 useComponentContent(): 无 context 抛错
react-sdk/src/v1/utils/component-renderer.tsx:77-79 useComponentContentOptional(): 无 context 返回 null
useTamboComponentState 用的是 Optional 版——这正是它能"三态自适应"的钥匙(见下节)。
3.4 状态双向绑定:useTamboComponentState
这是本章的核心。它长得像 useState,但多一条到服务器的双向通道。三种模式,靠 context 自动切换:
| 模式 | 判定条件 | 行为 |
|---|---|---|
| 已渲染组件 | 有 context 且 threadId !== "" | 本地即时更新 + 防抖回写服务器 + 反向对账 |
| interactable 组件 | 有 context 且 threadId === "" | 写进 interactable provider 的内存态(不打服务器) |
| 还没上下文 | context 为 null | 退化成普通 useState,无任何副作用 |
判定就这几行:
react-sdk/src/v1/hooks/use-tambo-v1-component-state.ts:95 isContextAvailable = componentContent !== null
react-sdk/src/v1/hooks/use-tambo-v1-component-state.ts:102 isInteractable = isContextAvailable && threadId === ""
threadId === "" 是个约定信号——interactable 组件被包裹时故意把 threadId 设成空串(见 3.5),以此区别于真正落在某个 thread 里的已渲染组件。
下行:服务器状态怎么进到组件。 从累积状态里按 id 找回这个组件的服务器状态,取出 keyName 对应的值当初始/同步源:
react-sdk/src/v1/hooks/use-tambo-v1-component-state.ts:105-111 findComponentContent(streamState, threadId, componentId) → serverState[keyName]
packages/client/src/utils/thread-utils.ts:17 findComponentContent: 从最近的消息往前找该 id 的组件块
上行:本地改动怎么回写。 setState 先即时更新本地(UI 不卡),再触发一个防抖回调打服务器:
react-sdk/src/v1/hooks/use-tambo-v1-component-state.ts:137-168 syncToServer = useDebouncedCallback(async ... , debounceTime=500)
react-sdk/src/v1/hooks/use-tambo-v1-component-state.ts:146 await client.threads.state.updateState(componentId, { threadId, state: { [keyName]: newState }, userKey })
这个调用打到的正是 POST threads/:threadId/components/:componentId/state(apps/api/src/v1/v1.controller.ts:607)——支持整体替换或 JSON Patch,且thread 有活跃 run 时会 409 拒绝(状态不能在模型正在生成时被改)。
难点:本地 vs 服务器抢写怎么办。 流式期间服务器也会推状态,可 能和用户刚点的东西打架。它用三个 ref 做对账,规则很清楚:
hasPendingLocalChangeRef—— 有没写完的本地改动就别让服务器覆盖(:241)。lastSentValueRef—— 服务器回来的值若等于我上次发的,是回声,忽略(:245-250)。syncSeqRef—— 只有最新那次请求完成才清isPending,防止旧请求乱清标志(:140、:164)。
反向同步 effect 就在这几行(仅已渲染组件生效):
react-sdk/src/v1/hooks/use-tambo-v1-component-state.ts:236-256 服务器值变了且非本地待写、非回声 → setLocalState
别丢最后一次改动。 卸载时 flush 一次防抖队列,避免用户改完立刻切走导致丢写:
react-sdk/src/v1/hooks/use-tambo-v1-component-state.ts:259-264 return () => void syncToServer.flush()
⑤ 闭环:状态怎么回到模型。 回写到服务器的状态,会在下一轮 run 里被后端拼进消息 ——序列化成一段 <component_state> 标签塞给模型:
packages/backend/src/util/thread-to-model-message-conversion.ts:386-394
if (message.componentState && ...) {
content.push({ type: "text", text: `<component_state>${safeJson}</component_state>` });
}
这就是"用户点了组件里的开关,模型下一句就知道开关开着"的机制来源——呼应 第2章的决策循环。
3.5 Interactable:让"页面上已有的组件"也能被 AI 操纵
前面讲的是"AI 生成的组件"。反过来,你手写的普通组件也能变成 AI 能读能改的对象——这就是 interactable。
思路: 有个 provider 维护一张"当前可交互组件"清单,并替每个组件动态注册两把工具——改 props 的、改 state 的——AI 用这些工具就能操纵它们:
react-sdk/src/providers/tambo-interactable-provider.tsx:299-358 registerInteractableComponentPropsUpdateTool → 工具名 update_component_props_<id>
react-sdk/src/providers/tambo-interactable-provider.tsx:360-419 registerInteractableComponentStateUpdateTool → 工具名 update_component_state_<id>
react-sdk/src/providers/tambo-interactable-provider.tsx:421-450 addInteractableComponent: 生成 id、建 state、注册两把工具
这两把工具带 tamboStreamableHint: true(:351、:412),所以 AI 改 props/state 时是边流边应用的(呼应 第4章的流式工具执行)。清单还通过 context helper 注入给模型,让它知道"现在页面上有哪些可交互的东西":
react-sdk/src/providers/tambo-interactable-provider.tsx:95-101 addContextHelper("interactables", ...)
HOC 把普通组件接进来。 withTamboInteractable 包裹后:挂载时注册进清单、卸载时移除、父层 props 变化时同步进 provider,然后用 ComponentContentProvider 把组件包住——注意 threadId="",这正是 3.4 里 interactable 模式的判定信号:
react-sdk/src/hoc/with-tambo-interactable.tsx:98 withTamboInteractable(WrappedComponent, config)
react-sdk/src/hoc/with-tambo-interactable.tsx:191-193 还没拿到 id 时: 先裸渲染(此时 useTamboComponentState 退化成普通 useState)
react-sdk/src/hoc/with-tambo-interactable.tsx:222-229 拿到 id 后: <ComponentContentProvider threadId="" ...>
于是同一个 useTamboComponentState 在 interactable 组件里,setState 走的是内存态而非服务器:
react-sdk/src/v1/hooks/use-tambo-v1-component-state.ts:184-186 isInteractable → setInteractableState(componentId, keyName, nextState)
react-sdk/src/providers/tambo-interactable-provider.tsx:483-513 setInteractableStateValue: 不可变更新清单里那个组件的 state
AI 通过工具改了 interactable 的 state 后,provider 的 interactableState 变化,组件里的 hook 通过一个 effect 把它同步进本地(use-tambo-v1-component-state.ts:227-233)——双向绑定在 interactable 这边也成立,只是"另一端"从服务器换成了内存 provider。
clearInteractableSelections(:542-547)在每次发消息后被调用(见 第4章 的 useTamboSendMessage),保证"选中"只对本条消息生效。
3.6 渲染前的校验/容错:三道关,各管一段
"校验"在这套里不是一处,而是分布在不同时机的三道关。别混淆:
| 关卡 | 时机 | 管什么 | 位置 |
|---|---|---|---|
validateInput | 用户提交前 | 消息文本非空、≤10000 字符 | react-sdk/src/model/validate-input.ts:12 |
assertNoRecordSchema | 组件/工具注册时 | 禁止"动态键的 record 类型"(后端序列化不了) | packages/client/src/schema/validate.ts:148 |
Standard Schema ~standard.validate | 组件渲染时 | 校验流入的 props,失败仍容错渲染 | v1-component-renderer.tsx:98-116 |
前两道是"入口守门"(输入文本、schema 形状),第三道才是 3.1 讲的"渲染前 props 净化"。三者独立,别当成一件事。
一个易被忽略的细节:判断"这是不是一个能校验的 schema",用的是鸭子类型而非 instanceof,以躲开跨 Zod 版本的兼容坑:
packages/client/src/schema/standard-schema.ts:22 isStandardSchema: 检查 obj["~standard"] 有 version===1 / vendor / validate
4. 收尾:TamboProvider 把所有层组装进 React 树
前面每一层都靠 context 工作,谁来把它们按正确顺序嵌好?TamboProvider。它是一串 provider 的合成,顺序有讲究:
react-sdk/src/v1/providers/tambo-v1-provider.tsx:287-324
<TamboClientProvider> // 客户端 + 鉴权
<TamboRegistryProvider> // 组件/工具注册表(第1章)
<TamboContextHelpersProvider>
<TamboMcpTokenProvider><TamboMcpProvider>
<TamboContextAttachmentProvider>
<TamboInteractableProvider> // 本章 3.5
<TamboConfigContext.Provider> // 静态配置
<TamboStreamProvider> // 累积状态(第3章)
<TamboThreadInputProvider> // 输入表面
{children}
为什么 TamboInteractableProvider 在 TamboStreamProvider 外面? 因为 interactable 注册的工具要进注册表,而注册表更靠外;流状态则要能读到这些注册结果。为什么 config 是静态的? 它在 :280-285 一次性建 好、整个会话不变,所以直接当普通对象传,不需要额外的记忆化。
输入这边由 TamboThreadInputProvider 兜底(tambo-v1-thread-input-provider.tsx:162):它把"输入框的值 + 暂存图片 + 提交"做成共享状态,submit() 里组装内容、乐观清空、失败回填,最后交给 第4章 的 useTamboSendMessage。应用侧只碰 useTamboThreadInput()(:303)这一层薄表面。
5. 巧妙之处(可借鉴)
- 稳定 key = 免费的实例保持。 用
content.id当 React key,让"流式更新组件"退化成 React 普通协调问题,组件内部状态和 DOM 焦点都不丢(v1-component-renderer.tsx:57-70的注释把这条讲得很直白)。 - 两层缓存各管一件事。
useTambo缓存元素引用(防重挂),ComponentRenderer缓存渲染结果(防重算)——都以 props 的 JSON 串做指纹(use-tambo-v1.ts:368-378)。 - 一个 hook 三种模式,靠 context 自识别。
useTamboComponentState用 Optional 读取器 +threadId===""约定,把"已渲染 / interactable / 还没上下文"三种运行环境收进同一个 API(use-tambo-v1-component-state.ts:95-102)。 - 容错优先于正确。 流式期间 props 天生残缺,所以校验失败只警告不阻断,渲染异常吞成 fallback——宁可显示不完整,也不白屏(
v1-component-renderer.tsx:107-115、:119-130)。 - 状态对账用三个 ref 而非锁。 pending 标志 + 上次发送值 + 请求序号,三者组合就把"本地/服务器抢写""回声覆盖""旧请求乱清标志"三个竞态一并解决(
use-tambo-v1-component-state.ts:127-134)。
6. 边界与局限(诚实)
- 异步校验直接跳过。 若 schema 的
validate返回 Promise,渲染器只console.warn然后不校验——props 原样渲染(v1-component-renderer.tsx:102-106)。 - 渲染错误对应用不可见。 坏组件被
try/catch吞成null,只进console.error;应用拿不到结构化错误,只能看到 fallback(v1-component-renderer.tsx:119-130)。 - 多轮工具循环里 context 是快照。
useTamboSendMessage里一句TODO明说:interactable 上下文只在发送前抓一次,整个多轮工具循环复用同一份;流式中途 interactable 若变了,续跑会带旧上下文(use-tambo-v1-send-message.ts:551-554)。 - interactable 的 id 有长度上限。 因为工具名是
update_component_props_<id>,受工具名总长约束,id 过长直接抛错(tambo-interactable-provider.tsx:303-307)。 - interactable 状态不校验 schema。 provider 里留了
TODO(lachieh): validate state against schema?——目前 AI 改 state 不过 schema 校验(tambo-interactable-provider.tsx:284)。 - 回写要求 thread 空闲。 run 活跃时
POST .../state返回 409,此刻的本地改动只能等 run 结束后由防抖重试或用户再触发(apps/api/src/v1/v1.controller.ts:637-640)。
7. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 单个组件的渲染器 | react-sdk/src/v1/components/v1-component-renderer.tsx | ComponentRenderer |
| 组件上下文注入/读取 | react-sdk/src/v1/utils/component-renderer.tsx | ComponentContentProvider · useComponentContent · useComponentContentOptional |
| 主 hook + 元素缓存 | react-sdk/src/v1/hooks/use-tambo-v1.ts | useTambo · componentCacheRef |
| 轻量消息表面 | react-sdk/src/v1/hooks/use-tambo-v1-messages.ts | useTamboMessages |
| 状态双向绑定 | react-sdk/src/v1/hooks/use-tambo-v1-component-state.ts | useTamboComponentState · syncToServer |
| 按 id 找组件状态 | packages/client/src/utils/thread-utils.ts | findComponentContent |
| interactable 追踪 + 工具注册 | react-sdk/src/providers/tambo-interactable-provider.tsx | TamboInteractableProvider · addInteractableComponent · setInteractableStateValue |
| interactable HOC | react-sdk/src/hoc/with-tambo-interactable.tsx | withTamboInteractable |
| 消息输入表面 | react-sdk/src/v1/providers/tambo-v1-thread-input-provider.tsx | TamboThreadInputProvider · useTamboThreadInput |
| 输入文本校验 | react-sdk/src/model/validate-input.ts | validateInput |
| 注册期 schema 校验 | packages/client/src/schema/validate.ts | assertNoRecordSchema |
| Standard Schema 识别 | packages/client/src/schema/standard-schema.ts | isStandardSchema |
| 组装所有层 | react-sdk/src/v1/providers/tambo-v1-provider.tsx | TamboProvider |
| 状态回写端点 | apps/api/src/v1/v1.controller.ts | updateComponentState(POST threads/:threadId/components/:componentId/state) |
| 状态注入模型 | packages/backend/src/util/thread-to-model-message-conversion.ts | <component_state> 注入(第 386-394 行) |
相邻章节: index(全景与阅读地图) · 01 注册模型 · 02 决策循环 · 03 流式协议 · 04 消息的一生