跳到主要内容

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:70 extra="allow"),而顶层 AddToCartInputextra="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.tsxApp 内的 useEffect(:160-224)、itemsByName
本地增量按钮src/shopping-cart/index.tsxAppaddItem(:99-123)
双向同步底座src/use-widget-state.tsuseWidgetState
状态流说明shopping_cart_python/README.md(How the state flow works 节)