JavaRush /Курсы /ChatGPT Apps /Управление состоянием — Widget State, ToolInput, ToolOutp...

Управление состоянием — Widget State, ToolInput, ToolOutput

ChatGPT Apps
3 уровень , 2 лекция
Открыта

1. Зачем вообще думать про состояние виджета

В обычном React‑приложении вы привыкли: есть локальный стейт, есть API‑запросы, максимум — какой‑нибудь Zustand/Redux. Всё крутится вокруг браузера пользователя.

В ChatGPT App ситуация другая. Ваш виджет — это всего лишь тонкий UI‑слой над тремя другими сущностями:

  • моделью ChatGPT, которая решает, когда вообще звать ваш App и какие аргументы ему передать;
  • MCP‑сервером/бэкендом, который хранит настоящие данные и выполняет бизнес‑логику;
  • контекстом чата, в котором всё это живёт и может переоткрываться через час, день или неделю.

Поэтому «где лежит состояние» — не академический вопрос, а очень практический. Если всё складывать только в React‑стейт, при малейшем изменении чата пользователь потеряет выбор. Если вы засунете всё в widgetState, модель начнёт читать тонны JSON’а и весело галлюцинировать по его поводу. Если, наоборот, пытаться хранить всё на сервере и перезапрашивать каждый пиксель — будет медленно и дорого.

Официальные рекомендации прямо делят состояние ChatGPT App на три класса: бизнес‑данные, эфемерный UI‑стейт и долговременное кросс‑сессионное состояние. С этим и начнём.

2. Карта состояний в ChatGPT App

Документация по Apps SDK описывает три типа стейта. Удобно держать их в голове в виде одной таблицы:

Тип состояния Где живёт Жизненный цикл Примеры
Business data (authoritative) MCP‑сервер / ваш backend Долго: дни, недели, годы задачи, заказы, товары
UI state (ephemeral) Внутри конкретного виджета Пока живёт экземпляр виджета выбранная карточка, сортировка, раскрытый спойлер
Cross‑session state (durable) Ваш backend / хранилище Между сессиями и чатами сохранённые фильтры, workspace, pinned board

Важно: authoritative данные должны оставаться на сервере, а не в виджете. Виджет получает снимок этих данных через инструменты (MCP tools) и рендерит его, накладывая на него свой локальный UI‑стейт.

У нас в этой лекции фокус на том, что видит именно виджет:

  • toolInput — входные аргументы вызванного инструмента;
  • toolOutputstructuredContent от сервера (основные данные);
  • toolResponseMetadata — служебные метаданные _meta, видимые только виджету;
  • widgetState — сохранённое UI‑состояние, которое ChatGPT хранит вместе с сообщением.

3. Что именно попадает в виджет: ToolInput, ToolOutput, Metadata, WidgetState

Эти три типа стейта в ChatGPT App как раз отражаются в конкретных полях, которые платформа кладёт в window.openai и прокидывает в хуки SDK. На практике вы будете получать их через React‑хуки, но полезно знать точные определения.

toolInput

Это объект с аргументами инструмента (tool), которые модель передала при его вызове.

Например, пользователь пишет:
«Подбери идеи подарков для женщины 30 лет, бюджет 100 долларов».
Модель решает вызвать ваш инструмент gift_search с аргументами:

{
  "recipient": "female",
  "age": 30,
  "budget": 100,
  "occasion": "birthday"
}

Именно этот объект вы увидите в toolInput внутри виджета. Там хранятся исходные настройки сценария — то, ради чего вообще запускали ваш App.

toolOutput

Это structuredContent, который вернул ваш MCP‑сервер / backend при выполнении инструмента.

Обычно это 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

Это JSON‑объект, в котором ChatGPT хранит снимок UI‑состояния конкретного виджета между рендерами.

Его свойства:

  • живёт на стороне ChatGPT и привязан к конкретному message/widgetId;
  • восстанавливается при повторном открытии этого же сообщения;
  • виден и виджету, и модели (данные из widgetState входят в контекст LLM);
  • ограничен по размеру приблизительно 4k токенов, так что туда нельзя сваливать всё подряд или хранить огромные списки.

Важно: widgetState — не место для секретов. Ни токены, ни PII‑данные туда класть нельзя, потому что модель их увидит, а сама платформа не позиционирует это как секьюрное хранилище.

4. Локальный React‑стейт: где он по‑прежнему нужен

Несмотря на всё волшебство вокруг toolOutput и widgetState, внутри виджета вы всё ещё пишете обычный React с useState, useReducer, useRef и т.п. Разница только в том, что:

  • локальный стейт живёт столько, сколько живёт конкретный рендер/iframe;
  • модель его не видит вообще;
  • при размонтировании виджета (пользователь ушёл в другой чат, перерисовка, обновление) локальный стейт исчезает.

Локальный стейт отлично подходит для:

  • мгновенных вещей — 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>
);

Пока мы не нажали «Подтвердить выбор», это отличный кандидат на локальный стейт. Но как только хотим, чтобы выбор «переехал» между обновлениями виджета, надо думать о widgetState.

5. widgetState: память виджета между рендерами

widgetState — это та самая «память» виджета, которую сохраняет сама платформа. При каждом важном действии UI вы можете вызывать setWidgetState, и ChatGPT сохранит этот JSON вместе с сообщением. При следующем рендере этого же виджета (например, пользователь пролистал историю чата назад, а потом вернулся) SDK восстановит этот объект и передаст вам.

Строго говоря, можно было бы напрямую дергать window.openai.widgetState и window.openai.setWidgetState, но в лекции мы придерживаемся рекомендованного пути — React‑хуков на слое SDK.

Хук useWidgetState

Один из таких хуков как раз оборачивает 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‑стейт, он ещё и вызывает window.openai.setWidgetState под капотом, если тот доступен.

Если пользователь позже нажмёт follow‑up под этим виджетом, ChatGPT может продолжить диалог с тем же widgetId и тем же widgetState, и модель увидит, какой подарок был выбран.

6. Чтение данных инструмента в React: useWidgetProps и аналоги

Чтобы каждый компонент не лазил руками в window.openai.toolOutput, в Apps SDK есть ещё один полезный слой — хук useWidgetProps. Он берёт toolOutput из глобала, даёт вам типизированный объект и при желании подмешивает дефолтные значения.

Упрощённая сигнатура выглядит так:

export function useWidgetProps<T>(defaultState?: T | () => T): T {
  const toolOutput = useOpenAIGlobal("toolOutput") as T;
  return toolOutput ?? defaultState ?? null;
}

То есть внутри вам просто возвращается toolOutput как тип T.

Предположим, наш 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 напрямую — всё через хук.

7. Синхронизация UI с toolOutput: загрузка, пустые данные, ошибки

В реальном мире 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
      ↓
Виджет → рендерит UI из toolOutput + widgetState

Официальный гайд по серверу рисует почти такую же диаграмму «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, толстый backend»

Обобщим распределение ролей:

  • authoritative данные (список подарков, статусы заказов) живут на сервере и приходят в toolOutput;
  • временные визуальные штуки (раскрыт ли спойлер, текущее содержимое незаконченного ввода) живут в локальном React‑стейте;
  • устойчивые UI‑решения внутри одного виджета (текущий шаг, выбранный элемент, сортировка) живут в widgetState;
  • долговременные настройки пользователя между чатами (любимая категория подарков, последняя валюта) живут в вашем бэкенде как persistent state.

Очень хочется иногда сделать «большой объект всего», положить его в widgetState и жить спокойно. Но это плохая идея. Документация подчёркивает, что состояние, которое вы передаёте через widgetState, целиком входит в контекст модели и должно быть лёгким и в основном про UI.

То же касается toolOutput: туда стоит класть ровно те данные, которые нужны и виджету, и модели для объяснения пользователю, что произошло. Большие деревья, бинарные блобы, сырые ответы других API — это всё прямая дорога к странным и дорогим ответам модели.

Insight

Внутри виджета ChatGPT невозможно опираться на классические механизмы клиентской идентификации. Cookies фактически недоступны: виджет загружается как сторонний ресурс в песочнице ChatGPT, а современные браузеры блокируют third-party 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: Хранить бизнес‑данные в widgetState вместо сервера.
Иногда хочется сохранить целый список сущностей в widgetState, чтобы не звать сервер повторно. Это плохо по двум причинам: вы дублируете authoritative данные (сервер и виджет могут разъехаться), и выдуваете контекст модели, потому что widgetState входит в него целиком. Лучше хранить настоящие данные на сервере и возвращать свежий toolOutput как снимок.

Ошибка №2: Пихать в widgetState секреты или PII.
Поскольку содержимое widgetState видит модель и оно не предназначено как защищённое хранилище, туда нельзя класть токены, логины, e‑mail’ы, телефоны и прочую конфиденциальную информацию. Такие вещи должны жить на сервере, а в widgetState максимум хранится ID записи, с которой вы дальше работаете через MCP.

Ошибка №3: Считать, что toolOutput всегда есть и всегда корректный.
Виджет, который без проверки лезет в toolOutput.gifts[0], рано или поздно сломается: инструмент может вернуть ошибку, пустой массив или поменять структуру. Рекомендуется явно обрабатывать состояния «загрузка», «пусто», «ошибка» и только потом — нормальный рендер.

Ошибка №4: Копировать toolOutput в локальный стейт без необходимости.
Бывает соблазн сделать const [data, setData] = useState(toolOutput) и дальше жить только с этим data. В результате вы получаете дублированный источник истины: когда придёт новый toolOutput, локальный стейт об этом не узнает, и UI продолжит показывать старые данные. Лучше читать toolOutput напрямую из useWidgetProps или производить derive‑состояние (мэппинг, фильтр) в рендере, не дублируя весь объект.

Ошибка №5: Использовать только локальный useState там, где нужен widgetState.
Классический баг: вы делаете маленького мастера, храните currentStep в локальном стейте, всё тестируете — работает. Потом пользователь пролистывает чат, возвращается — и вдруг снова первым шагом. Причина проста: локальный стейт не пережил размонтирование виджета. Для шагов, важных в сценарии, стоит завести widgetState, тогда платформа восстановит их вместе с сообщением.

Ошибка №6: Пытаться обращаться к window.openai напрямую в каждом компоненте.
Формально это работает, но вы получаете жёсткую привязку к глобалу, сложный для отладки код и руками написанные подписки на события. Официальные материалы и примеры советуют использовать слой хуков (useWidgetProps, useWidgetState, useOpenAiGlobal), которые инкапсулируют все детали и легче тестируются.

Ошибка №7: Не учитывать message‑scoped природу виджетов.
Если пользователь не нажимает follow‑up, а просто пишет новое сообщение в чат, ChatGPT создаёт новый экземпляр виджета с новым widgetId и пустым widgetState. Сценарии, которые полагаются на «вечную» память одного виджета, начинают вести себя странно. Здесь нужно либо хранить кросс‑сессионный контекст на сервере, либо строить UX вокруг follow‑up’ов и явного продолжения сценария.

1
Задача
ChatGPT Apps, 3 уровень, 2 лекция
Недоступна
Мини-инспектор состояния (toolInput/toolOutput + счётчик в widgetState)
Мини-инспектор состояния (toolInput/toolOutput + счётчик в widgetState)
1
Задача
ChatGPT Apps, 3 уровень, 2 лекция
Недоступна
Выбор элемента из toolOutput с сохранением в widgetState
Выбор элемента из toolOutput с сохранением в widgetState
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ