1. 为什么要考虑小部件的状态
在普通的 React 应用里你早就习惯了:有本地 state,有 API 请求,最多再加个 Zustand/Redux,一切都围绕用户的浏览器打转。
在 ChatGPT App 中情况不同。你的小部件仅仅是三类实体之上的一个轻量 UI 层:
- ChatGPT 模型,决定何时调用你的 App 以及要传入哪些参数;
- MCP 服务器/后端,存放真实数据并执行业务逻辑;
- 聊天上下文,承载这一切,并可能在一小时、一天或一周后被重新打开。
因此,“状态放在哪里”不是学术问题,而是非常实际的选择。如果所有东西都塞进 React state,一旦聊天发生细微变化,用户的选择就会丢失。若把一切都放进 widgetState,模型会去读一大坨 JSON,并可能对其“发挥想象”。如果反过来把所有东西都放到服务器上、每个像素都重新请求,那就会又慢又贵。
官方建议将 ChatGPT App 的状态明确划分为三类:业务数据、短暂的 UI 状态以及跨会话的持久状态。我们就从这里开始。
2. ChatGPT App 的状态地图
Apps SDK 文档描述了三种状态类型。把它们放在一张表里会更直观:
| 状态类型 | 存放位置 | 生命周期 | 示例 |
|---|---|---|---|
| 业务数据(权威) | MCP 服务器 / 你的后端 | 长久:数天、数周、数年 | 任务、订单、商品 |
| UI 状态(短暂) | 小部件内部 | 随该小部件实例的生命周期存在 | 选中的卡片、排序、展开的折叠项 |
| 跨会话状态(持久) | 你的后端 / 存储 | 跨会话与聊天之间 | 已保存的筛选器、工作区、固定的看板 |
重要:authoritative 数据应当保留在服务器,而不是小部件里。小部件通过工具(MCP tools)获取这些数据的快照并进行渲染,然后叠加自身的本地 UI 状态。
本讲我们聚焦于小部件能直接看到的内容:
- toolInput —— 被调用工具(tool)的入参;
- toolOutput —— 来自服务器的 structuredContent(主要数据);
- toolResponseMetadata —— 仅小部件可见的服务性元数据 _meta;
- widgetState —— 由 ChatGPT 和消息一起保存的 UI 状态。
3. 小部件实际接收到什么:ToolInput、ToolOutput、Metadata、WidgetState
这三类状态在 ChatGPT App 中对应到平台注入 window.openai 的具体字段,并通过 SDK 的 hooks 传入。 实际上你会通过 React hooks 获取它们,但了解准确的定义很有用。
toolInput
这是工具(tool)的参数对象,由模型在调用该工具时传入。
例如,用户写道:
“为一位 30 岁女性提供礼物创意,预算 100 美元。”
模型决定调用你的工具 gift_search,参数如下:
{
"recipient": "female",
"age": 30,
"budget": 100,
"occasion": "birthday"
}
在小部件内部,你会在 toolInput 中看到这个对象。这里保存了场景的初始配置——也就是启动你的 App 的初衷。
toolOutput
这是你的 MCP 服务器 / 后端在执行工具时返回的 structuredContent。
通常是类似这样的 JSON:
{
"gifts": [
{ "id": "1", "title": "冰岛旅行指南", "price": 45 },
{ "id": "2", "title": "旅行电子书", "price": 20 }
],
"total": 2
}
toolOutput 正是渲染的主要数据源。官方强调:模型会逐字阅读这个字段,因此请保持它简洁易懂。
toolResponseMetadata
这是工具响应中的 _meta,同样可通过 window.openai 以 toolResponseMetadata 访问。文档特别指出:_meta 的内容只有小部件能看到,模型不会接收它。
典型示例:
- 你系统中的内部 ID;
- UI 标志(例如“是否命中缓存”);
- 用于调试的服务性消息。
简而言之:toolOutput 是“要告诉用户与模型的内容”,而 _meta 是“仅小部件与日志需要知道的内容”。
widgetState
这是一个 JSON 对象,ChatGPT 用它在渲染之间保存该小部件的 UI 状态快照。
它的特性:
- 存放在 ChatGPT 端,并绑定到具体的 message/widgetId;
- 在再次打开同一条消息时会被恢复;
- 小部件和模型都可见(widgetState 的数据会进入 LLM 的上下文);
- 大小大约限制在 4k tokens,不能把所有东西都往里丢,尤其不能放超大的列表。
重要:widgetState 不是放置机密的地方。不能把 token 或 PII 放进去,因为模型会看到它,而且平台也不把它定位为安全存储。
4. 本地 React 状态:它依然有用的地方
尽管有 toolOutput 与 widgetState 的“魔法”,在小部件内部你依然在使用普通的 React: useState、useReducer、useRef 等等。 唯一的区别是:
- 本地 state 的寿命与该渲染/iframe 的寿命一致;
- 模型完全不可见;
- 当小部件被卸载(用户切换到其他聊天、重绘或刷新)时,本地 state 就会消失。
本地 state 非常适合:
- 瞬时交互 —— hover、已选 tab、已展开的下拉;
- 提交前的表单输入;
- 临时标志,如 isSubmitting 或 isTooltipOpen。
在我们的教学 App GiftGenius(礼物推荐助手)里有一个小例子:
const [selectedGiftId, setSelectedGiftId] = useState<string | null>(null);
return (
<div>
{gifts.map(gift => (
<button
key={gift.id}
onClick={() => setSelectedGiftId(gift.id)}
>
{gift.title}
</button>
))}
</div>
);
在未点击“确认选择”之前,这是本地 state 的极佳用例。但一旦希望该选择能在小部件更新之间“保留下来”,就应考虑 widgetState。
5. widgetState:渲染之间的小部件记忆
widgetState 是平台代为保存的小部件“记忆”。在每个重要的 UI 行为后,你可以调用 setWidgetState,ChatGPT 会把该 JSON 与消息一起保存。下次渲染同一个小部件时(比如用户在聊天历史中上下切换)SDK 会恢复该对象并传给你。
严格来说,你也可以直接操作 window.openai.widgetState 与 window.openai.setWidgetState, 但在本讲中我们遵循推荐路径——通过 SDK 层的 React hooks 来使用。
Hook:useWidgetState
其中一个 hook 正好封装了 widgetState。它会:
- 从 window.openai.widgetState 或传入的 defaultState 取初始值;
- 订阅宿主的更新;
- 在你每次调用 setWidgetState 时,通过 window.openai.setWidgetState 向上同步新值。
小部件组件中的典型用法(语法在模板中可能略有不同,但思路一致):
import { useWidgetState } from "@openai/chatgpt-apps-sdk/react";
type GiftUiState = { likedIds: string[] };
const [uiState, setUiState] = useWidgetState<GiftUiState>(() => ({
likedIds: [],
}));
现在,即使用户:
- 收起/展开聊天;
- 切换到其他对话再返回;
- 刷新页面(如果平台决定恢复该小部件);
也能恢复到相同的 uiState。
示例:记住选中的礼物
我们从 toolOutput 里取礼物列表,并把选中的礼物存入 widgetState,防止丢失。
type Gift = { id: string; title: string; price: number };
const [uiState, setUiState] = useWidgetState<{ selectedId: string | null }>(() => ({
selectedId: null,
}));
return (
<ul>
{gifts.map(gift => (
<li
key={gift.id}
style={{
fontWeight: uiState?.selectedId === gift.id ? "bold" : "normal",
}}
onClick={() => setUiState({ selectedId: gift.id })}
>
{gift.title}
</li>
))}
</ul>
);
这里的关键点:setUiState 不只是修改本地 React state,它还会在可用时在底层调用 window.openai.setWidgetState。
如果用户之后在该小部件下点击 follow‑up,ChatGPT 可能会沿用相同的 widgetId 和相同的 widgetState 继续对话,模型也就能看到哪个礼物被选中了。
6. 在 React 中读取工具数据:useWidgetProps 等
为了避免每个组件都去手动访问 window.openai.toolOutput,Apps SDK 提供了一个很实用的抽象层 —— hook useWidgetProps。它会从全局读取 toolOutput,返回一个带类型的对象,并可按需混入默认值。
简化后的签名大致如下:
export function useWidgetProps<T>(defaultState?: T | () => T): T {
const toolOutput = useOpenAIGlobal("toolOutput") as T;
return toolOutput ?? defaultState ?? null;
}
也就是说,你会直接拿到作为类型 T 的 toolOutput。
假设我们的 MCP 工具返回如下 structuredContent:
type GiftToolOutput = {
gifts: { id: string; title: string; price: number }[];
currency: string;
};
小部件可以这样读取:
import { useWidgetProps } from "@openai/chatgpt-apps-sdk/react";
export function GiftListWidget() {
const { gifts, currency } = useWidgetProps<GiftToolOutput>(() => ({
gifts: [],
currency: "USD",
}));
if (!gifts.length) {
return <div>暂时没有合适的点子。试试其他请求。</div>;
}
return (
<ul>
{gifts.map(gift => (
<li key={gift.id}>
{gift.title} — {gift.price} {currency}
</li>
))}
</ul>
);
}
这里有几条不错的实践:
- 不要假设 toolOutput 一定存在 —— 提供默认值;
- 谨慎处理空列表;
- 不要直接访问 window.openai —— 统一走 hook。
7. 将 UI 与 toolOutput 同步:加载、空数据、错误
在真实环境中,toolOutput 并不总是立刻到达,也不总是“完美”。Apps SDK 文档明确建议考虑三种状态:加载、正常数据、错误/空。
最简单的模式:
type GiftToolOutput = {
gifts: { id: string; title: string }[];
error?: string;
};
const data = useWidgetProps<GiftToolOutput | null>(() => null);
if (data === null) {
return <div>正在加载礼物创意…</div>;
}
if (data.error) {
return <div>错误: {data.error}</div>;
}
if (!data.gifts.length) {
return <div>没有找到符合你条件的结果。</div>;
}
return (
<ul>
{data.gifts.map(gift => (
<li key={gift.id}>{gift.title}</li>
))}
</ul>
);
这种方式与服务器和模型可能重试调用工具的机制配合良好:你会拿到新的 toolOutput。小部件通过 useWidgetProps 获取到新值并重新渲染即可。
整体流程大致如下:
用户 → 请求
↓
模型 → 调用 MCP tool
↓
服务器 → 处理、访问数据库/集成,返回 structuredContent 和 _meta
↓
ChatGPT → 将 structuredContent 放入 toolOutput
↓
小部件 → 依据 toolOutput + widgetState 渲染 UI
服务器端的官方指南也画了几乎相同的图 “User → Model → MCP tool → widget iframe”,其中 toolOutput 是小部件的主输入。
8. 多步骤场景:在 widgetState 中保存当前步骤
我们的 GiftGenius 很难只用一个卡片就结束。更常见的是“向导”式步骤:先收集偏好,再确定预算,最后给出具体备选。
保存向导步骤编号的合理方式就是放到 widgetState。文档和示例都这样推荐。
一个由两步组成的迷你向导示例:
type GiftWizardState = {
step: 1 | 2;
budget?: number;
};
const [state, setState] = useWidgetState<GiftWizardState>(() => ({ step: 1 }));
if (state.step === 1) {
return (
<div>
<label>
预算, $
<input
type="number"
defaultValue={state.budget ?? 50}
onBlur={e =>
setState({ step: 2, budget: Number(e.target.value) || 50 })
}
/>
</label>
</div>
);
}
return (
<div>
<div>正在查找不超过 {state.budget} $ 的礼物…</div>
{/* 这里就可以渲染包含礼物的 toolOutput */}
</div>
);
这里有几个要点:
- 首次显示时 step 等于 1,用户输入预算;
- 在 onBlur 后,我们把 widgetState 更新为 { step: 2, budget: … };
- 下一次渲染(包括一分钟后或再次打开该消息时),小部件会直接停留在第 2 步,并带有保存的预算。
更进阶的版本里,你会在第二步通过 useCallTool 启动一个工具,把 budget 传过去,并从 toolOutput 读取结果。但这是“工具”模块(模块 4)的内容,今天主要讲的是我们把“步骤”信息放在哪里。
9. 放什么到哪里:薄 UI、厚后端
总结一下各自职责:
- authoritative 数据(礼物列表、订单状态)放在服务器,通过 toolOutput 传来;
- 临时的视觉交互(折叠是否展开、未提交输入的临时内容)放在本地 React state;
- 在单个小部件内需要持久的 UI 决策(当前步骤、选中项、排序)放在 widgetState;
- 跨聊天的用户长期偏好(偏爱的礼物类别、最近一次使用的货币)放在你的后端作为持久状态。
有时很想做一个“超级大对象”,把一切放进 widgetState 然后高枕无忧。但这是个坏主意。文档强调,通过 widgetState 传递的状态会整体进入模型上下文,应保持轻量,并以 UI 为主。
toolOutput 也同理:只放小部件和模型都需要、足以向用户解释“发生了什么”的数据。巨大的树、二进制 blob、其他 API 的原始响应——都会把模型引向奇怪且昂贵的回答。
Insight
在 ChatGPT 小部件中无法依赖传统的客户端标识机制。Cookie 实际上不可用:小部件作为第三方资源在 ChatGPT 的沙箱中加载,而现代浏览器默认阻止第三方 Cookie。由此,任何试图通过 Cookie 来保存状态的做法都不可行。
实测:localStorage 工作良好,你在设计应用时可以放心依赖它。
10. 一个小的端到端示例:GiftGenius 的持久选择
我们把一切拼在一个迷你小部件中,它会:
- 从 toolOutput 读取数据;
- 把用户选择保存到 widgetState;
- 优雅地处理空数据。
import {
useWidgetProps,
useWidgetState,
} from "@openai/chatgpt-apps-sdk/react";
type Gift = { id: string; title: string; price: number };
type GiftToolOutput = { gifts: Gift[]; currency: string; error?: string };
export function GiftWidget() {
const data = useWidgetProps<GiftToolOutput | null>(() => null);
const [uiState, setUiState] = useWidgetState<{ selectedId: string | null }>(
() => ({ selectedId: null })
);
if (data === null) {
return <div>请稍等,正在挑选点子…</div>;
}
if (data.error) {
return <div>错误: {data.error}</div>;
}
if (!data.gifts.length) {
return <div>很遗憾,没有找到结果。试试其他请求。</div>;
}
return (
<ul>
{data.gifts.map(gift => (
<li
key={gift.id}
style={{
fontWeight: uiState?.selectedId === gift.id ? "bold" : "normal",
cursor: "pointer",
}}
onClick={() => setUiState({ selectedId: gift.id })}
>
{gift.title} — {gift.price} {data.currency}
</li>
))}
</ul>
);
}
这段代码已经很接近真实小部件了:
- 如果工具仍在执行,会看到“正在挑选点子”;
- 如果服务器返回错误——如实展示;
- 如果没有礼物——正确处理空结果;
- 选中的礼物会保存在 widgetState 中,模型可在后续对话步骤中使用它。
接下来你可以加入“用这个礼物继续”(follow‑up)按钮、发起新工具调用等,基于已经存入状态的选择来扩展体验。
总之,ChatGPT App 中良好的状态架构可归纳为一个简单理念:业务数据存服务器,当前快照通过 toolOutput 提供;临时 UI 在本地 useState;而与某条消息绑定、但需要在渲染之间保留的小部件上下文放在 widgetState。牢记这张图,不要把“一切都塞进一个层”,小部件对用户与模型都会更可预期。
11. 使用 Widget State、ToolInput 与 ToolOutput 时的常见错误
错误 1:把业务数据存进 widgetState 而不是服务器。
有时会想把一整个实体列表塞进 widgetState,以避免再次访问服务器。这有两个问题:你在复制 authoritative 数据(服务器与小部件可能出现不一致),并且会撑爆模型上下文,因为 widgetState 会整体进入上下文。更好的做法是把真实数据放在服务器,返回一个最新的 toolOutput 作为快照。
错误 2:把机密或 PII 塞进 widgetState。
由于 widgetState 的内容模型可见,且它并非安全存储,绝不能把 token、登录信息、电子邮件、电话等敏感信息放进去。这类信息应存放在服务器端;在 widgetState 中最多只保存记录 ID,再通过 MCP 去处理。
错误 3:认为 toolOutput 一定存在且一定正确。
不做检查就访问 toolOutput.gifts[0] 的小部件早晚会出问题:工具可能返回错误、空数组或结构有变化。建议显式处理“加载”“空”“错误”,之后再进行正常渲染。
错误 4:不必要地把 toolOutput 复制到本地 state。
可能会忍不住来一句 const [data, setData] = useState(toolOutput) 然后只和这个 data 打交道。结果你获得了重复的“真相源”:当新的 toolOutput 到来时,本地 state 并不会自动更新,UI 还在展示旧数据。更好的做法是直接通过 useWidgetProps 读取 toolOutput,或在渲染时派生(映射、过滤)状态,而不是复制整个对象。
错误 5:在需要 widgetState 时只使用本地 useState。
经典 bug:你实现了一个小向导,把 currentStep 存在本地 state,测试一切正常。用户在聊天里滚动,又回来——突然又回到第一步。原因很简单:本地 state 没能熬过小部件的卸载。对于对场景至关重要的步骤,应使用 widgetState,让平台随消息一起恢复。
错误 6:在每个组件中直接访问 window.openai。
从形式上说这能工作,但会导致你强依赖全局对象、调试困难、需要手写事件订阅。官方资料与示例建议使用 hooks 抽象层 (useWidgetProps、useWidgetState、useOpenAiGlobal),它们封装了细节,更易测试。
错误 7:忽视小部件的 message 范围特性。
如果用户不点 follow‑up,而是直接在聊天中发一条新消息,ChatGPT 会创建新的小部件实例,拥有新的 widgetId 和空的 widgetState。那些依赖“单个小部件有永恒记忆”的场景会表现异常。此时要么把跨会话上下文存放在服务器,要么围绕 follow‑up 与“显式继续”来设计 UX。
GO TO FULL VERSION