CodeGym /課程 /ChatGPT Apps /在小工具中處理工具結果:ToolOutput → UI

在小工具中處理工具結果:ToolOutput → UI

ChatGPT Apps
等級 4, 課堂 3
開放

1. 從 ToolOutput 到 React‑元件:資料流總覽

在上一講,我們拆解了伺服器端 tool 如何組出 ToolOutput —— 對模型與小工具的結構化回應。現在看這條路徑的後半段:這個 ToolOutput 如何進入小工具並變成 UI。

為了別把過程當作魔法,我們再走一遍從使用者到你的小工具的資料路徑。簡化後是這樣:

  1. 使用者在聊天中提出問題。
  2. GPT 分析請求、查看工具清單並決定:「現在我會使用 suggest_gifts」。
  3. GPT 建立帶有名稱與參數的 tool call(ToolInput),並把它送到你的伺服器(MCP 或 backend)。
  4. 伺服器執行工具邏輯,並以 ToolOutput 的形式回傳結果——包含資料的結構化 JSON,加上提供給模型的文字摘要。
  5. ChatGPT 取得 ToolOutput 後繼續分發:給模型(延續對話),也透過 Apps SDK 傳給你的小工具(window.openai.toolOutput 或各種 Hook)。
  6. 你的小工具——一個普通的 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": { /* 僅供小工具使用的服務性資料 */ }
}

下面這張表說明誰能看到什麼:

欄位 誰可見 用途
structuredContent
模型 + 小工具 主要的結構化資料(清單、物件、參數)
content
模型 + 使用者(文字中) GPT 可插入到自身回覆中的簡短摘要
_meta
僅小工具 模型不需要的服務性資料(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。它讓你的元件能在重繪之間記住使用者的選擇,使小工具從展示櫥窗進化為完整的精靈或「禮物配置器」。

典型流程如下:

  1. 第一個 ToolOutput 帶來禮物清單。
  2. 使用者點選其中一張卡片。
  3. 小工具把選擇寫入 widgetState,並可能發送後續訊息或新的 tool call 以取得詳情。
  4. 後續的 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 替換。

1
任務
ChatGPT Apps, 等級 4, 課堂 3
上鎖
ToolOutput 檢視器(最小化偵錯畫面)
ToolOutput 檢視器(最小化偵錯畫面)
1
任務
ChatGPT Apps, 等級 4, 課堂 3
上鎖
來自 structuredContent 的卡片清單 + 3 種狀態(loading/empty/error)
來自 structuredContent 的卡片清單 + 3 種狀態(loading/empty/error)
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION