1. 從 ToolOutput 到 React‑元件:資料流總覽
在上一講,我們拆解了伺服器端 tool 如何組出 ToolOutput —— 對模型與小工具的結構化回應。現在看這條路徑的後半段:這個 ToolOutput 如何進入小工具並變成 UI。
為了別把過程當作魔法,我們再走一遍從使用者到你的小工具的資料路徑。簡化後是這樣:
- 使用者在聊天中提出問題。
- GPT 分析請求、查看工具清單並決定:「現在我會使用 suggest_gifts」。
- GPT 建立帶有名稱與參數的 tool call(ToolInput),並把它送到你的伺服器(MCP 或 backend)。
- 伺服器執行工具邏輯,並以 ToolOutput 的形式回傳結果——包含資料的結構化 JSON,加上提供給模型的文字摘要。
- ChatGPT 取得 ToolOutput 後繼續分發:給模型(延續對話),也透過 Apps SDK 傳給你的小工具(window.openai.toolOutput 或各種 Hook)。
- 你的小工具——一個普通的 React‑元件——讀取 toolOutput 並渲染 UI。
示意如下:
flowchart TD U[使用者] -->|聊天中的請求| GPT[GPT] GPT -->|callTool: suggest_gifts| B[Backend/MCP] B -->|"ToolOutput (JSON)"| GPT GPT -->|傳遞 toolOutput| W["Widget(React)"] W -->|卡片、清單| U
重要的是牢記這個觀念:ToolOutput 不只是「伺服器的回應」,同時也是你的小工具的繪製指令與模型的上下文。好的 App 會把這個 JSON 轉成友善的介面,而不是讓開發者只在 DevTools 用眼睛閱讀。
2. ToolOutput 解剖:裡面有什麼
Apps SDK 中的工具結果分成三個邏輯區塊:structuredContent、content 與 _meta(在小工具中以 toolResponseMetadata 名稱提供)。
可以把它想成這樣:
{
"structuredContent": { /* 提供給 UI 與模型的資料 */ },
"content": "簡短的文字摘要,提供給模型與使用者",
"_meta": { /* 僅供小工具使用的服務性資料 */ }
}
下面這張表說明誰能看到什麼:
| 欄位 | 誰可見 | 用途 |
|---|---|---|
|
模型 + 小工具 | 主要的結構化資料(清單、物件、參數) |
|
模型 + 使用者(文字中) | GPT 可插入到自身回覆中的簡短摘要 |
|
僅小工具 | 模型不需要的服務性資料(ID、版本、金鑰等) |
Apps SDK 的文件強調,structuredContent 與 content 會進入模型,可被用於後續回答;而 _meta 會被隱藏,只能在小工具內以 toolResponseMetadata 讀取。
GiftGenius 的 ToolOutput 範例
假設我們的工具 suggest_gifts 在伺服器端回傳大致如下的內容:
{
"structuredContent": {
"items": [
{
"id": "boardgame-cozy-strategy",
"title": "Cozy Strategy Board Game",
"price": 39.99,
"currency": "USD",
"score": 0.92,
"tags": ["board_game","strategy","2-4_players"]
}
]
},
"content": "我找到了幾個禮物點子。下面的小工具會以卡片方式顯示。",
"_meta": {
"giftGenius": {
"catalogVersion": "2025-10-01",
"experimentBucket": "A"
}
}
}
這裡的 structuredContent.items 是你的 React‑小工具要渲染的內容;content 可供模型向使用者說明目前發生的事;_meta.giftGenius 則是只供 UI 或分析使用的內部資訊(例如連結要用哪個目錄版本)。
重點是:structuredContent 是你在 JSX 中會直接使用的物件,而不是手動去解析伺服器傳來的任意 JSON。
3. 在小工具中取得 ToolOutput:window.openai 與 Hook
現在從 JSON 轉到程式碼。這個 ToolOutput 到底怎麼進入你的 React‑元件?
Apps SDK 範本一般有兩種做法:直接讀取 window.openai.toolOutput,或使用現成的 React Hook(useWidgetProps、useToolOutput 等)。建議使用 Hook,如此就不用直接碰 window.openai,程式更安全、也更容易測試。
最簡做法:直接從 window.openai 讀
為了理解,可以先看一個「赤裸裸」的版本:
'use client';
function RawToolOutputDebug() {
const toolOutput = (window as any).openai?.toolOutput;
return (
<pre>{JSON.stringify(toolOutput, null, 2)}</pre>
);
}
當然,不建議在生產環境這麼做,但在除錯或「先看一眼」時還算實用。
實用做法:透過 React Hook
更好的方式是把對 window.openai 的存取包成一個小 Hook,並以具型別的物件來使用。假設我們的 SDK 提供 useWidgetProps,能回傳 toolOutput 與 toolResponseMetadata。
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftWidgetRoot() {
const { toolOutput, toolResponseMetadata } = useWidgetProps();
// 先簡單輸出禮物數量
const items = toolOutput?.structuredContent?.items ?? [];
return (
<div>
找到的禮物數量:{items.length}
</div>
);
}
在實際範本中 Hook 名稱可能不同,但核心一致:SDK 從 window.openai 取回資料,並以 props 或 context 的方式交給你的元件。這比每次去讀全域物件簡單得多,也方便在測試時以固定資料(例如替換成 toolOutput 的 fixture)注入。
4. 渲染禮物:從 structuredContent 到 JSX
進入重點:取出 structuredContent.items,畫成卡片。別忘了我們的小工具是 Next.js 的一般 React 用戶端元件(檔案頂端有 'use client')。
先定義單一禮物的型別:
type GiftItem = {
id: string;
title: string;
price: number;
currency: string;
tags?: string[];
};
接著寫一個小型卡片元件:
function GiftCard({ gift }: { gift: GiftItem }) {
return (
<div className="gift-card">
<div className="gift-title">{gift.title}</div>
<div className="gift-price">
{gift.price} {gift.currency}
</div>
</div>
);
}
再來是清單元件,從 toolOutput 取資料:
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftList() {
const { toolOutput } = useWidgetProps();
const items = (toolOutput?.structuredContent?.items ?? []) as GiftItem[];
return (
<div className="gift-list">
{items.map(gift => (
<GiftCard key={gift.id} gift={gift} />
))}
</div>
);
}
注意整體看起來就像一般 React 程式碼。唯一的「魔法」在於資料來源:我們不是透過 props 或 fetch,而是從 ChatGPT 的容器中讀取 toolOutput。
而且,前期加上一兩個 as GiftItem[] 也沒什麼大不了。之後可以把 structuredContent 與後端共用型別(例如用 Zod / 由 JSON Schema 產生 TS 型別)做更嚴謹的型別化;示範先到這裡就足夠了。
5. 環繞 ToolOutput 的 UI 狀態:載入、空、錯誤
只在幸運時顯示卡片、其他情況一律沉默的應用體驗並不好。至少要明確處理四種狀態:工具執行中、尚無資料、有結果、以及發生錯誤。
Apps SDK 通常會提供一些狀態資訊:例如工具呼叫清單(useToolInvocations),或與 toolOutput 相關的旗標。這裡用最簡單的模型:若尚未有 toolOutput —— 就是「載入中」;若已有但清單為空 —— 就是「空狀態」;若收到錯誤 —— 就是「錯誤」。
為了簡化,假設伺服器在錯誤時會在 structuredContent 放入 error 欄位,且 toolOutput 根層的 ok 旗標為 false。我們在先前的伺服器實作單元已經討論過這個回應合約。
type ToolOutput = {
ok: boolean;
structuredContent?: {
items?: GiftItem[];
error?: { code: string; message: string };
};
};
現在更新清單元件:
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftListWithStates() {
const { toolOutput } = useWidgetProps() as { toolOutput?: ToolOutput };
if (!toolOutput) {
return <div>正在挑選禮物…</div>;
}
if (!toolOutput.ok) {
const msg = toolOutput.structuredContent?.error?.message
?? '無法取得推薦。';
return <div>錯誤:{msg}</div>;
}
const items = toolOutput.structuredContent?.items ?? [];
if (items.length === 0) {
return <div>找不到符合條件的禮物。請嘗試調整參數。</div>;
}
return (
<div className="gift-list">
{items.map(gift => (
<GiftCard key={gift.id} gift={gift} />
))}
</div>
);
}
這樣的程式碼已能提供良好的體驗:
- 工具執行時,使用者看得到進度。
- 若發生錯誤,不是空白畫面,而是清楚的訊息。
- 若沒有結果,也會明確告知,而不是假裝一切正常。
在生產環境,你很可能會把「正在挑選禮物…」換成 skeleton 或 spinner。對於較複雜的錯誤,也可以讓 GPT 產生更人性化的說明。但元件的基本結構不會改變。
6. 在 UI 中使用 _meta 與 toolResponseMetadata
我們已經學會如何渲染 structuredContent 的主要資料,並處理 loading/empty/error 等基本狀態。還有一塊模型不使用、但很重要的 ToolOutput 區域 —— _meta。
回到 _meta 欄位。它對模型不可見,但會以 toolResponseMetadata 的形式提供給你的小工具(名稱可能不同,但概念相同)。
這是放置與 UI 有關、但不該影響 GPT 推理的絕佳位置:
- 目錄或設定的版本;
- 實驗/A/B 測試的 internal ID;
- 是否顯示哪些「按鈕」的旗標;
- 其他不想與領域資料混在一起的技術性資訊。
例如,伺服器可以回傳這樣的 _meta:
"_meta": {
"giftGenius": {
"catalogVersion": "2025-10-01",
"showExperimentalBadges": true
}
}
小工具可以讀取這些資訊,並在某些卡片上顯示「新點子」徽章。
type GiftMeta = {
giftGenius?: {
catalogVersion: string;
showExperimentalBadges?: boolean;
};
};
export function GiftListWithMeta() {
const { toolOutput, toolResponseMetadata } = useWidgetProps() as {
toolOutput?: ToolOutput;
toolResponseMetadata?: GiftMeta;
};
const meta = toolResponseMetadata?.giftGenius;
const items = toolOutput?.structuredContent?.items ?? [];
return (
<div>
{meta && (
<div className="catalog-version">
目錄版本:{meta.catalogVersion}
</div>
)}
<div className="gift-list">
{items.map(gift => (
<GiftCard
key={gift.id}
gift={gift}
/>
))}
</div>
</div>
);
}
模型完全不會參與這部分:它不知道 catalogVersion 或 showExperimentalBadges,但你的 UI 可以隨意使用。
文件強調了這種分工:影響對話與模型推理的資料,放到 structuredContent 與 content;純粹 UI 技術性的內容,放在 _meta/toolResponseMetadata。
7. 關於 ToolInvocation 狀態與「正在執行 X…」
當工具在執行時,ChatGPT 會自動向使用者顯示目前狀態:聊天頂部會出現像「正在執行 GiftGenius…」或「正在呼叫外部應用程式」的提示。這不是你手動輸出的字串,而是 ChatGPT 主機依據工具呼叫的後設資料自動呈現。
在底層,這透過服務性鍵值 _meta["openai/toolInvocation/invoking"] 與 _meta["openai/toolInvocation/invoked"] 來描述,用以標誌動作進行中或已完成。這些欄位供平台本身顯示狀態,通常無需你操心:由 SDK 在伺服器端處理。
對 UX 而言,這是個加分點:即便小工具尚未渲染 skeleton,使用者也已知道系統在處理。你的任務是用本地狀態提示(例如「正在挑選禮物…」與 skeleton)來補充這個全域狀態,就像前面範例那樣。
8. 資料大小與效能:不要把所有東西都塞進 structuredContent
值得特別說明的是:「到底可以在 structuredContent 放多少資料?」直覺上很誘人:「既然我有完整禮物目錄,就全部丟給小工具,讓它自己過濾。」實務上不建議這麼做。
首先,structuredContent 會進入模型(LLM)的上下文,總 Token 容量有限。文件與實務指南都強烈建議把體積控制在合理範圍:這不是資料倉庫,而是一次動作的結果。
其次,payload 越大,回應越慢,越可能踩到限制或遇到被截斷/錯誤的情況。
務實做法:
- 後端先行過濾與排序,僅回傳此步驟所需:例如前 10–20 個最佳禮物。
- 若需要下一頁,另啟一個動作(新的 tool call、新的 ToolOutput)。
- 純 UI 用的內容(如所有可用的篩選標籤)可放在 _meta,但也要克制。
在狀態模組中我們談過「後端是單一事實來源,小工具是快取/視圖」。這裡相同:工具結果是呼叫當下狀態的精煉「切片」,不是你資料庫的完整拷貝。
9. 與小工具狀態與後續對話的連動
雖然本講題是 ToolOutput → UI,但旁邊還有一個很重要的部分 —— widgetState。它讓你的元件能在重繪之間記住使用者的選擇,使小工具從展示櫥窗進化為完整的精靈或「禮物配置器」。
典型流程如下:
- 第一個 ToolOutput 帶來禮物清單。
- 使用者點選其中一張卡片。
- 小工具把選擇寫入 widgetState,並可能發送後續訊息或新的 tool call 以取得詳情。
- 後續的 ToolOutput 會基於這個選擇。
在程式碼層面,它就像一般的 React 狀態加上一個 setWidgetState,將選擇保存於 ChatGPT 端。差別在於:此狀態同時對模型與後端可見,因此必須保持精簡,不要存放敏感資訊。
我們會在多步驟工作流與後續互動的模組中詳細說明。現在先記住:ToolOutput 提供伺服器端的「資料切片」,而 widgetState 則是在這個切片周圍的使用者選擇上下文。
ToolOutput → UI 常見錯誤
錯誤 №1:UI 直接渲染原始 JSON 樹,缺乏對使用者的轉譯。
有時為了除錯,會簡單做個 <pre>{JSON.stringify(toolOutput)}</pre> 就結束。開發時可以,但上線後使用者只會看到你自豪的結構、卻看不懂。應儘早把 structuredContent 包裝成有意義的組件(清單、卡片、表格),而不是讓人閱讀伺服器的 token 化回應。
錯誤 №2:在 structuredContent 中混放領域資料與技術後設資料。
當你區分「模型與使用者應該看到的內容」與「只給 UI 與分析用的內容」時,程式碼會乾淨許多。像實驗旗標、目錄版本、idempotency key 等技術欄位,應放在 _meta/toolResponseMetadata。若全部混在 structuredContent,契約演進與模型行為測試都會變得困難。
錯誤 №3:缺少明確的載入、空結果與錯誤狀態。
用空的 <div></div> 取代「找不到任何內容」或「發生問題」會讓使用者以為「App 壞了」。即便是最小的文字佔位與簡單 skeleton,也能顯著提升體驗。別只依賴 ChatGPT 的系統狀態提示「正在執行 X…」—— 小工具也應該回報自己的狀態。
錯誤 №4:試圖把整個世界塞進一個 ToolOutput。
把整個商品目錄、使用者歷史、甚至伺服器日誌都放進 structuredContent 是個壞主意。這會擊中模型限制、放慢回應,也讓 UI 更複雜。只回傳當前步驟所需(清單頁、已選項目詳情等),後續步驟再用新的工具呼叫處理。
錯誤 №5:在缺乏型別的情況下,讓 UI 緊耦合於不穩定的回應結構。
若在各處直接寫 toolOutput.structuredContent.items[0].whatever,既不檢查欄位是否存在、也沒有型別,伺服器端任何模式演進都可能導致小工具崩潰。同步 JSON Schema 產生 TS 型別,或至少手寫介面(GiftItem、ToolOutput),並謹慎處理 optional 欄位。
錯誤 №6:忽略 _meta,導致模型被不必要的欄位拖累。
常常會想把所有東西都塞到 structuredContent,因為「反正是 JSON,多幾個欄位也無妨」。但每個欄位都會增加模型的上下文,而且許多內容模型根本不需要。若資訊不該影響 GPT 推理、也不需要出現在文字回覆中,請放進 _meta,只在小工具端使用。
錯誤 №7:在十來個元件裡直接讀 window.openai。
是的,window.openai.toolOutput 可以運作,但當半個應用都在碰全域變數時,除錯與測試會非常痛苦。把它包成 Hook/context(useWidgetProps/useToolOutput)再到處使用具型別的物件與正常的 props 會好得多,也更容易在 Storybook/測試中以 fixture 替換。
GO TO FULL VERSION