跳到主要内容

前端渲染:把流式 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、校验、createElementreact-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 的 HOCreact-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 会认成新节点、反复重挂。于是用 componentCacheRefcomponent.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 还顺手给工具调用块算好展示状态(hasCompletedstatusMessage),并剥掉 _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}

为什么 TamboInteractableProviderTamboStreamProvider 外面? 因为 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.tsxComponentRenderer
组件上下文注入/读取react-sdk/src/v1/utils/component-renderer.tsxComponentContentProvider · useComponentContent · useComponentContentOptional
主 hook + 元素缓存react-sdk/src/v1/hooks/use-tambo-v1.tsuseTambo · componentCacheRef
轻量消息表面react-sdk/src/v1/hooks/use-tambo-v1-messages.tsuseTamboMessages
状态双向绑定react-sdk/src/v1/hooks/use-tambo-v1-component-state.tsuseTamboComponentState · syncToServer
按 id 找组件状态packages/client/src/utils/thread-utils.tsfindComponentContent
interactable 追踪 + 工具注册react-sdk/src/providers/tambo-interactable-provider.tsxTamboInteractableProvider · addInteractableComponent · setInteractableStateValue
interactable HOCreact-sdk/src/hoc/with-tambo-interactable.tsxwithTamboInteractable
消息输入表面react-sdk/src/v1/providers/tambo-v1-thread-input-provider.tsxTamboThreadInputProvider · useTamboThreadInput
输入文本校验react-sdk/src/model/validate-input.tsvalidateInput
注册期 schema 校验packages/client/src/schema/validate.tsassertNoRecordSchema
Standard Schema 识别packages/client/src/schema/standard-schema.tsisStandardSchema
组装所有层react-sdk/src/v1/providers/tambo-v1-provider.tsxTamboProvider
状态回写端点apps/api/src/v1/v1.controller.tsupdateComponentState(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 消息的一生