跳到主要内容

02 · Widget 运行时:window.openai 全局面与订阅机制

这章讲 widget 侧。读完你能说清:widget 怎么从宿主拿到数据、数据变了怎么自动重渲染、以及它能反向让宿主做哪些事。

1. 一个事实:widget 是纯消费者

widget 跑在 ChatGPT 注入的沙箱 iframe 里。它不连你的服务器,而是和宿主打交道——宿主把一个全局对象 window.openai 塞进 iframe。所有输入从这里读,所有动作往这里发。

这个对象的形状由 src/types.ts 定义(注意:这是类型契约,真正的实现在 ChatGPT 的 web-sandbox,见 src/types.ts:27 注释)。它分两半:

// 示意,非源码 —— window.openai 的两半
window.openai = {
// 一半:只读的「全局状态」(OpenAiGlobals)
theme, locale, displayMode, maxHeight, safeArea, userAgent,
toolInput, toolOutput, toolResponseMetadata, widgetState,
setWidgetState,
// 另一半:可调用的「宿主 API」(API)
callTool, sendFollowUpMessage, openExternal,
requestDisplayMode, requestModal, requestClose,
};

真实定义见 src/types.ts:1-46(OpenAiGlobals:1-25 + API:29-38),全局声明在 src/types.ts:101-109declare global { interface Window { openai: API & OpenAiGlobals } }

回扣 01 章: server 在 call_tool 里返回的 structuredContent,就是这里的 toolOutput;入参就是 toolInput

2. 状态怎么进来:openai:set_globals 事件 + 轮询兜底

host 更新状态(比如主题切暗、工具输出到了)时,会派发一个自定义事件 openai:set_globals,detail 里带变化的字段——detail 的 key 是 globals(Partial<OpenAiGlobals>)(src/types.ts:92-96 定义 SetGlobalsEvent)。widget 不直接读 window.openai,而是订阅这个事件,这样状态变了能自动重渲染。

这套订阅封装在 useOpenAiGlobal(src/use-openai-global.ts:31),基于 React 的 useSyncExternalStore。它的精巧之处:

读某个 key(如 "toolOutput")

├─ window.openai[key] 有值? → 用它,并写进 cachedGlobals 缓存

└─ 没值 → 回退到缓存里上次的值(避免渲染闪烁)

订阅:
├─ 监听 openai:set_globals,detail 里有该 key 就 onChange()
└─ 若初次读不到值,启动 250ms 轮询(最多 40 次)兜底

两条机制并存(src/use-openai-global.ts:54-90):事件是主路径,轮询是兜底——应对「widget 比 host 注入更早跑起来」的竞态(inferred)。轮询拿到值后立即 clearInterval

便捷 hook 都是它的薄封装:

hook读哪个 key文件
useWidgetPropstoolOutputsrc/use-widget-props.ts:3
useDisplayModedisplayModesrc/use-display-mode.ts:4
useMaxHeightmaxHeightsrc/use-max-height.ts:3

3. 状态怎么回写:useWidgetState

widget 想保存自己的状态(比如「便签内容」「购物车」),用 setWidgetStateuseWidgetState(src/use-widget-state.ts:11)把它包成熟悉的「像 useState」接口,但每次 set 会同时做两件事:

// 示意,非源码 —— set 时本地 + 宿主双写
const setWidgetState = (next) => {
_setLocalState(next); // ① 本地 state,立即重渲染
window.openai?.setWidgetState?.(next); // ② 推给宿主持久化
};

真实实现 src/use-widget-state.ts:33-44。它还用一个 useEffect(:26-28)监听宿主推回来的 widgetState,反向同步本地——这正是下一章「跨轮次状态」的基础。

4. widget 能让宿主做什么:host helper API

window.openai 上挂着一排可调用的 helper。kitchen-sink-lite 这个 widget 把它们全用了一遍,是最好的活教材(src/kitchen-sink-lite/kitchen-sink-lite.tsx):

API干什么调用点
callTool(name, args)widget 再调一个 MCP 工具(需 widgetAccessible)kitchen-sink-lite.tsx:195:257
sendFollowUpMessage({prompt})替用户往对话里发一句后续kitchen-sink-lite.tsx:232
requestDisplayMode({mode})申请变 inline/pip/fullscreen(可能被拒)kitchen-sink-lite.tsx:179
requestModal({title,params})弹一个模态kitchen-sink-lite.tsx:302
openExternal({href})在外部打开链接kitchen-sink-lite.tsx:239
setWidgetState(state)持久化状态(见 §3)useWidgetState

callTool 的回向闭环值得记:widget 调 callTool("kitchen-sink-refresh", {...}),这请求经 host 转给你的 server,server 的 call_tool handler 处理后返回——形成 widget↔server 的二次往返(server 侧对应 kitchen_sink_server_node/src/server.ts:269kitchen-sink-refresh 分支)。注意 requestDisplayMode 等都做了 if (!window.openai?.xxx) 的存在性检查(kitchen-sink-lite.tsx:173),因为某些 host 未必实现全部 API。

5. 一个最小 widget 长什么样

挂载极简(src/kitchen-sink-lite/index.tsx:5-9):找到 build 脚本约定的 #<name>-root 节点,createRoot 渲染 React 组件。

// 示意,非源码 —— widget 入口
const root = document.getElementById("kitchen-sink-lite-root");
if (root) createRoot(root).render(<KitchenSinkLite />);

组件内部就是普通 React:const toolOutput = useOpenAiGlobal("toolOutput") 拿数据(kitchen-sink-lite.tsx:98),渲染,绑事件调 helper。没有任何网络层——这是 widget 模型的核心简化。

6. 边界

  • useOpenAiGlobal 的轮询上限是 40 次 × 250ms = 10 秒(src/use-openai-global.ts:63remainingChecks = 40);超时仍无值就放弃,返回 null
  • widget 里可以直接 fetch 外部 API(kitchen-sink-lite.tsx:278 的 demo),沙箱不拦——但这与 MCP 工具调用是两回事。

7. 代码地图

主题文件路径符号名
全局对象类型src/types.tsOpenAiGlobalsAPI
set_globals 事件src/types.tsSET_GLOBALS_EVENT_TYPESetGlobalsEvent
订阅 hooksrc/use-openai-global.tsuseOpenAiGlobalreadOpenAiGlobal
回写状态src/use-widget-state.tsuseWidgetState
props 便捷 hooksrc/use-widget-props.tsuseWidgetProps
全 API 活教材src/kitchen-sink-lite/kitchen-sink-lite.tsxKitchenSinkLitehandleCallToolhandleSendFollowUp