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.openai 以 toolResponseMetadata 取得。文件特別指出,_meta 的內容僅小工具可見,模型不會拿到它。
常見用途:
- 你系統中的內部 ID;
- UI 旗標(例如是否命中快取);
- 除錯所需的服務訊息。
簡單說:toolOutput 是「要對使用者與模型說的內容」,而 _meta 是「只給小工具與日誌看的內容」。
widgetState
這是 ChatGPT 用來在重繪之間保存特定小工具 UI 狀態快照的 JSON 物件。
它的特性:
- 由 ChatGPT 端保存,並綁定到特定的 message/widgetId;
- 當再次開啟同一則訊息時會被還原;
- 小工具與模型都能看到它(widgetState 會被送入 LLM 的脈絡);
- 大約受限於 4k tokens 的大小,因此不能把所有東西都塞進去或存放巨大的清單。
重要:widgetState 不是放祕密的地方。不要把 token 或個人資料(PII)放進去,因為模型看得到,而且平台本身也不是把它定位成安全儲存。
4. 本地 React 狀態:何時依然需要它
儘管有 toolOutput 與 widgetState 的加持,你在小工具內仍然會寫普通的 React: useState、useReducer、useRef 等等。 差別只是:
- 本地 state 的生命期只跟當前的渲染/iframe 一樣長;
- 模型完全看不到它;
- 當小工具被卸載(使用者切到其他聊天、重繪或重新整理)時,本地 state 會消失。
本地 state 很適合:
- 即時互動——hover、選中的分頁、展開的下拉;
- 在按下「繼續」/「儲存」前的表單輸入;
- 像 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。它會從 global 取出 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. 與 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 層 (useWidgetProps、useWidgetState、useOpenAiGlobal), 它們封裝了細節,也更容易測試。
錯誤 №7:忽略以訊息為範圍(message‑scoped)的 widget 本質。
如果使用者沒有點 follow‑up,而是直接在聊天中輸入新的訊息,ChatGPT 會建立全新的小工具實例,帶著新的 widgetId,以及空的 widgetState。那些依賴「永恆記憶」的單一小工具情境就會表現得怪異。此時要嘛把跨工作階段的脈絡存放在伺服器上,要嘛把 UX 設計圍繞在 follow‑up 與明確的情境延續上。
GO TO FULL VERSION