03 · 跨轮次状态:widgetState + widgetSessionId 合流模型
这章讲一个真实难题:对话有多轮,widget 每轮都是新渲染的——怎么让「购物车」在轮次之间不清空?用购物车例子讲透。
1. 要解决的小问题
用户第一轮说「加 2 个鸡蛋」,第二轮说「再加 1 个面包」。第二轮 widget 重新渲染时,如果只拿到本轮 toolOutput(只有面包),鸡蛋就丢了。需要一种机制让新一轮能看到旧状态。
2. 思路:用一个会话 ID 当「储物柜钥匙」
核心约定:服务器在每次工具响应里盖一个 openai/widgetSessionId。host 拿这个 ID 当 key,保管对应的 widgetState,下一轮渲染同 session 的 widget 时把它喂回去(inferred —— host 保管逻辑不在本仓,但 shopping_cart_python/README.md 明确这么描述)。
第 1 轮: 第 2 轮:
call_tool(加鸡蛋) call_tool(加面包)
server 返回: server 返回:
structuredContent{鸡蛋} structuredContent{面包}
_meta.widgetSessionId = "c1" _meta.widgetSessionId = "c1" ← 同一把钥匙
│ │
▼ ▼
host 存 widgetState[c1] host 把 widgetState[c1](含鸡蛋)
连同新 toolOutput 一起注入 widget
│
▼
widget 合流:旧鸡蛋 + 新面包 → 回写
3. 服务器侧:盖 session 戳
购物车 server 在 call_tool 里把 cartId 同时放进 structuredContent 和 _meta(shopping_cart_python/main.py:204-210):
# 示意,非源码 —— 把 cartId 当 session id
structured_content = {"cartId": cart_id, "items": [...]}
meta = _widget_meta()
meta["openai/widgetSessionId"] = cart_id # 这一行是跨轮次的关键
cart_id 由 _get_or_create_cart(shopping_cart_python/main.py:105-111)管理:传了已存在的就复用,否则 uuid4().hex 开新的。
4. widget 侧:合流再回写
先分清 widget 里的两条写状态路径——它们在购物车 App(src/shopping-cart/index.tsx)里是两个不同的函数:
addItem(src/shopping-cart/index.tsx:99-123) 只是本地按钮的 +1 增量:它从prevState(上一份本地cartState)起步,对某个名字quantity + 1再回写。它不读toolOutput,纯粹服务于 UI 里手点加号。- 真正的跨轮次合流在一个
useEffect(src/shopping-cart/index.tsx:160-224)里:它在toolOutput变化时触发,把本轮新增的toolOutput.items和宿主推回来的widgetState合并,再回写整份。这才是本章的核心机制。
合流的关键就在这个 useEffect:它以 widgetState ?? cartState(上一轮的状态)为基底,用一个 itemsByName Map 按名字去重叠加新增项(src/shopping-cart/index.tsx:194-214):
// 示意,非源码 —— useEffect 里的跨轮次合流(:194-214)
// widgetState = 上一轮的状态(host 喂回);toolOutput.items = 本轮新增
const baseState = widgetState ?? cartState ?? createDefaultCartState();
const itemsByName = new Map();
for (const item of baseState.items ?? []) itemsByName.set(item.name, item); // 旧鸡蛋
for (const item of incomingItems) // 新面包
itemsByName.set(item.name, { ...itemsByName.get(item.name), ...item });
const nextItems = Array.from(itemsByName.values());
setCartState({ ...baseState, items: nextItems }); // 回写合流后的整份
关键点:setCartState 来自 useWidgetState(见 02 章 §3),回写时会同步推给宿主;而 useWidgetState 内部的 useEffect 又会在宿主推回新 widgetState 时更新本地——形成一个双向同步环。于是 UI 里手动加减数量(addItem)、和模型通过工具加的东西(useEffect 合流),落进同一份状态。
5. 关键细节 / 坑
- 示例故意不持久化。
shopping_cart_python/main.py:199-200把真正写进carts[cart_id]的那行注释掉了,改用一个本次调用内的空cart_items = []。也就是说服务器端不真的累积——累积发生在 widget+host 的widgetState里。README 明说生产应服务器端持久化。 - CartItem 允许额外字段(
shopping_cart_python/main.py:70extra="allow"),而顶层AddToCartInput用extra="forbid"(:86)——入参收紧、单品宽松。 - session id 不一定要是 cart 这种业务 ID;
authenticated_server_python/main.py:352就直接硬编码了一个"ren-test-session-id"当 demo。
6. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 盖 session 戳 | shopping_cart_python/main.py | _handle_call_tool、_get_or_create_cart |
| 跨轮次合流(核心) | src/shopping-cart/index.tsx | App 内的 useEffect(:160-224)、itemsByName |
| 本地增量按钮 | src/shopping-cart/index.tsx | App、addItem(:99-123) |
| 双向同步底座 | src/use-widget-state.ts | useWidgetState |
| 状态流说明 | shopping_cart_python/README.md | (How the state flow works 节) |