JavaRush /Курсы /ChatGPT Apps /Обработка результатов инструмента в виджете: ToolOutput →...

Обработка результатов инструмента в виджете: 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.

2. Анатомия ToolOutput: что там внутри

Формат результата инструмента в Apps SDK делится на три логических блока: structuredContent, content и _meta (которое попадает в виджет под именем toolResponseMetadata).

Условно его можно представить так:

{
  "structuredContent": { /* данные для UI + модели */ },
  "content": "Краткая текстовая сводка для модели и пользователя",
  "_meta": { /* служебные данные только для виджета */ }
}

В таблице видно, кто что видит:

Поле Кто видит Для чего используется
structuredContent
Модель + виджет Основные структурированные данные (списки, объекты, параметры)
content
Модель + пользователь (в тексте) Краткое резюме, которое GPT может вставить в свой ответ
_meta
Только виджет Служебные данные, которые не нужны модели (ID, версии, ключи и т.п.)

Документация Apps SDK подчёркивает, что пара structuredContent / content попадает в модель и может использоваться в её дальнейших ответах. Поле _meta при этом остаётся скрытым и доступно только внутри виджета через toolResponseMetadata.

Пример ToolOutput для GiftGenius

Предположим, наш инструмент 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 и хуки

Теперь пора перейти от JSON‑разговоров к коду. Как этот ToolOutput вообще попадает в ваш React‑компонент?

Шаблон Apps SDK делает это двумя основными способами: либо напрямую через window.openai.toolOutput, либо, что приятно, через готовые React‑хуки (useWidgetProps, useToolOutput и подобные). Рекомендованный подход — использовать хуки, чтобы не трогать 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, возвращающий 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>
  );
}

В реальном шаблоне имя хука может отличаться, но идея всегда одна: SDK забирает данные из window.openai и отдаёт их вашему компоненту как пропсы или через контекст. Это гораздо проще, чем каждый раз руками лезть в глобальный объект и, вдобавок, позволяет в тестах легко подменять источник данных (например, подставлять фикстуру toolOutput).

4. Рендерим подарки: от structuredContent к JSX

Переходим к вкусному: возьмём structuredContent.items и нарисуем из них карточки. Не забываем, что наш виджет — обычный React‑клиентский компонент в Next.js ('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 мы читаем toolOutput из контейнера ChatGPT.

И да, ничего страшного, если первое время вы будете добавлять as GiftItem[]. Позже можно будет аккуратно типизировать structuredContent через общие типы с backend (например, использовать Zod / JSON Schema → TS‑типы), но для демонстрации этого достаточно.

5. Состояния UI вокруг ToolOutput: загрузка, пусто, ошибка

Приложение, которое просто показывает карточки, когда повезло, и молчит во всех остальных случаях — не очень дружелюбно. Нужно явно обрабатывать, как минимум, четыре состояния: пока инструмент выполняется, когда данных ещё нет, когда есть результат, и когда что‑то пошло не так.

Apps SDK обычно даёт некоторую информацию о статусе вызова инструмента: через список tool invocations (useToolInvocations) или флаги, связанные с toolOutput. В этой лекции нам достаточно простой модели: если toolOutput ещё нет — значит, мы в состоянии «загрузка»; если есть, но список пустой — «пусто»; если пришла ошибка — «ошибка».

Для простоты будем считать, что сервер в случае ошибки кладёт в structuredContent поле error, а флаг ok в корне toolOutput равен 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. Используем _meta и toolResponseMetadata в UI

Мы уже научились рендерить основные данные из structuredContent и обрабатывать базовые состояния loading/empty/error. Остался ещё один важный кусочек ToolOutput, которым модель не пользуется, — поле _meta.

Вернёмся к полю _meta. Оно не видно модели, но приходит в ваш виджет как toolResponseMetadata (имя может отличаться, но суть та же).

Это отличное место для того, что не должно влиять на рассуждения GPT, но важно для UI:

  • версии каталога или конфигурации;
  • internal ID кампании / эксперимента A/B;
  • флаги, какие «кнопки» показывать пользователю;
  • любые технические штуки, которые не хочется путать с доменными данными.

Например, сервер может вернуть такое _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 это означает приятный бонус: даже если виджет пока не успел отрисовать скелетон, пользователь уже видит, что система что‑то делает. Ваша задача — дополнять этот глобальный статус локальными состояниями вида «Подбираем подарки…» и скелетоном в виджете, как мы делали выше.

8. Размер данных и производительность: не тащим весь мир в structuredContent

Отдельно стоит проговорить тему «а сколько вообще можно всего напихать в structuredContent». Интуитивно кажется заманчивым: «Ну у меня же есть весь каталог подарков — давайте отдадим его целиком, а виджет сам отфильтрует». На практике так делать не стоит.

Во‑первых, structuredContent попадает в контекст модели (LLM), и общий объём токенов ограничен. Документация и практические гайды настойчиво рекомендуют держать объём аккуратным: это не хранилище данных, а результат одного действия.

Во‑вторых, чем больше payload, тем медленнее приходит ответ и тем выше шанс уткнуться в лимиты или получить неожиданные обрезания/ошибки.

Здравый подход такой:

  • Backend заранее фильтрует и сортирует данные, возвращая ровно то, что нужно для текущего шага: например, 10–20 лучших подарков.
  • Если нужны следующие страницы, это отдельное действие (новый tool call, новый ToolOutput).
  • Для чисто UI‑вещей (например, список всех возможных тегов для фильтрации) можно использовать _meta, но тоже без фанатизма.

В модуле про состояние мы уже обсуждали концепцию «backend — источник истины, а виджет — кэш/представление». Здесь то же самое: результат инструмента — это аккуратный «срез» состояния на момент вызова, а не полная копия вашей базы.

9. Связка с состоянием виджета и дальнейшим диалогом

Хоть эта лекция официально про ToolOutput → UI, нельзя не вспомнить, что по соседству живёт ещё один важный кусок — widgetState. Именно он позволяет запомнить выбор пользователя между рендерами и сделать из вашего виджета не просто витрину, а полноценный мастер или «конфигуратор подарка».

Типичный сценарий выглядит так:

  1. Первый ToolOutput приносит список подарков.
  2. Пользователь кликает на одну из карточек.
  3. Виджет записывает в widgetState, какой подарок выбран, и, возможно, отправляет follow‑up или новый tool call для деталей.
  4. Следующие ToolOutput‑ы опираются на этот выбор.

С точки зрения кода это выглядит как обычный React‑стейт плюс вызов setWidgetState, который сохраняет выбор на стороне ChatGPT. Разница лишь в том, что это состояние доступно и модели, и вашему backend, поэтому его нужно держать компактным и не хранить там секреты.

Подробно мы разберем это в модулях про многошаговые workflow и follow‑ups. Уже сейчас полезно мыслить так: ToolOutput даёт вам «срез данных» от сервера, а widgetState — контекст выбора пользователя вокруг этого среза.

Типичные ошибки при работе с ToolOutput → UI

Ошибка №1: «UI рендерит сырое JSON‑дерево без адаптации под пользователя».
Иногда хочется для отладки просто сделать <pre>{JSON.stringify(toolOutput)}</pre> и на этом остановиться. Для разработки это ок, но в продакшене пользователь видит структуру, которой вы гордитесь, но не понимает. Важно как можно раньше оборачивать structuredContent в осмысленные компоненты (списки, карточки, таблицы), а не заставлять человека читать токенизированный ответ сервера.

Ошибка №2: Смешивание доменных данных и технических метаданных в structuredContent.
Код становится гораздо чище, если разделять: «то, что должно быть видно модели и пользователю» и «то, что нужно только UI и аналитике». Техническим полям — экспериментальные флаги, версии каталогов, idempotency key — место в _meta / toolResponseMetadata. Когда всё это лежит вперемешку в structuredContent, сложнее эволюционировать контракт и тестировать модельное поведение.

Ошибка №3: Отсутствие явных состояний загрузки, пустого результата и ошибок.
Пустой <div></div> вместо «Ничего не найдено» или «Что‑то пошло не так» — прямой путь к тому, что пользователь решит: «App не работает». Даже минимальные текстовые заглушки и простой skeleton драматически улучшают UX. Не полагайтесь только на системный статус 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 работает, но когда пол‑приложения начинает лазить в глобальную переменную, отладка и тестирование становятся адом. Гораздо лучше один раз завернуть это в хук/контекст (useWidgetProps/useToolOutput) и дальше использовать уже нормальные пропсы и типизированные объекты. Это и чище, и проще подменяется фикстурами в Storybook/тестах.

1
Задача
ChatGPT Apps, 4 уровень, 3 лекция
Недоступна
Инспектор ToolOutput (минимальный debug-экран)
Инспектор ToolOutput (минимальный debug-экран)
1
Задача
ChatGPT Apps, 4 уровень, 3 лекция
Недоступна
Список карточек из structuredContent + 3 состояния (loading/empty/error)
Список карточек из structuredContent + 3 состояния (loading/empty/error)
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ