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 的文件描述了三種類型的狀態。把它們記成一張表會很直觀:

狀態類型 所在位置 生命週期 範例
Business data (authoritative) MCP‑伺服器 / 您的後端 長期:數天、數週、數年 任務、訂單、商品
UI state (ephemeral) 特定小工具內部 小工具實例存活期間 被選中的卡片、排序、展開的說明/摺疊
Cross‑session state (durable) 您的後端/儲存 跨工作階段與聊天之間 已保存的篩選、工作區(workspace)、釘選看板(pinned board)

重要: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

這是 ChatGPT 用來在重繪之間保存特定小工具 UI 狀態快照的 JSON 物件。

它的特性:

  • 由 ChatGPT 端保存,並綁定到特定的 message/widgetId;
  • 當再次開啟同一則訊息時會被還原;
  • 小工具與模型都能看到它(widgetState 會被送入 LLM 的脈絡);
  • 大約受限於 4k tokens 的大小,因此不能把所有東西都塞進去或存放巨大的清單。

重要:widgetState 不是放祕密的地方。不要把 token 或個人資料(PII)放進去,因為模型看得到,而且平台本身也不是把它定位成安全儲存。

4. 本地 React 狀態:何時依然需要它

儘管有 toolOutputwidgetState 的加持,你在小工具內仍然會寫普通的 React: useStateuseReduceruseRef 等等。 差別只是:

  • 本地 state 的生命期只跟當前的渲染/iframe 一樣長;
  • 模型完全看不到它;
  • 當小工具被卸載(使用者切到其他聊天、重繪或重新整理)時,本地 state 會消失。

本地 state 很適合:

  • 即時互動——hover、選中的分頁、展開的下拉;
  • 在按下「繼續」/「儲存」前的表單輸入;
  • 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。它會從 global 取出 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. 與 toolOutput 的 UI 同步:載入、空資料、錯誤

在真實世界中,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 的小工具中,無法依賴傳統的客戶端識別機制。Cookies 幾乎不可用:小工具以第三方資源在 ChatGPT 的沙盒中載入,而現代瀏覽器預設會封鎖第三方 cookies。因此任何試圖用 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:把商務(authoritative)資料存進 widgetState 而不是伺服器。
有時會想把整個實體清單保存到 widgetState,避免再次呼叫伺服器。這在兩方面都不好:你在重複 authoritative 資料(伺服器與小工具可能分岔),也在膨脹模型脈絡,因為 widgetState 會完整進入其中。更好的做法是把真正的資料留在伺服器,並回傳最新的 toolOutput 當作快照。

錯誤 №2:把祕密或個資(PII)塞進 widgetState
由於 widgetState 的內容會被模型看到,且它並非設計為安全儲存,因此不要把 token、登入資訊、電子郵件、電話號碼等機敏資訊放進去。這些東西應該保存在伺服器端;在 widgetState 中最多只保存你後續要透過 MCP 操作的那筆記錄 ID。

錯誤 №3:認為 toolOutput 一定存在且一定正確。
不做檢查就直接訪問 toolOutput.gifts[0] 的小工具,遲早會出問題:工具可能回傳錯誤、空陣列,或結構變動。建議明確處理「載入」「空」「錯誤」狀態,然後再進入正常渲染。

錯誤 №4:不必要地把 toolOutput 複製到本地 state。
很容易受誘做出 const [data, setData] = useState(toolOutput) 然後從此只用這個 data。結果就是重複的真相來源:當新的 toolOutput 到來時,本地 state 不會自動更新,UI 會繼續顯示舊資料。更好的做法是直接透過 useWidgetProps 讀取 toolOutput,或在渲染時計算衍生狀態(mapping、過濾),而不是複製整個物件。

錯誤 №5:在該用 widgetState 的地方只用本地 useState
經典的 bug:你做了一個小精靈,把 currentStep 放在本地 state,測起來一切正常。之後使用者翻了聊天紀錄再回來——卻又回到第一步。原因很簡單:本地 state 沒能撐過小工具的卸載。對情境中重要的步驟,應該使用 widgetState,平台就會和訊息一起還原它們。

錯誤 №6:在每個元件中都直接存取 window.openai
形式上它是可行的,但你會得到強耦合於 global 的程式、難以除錯的代碼,還有手寫的事件訂閱。官方教材與範例建議使用 hooks 層 (useWidgetPropsuseWidgetStateuseOpenAiGlobal), 它們封裝了細節,也更容易測試。

錯誤 №7:忽略以訊息為範圍(message‑scoped)的 widget 本質。
如果使用者沒有點 follow‑up,而是直接在聊天中輸入新的訊息,ChatGPT 會建立全新的小工具實例,帶著新的 widgetId,以及空的 widgetState。那些依賴「永恆記憶」的單一小工具情境就會表現得怪異。此時要嘛把跨工作階段的脈絡存放在伺服器上,要嘛把 UX 設計圍繞在 follow‑up 與明確的情境延續上。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION