CodeGym /课程 /ChatGPT Apps /状态管理 — Widget State, ToolInput, ToolOutput

状态管理 — Widget State, ToolInput, ToolOutput

ChatGPT Apps
第 3 级 , 课程 2
可用

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.openaitoolResponseMetadata 访问。文档特别指出:_meta 的内容只有小部件能看到,模型不会接收它。

典型示例:

  • 你系统中的内部 ID;
  • UI 标志(例如“是否命中缓存”);
  • 用于调试的服务性消息。

简而言之:toolOutput 是“要告诉用户与模型的内容”,而 _meta 是“仅小部件与日志需要知道的内容”。

widgetState

这是一个 JSON 对象,ChatGPT 用它在渲染之间保存该小部件的 UI 状态快照。

它的特性:

  • 存放在 ChatGPT 端,并绑定到具体的 message/widgetId;
  • 在再次打开同一条消息时会被恢复;
  • 小部件和模型都可见(widgetState 的数据会进入 LLM 的上下文);
  • 大小大约限制在 4k tokens,不能把所有东西都往里丢,尤其不能放超大的列表。

重要:widgetState 不是放置机密的地方。不能把 token 或 PII 放进去,因为模型会看到它,而且平台也不把它定位为安全存储。

4. 本地 React 状态:它依然有用的地方

尽管有 toolOutputwidgetState 的“魔法”,在小部件内部你依然在使用普通的 React: useStateuseReduceruseRef 等等。 唯一的区别是:

  • 本地 state 的寿命与该渲染/iframe 的寿命一致;
  • 模型完全不可见;
  • 当小部件被卸载(用户切换到其他聊天、重绘或刷新)时,本地 state 就会消失。

本地 state 非常适合:

  • 瞬时交互 —— hover、已选 tab、已展开的下拉;
  • 提交前的表单输入;
  • 临时标志,如 isSubmittingisTooltipOpen

在我们的教学 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.widgetStatewindow.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;
}

也就是说,你会直接拿到作为类型 TtoolOutput

假设我们的 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 抽象层 (useWidgetPropsuseWidgetStateuseOpenAiGlobal),它们封装了细节,更易测试。

错误 7:忽视小部件的 message 范围特性。
如果用户不点 follow‑up,而是直接在聊天中发一条新消息,ChatGPT 会创建新的小部件实例,拥有新的 widgetId 和空的 widgetState。那些依赖“单个小部件有永恒记忆”的场景会表现异常。此时要么把跨会话上下文存放在服务器,要么围绕 follow‑up 与“显式继续”来设计 UX。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION