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 或钩子)。
  6. 你的小部件——一个普通的 React 组件——读取 toolOutput 并渲染 UI。

可以用示意图表示为:

flowchart TD
  U[用户] -->|聊天请求| GPT[GPT]
  GPT -->|callTool: suggest_gifts| B[Backend/MCP]
  B -->|"ToolOutput (JSON)"| GPT
  GPT -->|传递 toolOutput| W["小部件(React)"]
  W -->|卡片、列表| U

需要牢牢记住一点:ToolOutput 不只是“服务器的回复”。它同时是你的小部件的渲染指令,也是模型的上下文。一个好的 App,应当把这个 JSON 变成友好的界面,而不是只让开发者在 DevTools 里翻 JSON。

2. ToolOutput 的结构:里面有什么

Apps SDK 中的工具结果大致分为三个逻辑块:structuredContentcontent_meta(在小部件侧以 toolResponseMetadata 名称出现)。

可以粗略表示为:

{
  "structuredContent": { /* 用于 UI + 模型的数据 */ },
  "content": "给模型和用户的简短文本摘要",
  "_meta": { /* 仅供小部件使用的服务性数据 */ }
}

下表说明各字段可见性与用途:

字段 可见对象 用途
structuredContent
模型 + 小部件 主要的结构化数据(列表、对象、参数)
content
模型 + 用户(在文本中) GPT 可插入到其回复中的简短摘要
_meta
仅小部件 模型不需要的服务性数据(ID、版本、键等)

Apps SDK 文档强调,structuredContentcontent 会进入模型上下文,并可能被用于后续回答。而 _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 或分析使用的内部信息(例如用于生成链接的目录版本)。

真正用于 JSX 的对象就是 structuredContent;相比手工解析任意 JSON,这更稳定也更可维护。

3. 在小部件中获取 ToolOutput:window.openai 与钩子

现在从 JSON 进入代码世界。这个 ToolOutput 是如何进入你的 React 组件的?

Apps SDK 模板一般有两种方式:要么直接读取 window.openai.toolOutput,要么(更推荐)使用现成的 React 钩子(useWidgetPropsuseToolOutput 等)。推荐用钩子,这样无需手动碰 window.openai,代码更安全也更易测试。

最简单的方式:直接从 window.openai 读取

为了理解,可以先看一个“裸”版本:

'use client';

function RawToolOutputDebug() {
  const toolOutput = (window as any).openai?.toolOutput;
  return (
    <pre>{JSON.stringify(toolOutput, null, 2)}</pre>
  );
}

生产环境当然不建议这样做,但用于调试或“先用肉眼看一眼”的第一步完全没问题。

实践方案:通过 React 钩子

更方便的方式是把对 window.openai 的读取封装在一个小钩子里,并与类型良好的对象一起使用。假设我们的 SDK 提供了 useWidgetProps 钩子,返回 toolOutputtoolResponseMetadata

'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>
  );
}

在真实模板中钩子名称可能不同,但思路相同: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 代码。唯一的“魔法”在于数据来源:不是从 propsfetch,而是从 ChatGPT 的容器中读取 toolOutput

而且,前期写 as GiftItem[] 也没什么问题。后续可以把 structuredContent 通过与后端共享的类型做好严格类型化(比如用 Zod / 从 JSON Schema 生成 TS 类型)。用于演示的话,上面的代码已经足够。

5. 围绕 ToolOutput 的 UI 状态:加载、为空、错误

一个只在“运气好时”显示卡片、其余时候沉默的应用并不友好。至少要明确处理四类状态:工具执行中、尚无数据、有结果、以及出现错误。

Apps SDK 通常会提供一些与工具调用状态有关的信息:例如通过 tool invocations 列表(useToolInvocations)或与 toolOutput 相关的标记。本节我们采用一个简单模型:如果还没有 toolOutput —— “加载中”;如果有但列表为空 —— “空”;如果来了错误 —— “错误”。

为简单起见,假设服务端在出错时会在 structuredContent 中放入 error 字段,同时 toolOutput 根上的 okfalse。这个约定我们在上一节服务端实现中设计过。

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 的形式传到你的小部件(名称可能不同,但含义一致)。

这是放置不应影响 GPT 推理但对 UI 重要内容的绝佳位置:

  • 目录或配置的版本;
  • 活动/实验的 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 可以按需使用这些信息。

文档强调这种分层:对对话与推理重要的数据放在 structuredContentcontent;纯 UI 技术项放在 _meta/toolResponseMetadata

7. 关于 ToolInvocation 状态与“正在执行 X…”

工具运行期间,ChatGPT 会自行向用户显示状态:聊天顶部会出现如“正在执行 GiftGenius…”或“正在调用外部应用”之类的提示。这不是你手工输出的,而是 ChatGPT 宿主根据工具调用的元数据自动展示的。

在底层,这是通过类似 _meta["openai/toolInvocation/invoking"]_meta["openai/toolInvocation/invoked"] 这样的服务键来标记“执行中/已完成”。平台会用这些字段来展示状态,通常无需你处理:SDK 会在服务器侧代为设置。

对 UX 而言这是个加分项:即使小部件的骨架屏还没渲染出来,用户也能看到系统在工作。你的任务是用本地状态(例如“正在挑选礼物…”与骨架屏)补充这一全局状态,就像我们上面做的那样。

8. 数据体量与性能:不要把整个世界塞进 structuredContent

再谈一个常见问题:“到底能在 structuredContent 里塞多少东西”。直觉上很诱人:“我有完整礼物目录——全给小部件,让它自己筛”。实践中不建议这样做。

首先,structuredContent 会进入模型的上下文(LLM),总 token 受限。文档与实战指南一再建议控制体量:它不是数据仓库,而是一条操作的结果。

其次,payload 越大,响应越慢,越容易遇到配额限制或被意外截断/报错。

更稳妥的做法:

  • 后端提前过滤与排序,返回当前步骤所需的最小集合:例如 10–20 个最佳礼物。
  • 如果需要下一页,这是一个独立动作(新的 tool call,新的 ToolOutput)。
  • 纯 UI 的东西(例如用于筛选的所有可选标签)可以放在 _meta,但也要克制。

我们在“状态”模块中讨论过“后端是事实来源,小部件是缓存/视图”的理念。这里同样适用:工具结果是调用时刻的一个“切片”,而不是你的数据库的完整镜像。

9. 与小部件状态和后续对话的衔接

虽然本节主题是 ToolOutput → UI,但不得不提旁边的重要拼图——widgetState。它让你的组件能在多次渲染间记住用户选择,使小部件从“橱窗”进化为真正的向导或“礼物配置器”。

典型流程如下:

  1. 首个 ToolOutput 带来礼物列表。
  2. 用户点击某个卡片。
  3. 小部件把所选礼物写入 widgetState,并可能发送跟进或新的工具调用以获取详情。
  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 不工作”的直通车。哪怕是最基本的占位文本和简单的骨架屏,都能显著改善体验。不要只依赖 ChatGPT 的系统状态“正在执行 X…”——小部件也应说明自身状态。

错误 #4:试图在一个 ToolOutput 里塞下整个世界。
把完整商品目录、用户历史、再加一份服务器日志都放进 structuredContent 是糟糕的主意。这会消耗模型上下文、拖慢响应、并让 UI 复杂化。更好的是只返回当前步骤需要的数据体量(列表页、选中项详情等),后续步骤以新的工具调用进行。

错误 #5:UI 严重依赖不稳定的响应结构且无类型保障。
若代码处处写 toolOutput.structuredContent.items[0].whatever 而不校验字段存在性、也没有类型,后端任何架构演进都会让小部件崩溃。要么与 JSON Schema 同步并生成 TS 类型,要么至少手写接口(GiftItemToolOutput),对可选字段谨慎处理。

错误 #6:忽略 _meta,导致模型被“无用字段”拖累。
“反正是 JSON,多几个字段无所谓”的想法很常见。但每个字段都会增加模型上下文,而很多信息对模型毫无必要。若信息不应影响 GPT 推理、也不需要出现在文本回复中,请把它放在 _meta,仅在小部件中使用。

错误 #7:在十几个组件里直接访问 window.openai。
是的,window.openai.toolOutput 能用。但当半个应用都去读全局变量时,调试与测试会变得很痛苦。把它封装在一个钩子/上下文中(useWidgetProps/useToolOutput)再向下传递类型良好的 props 要好得多,也更容易在 Storybook/测试中用 fixture 替换。

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