JavaRush /Курсы /ChatGPT Apps /UX потоков: прогресс, partial results, отмена длительных ...

UX потоков: прогресс, partial results, отмена длительных операций

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

1. Почему UX потоков важен именно в ChatGPT App

В обычном вебе пользователи уже привыкли к прогресс-бару загрузки файлов, крутящемуся спиннеру и skeleton‑экрану. Но в ChatGPT‑приложениях у вас есть дополнительный «конкурент»: сама модель, которая умеет стримить текст в реальном времени. Если виджет в этот момент рисует статичный спиннер без пояснений, он проигрывает по ощущениям — GPT «живой», а App «подвис».

UX для долгих операций решает сразу несколько задач. Во‑первых, снижает тревогу пользователя: вместо «подвисло или ещё думает?» он видит статусы, этапы, проценты и даже первые результаты. Во‑вторых, повышает доверие: когда App явно показывает, что делает (анализирует отзывы, сверяет цены, фильтрует подарки), это создаёт ту самую operational transparency — прозрачность операций. Пользователь понимает: под капотом не магия, а понятная последовательность шагов.

И наконец, UX потоков — это не только про прогресс. Это ещё и про контроль. Возможность остановить тяжёлый подбор подарков, сменить параметры и сразу запустить заново — важная часть ощущения «я управляю, а не жду милости от сервера».

В этой лекции мы:

  • спроектируем простую модель состояний долгой задачи (pending / in_progress / partial_ready / …);
  • переведём её в React‑состояние виджета;
  • разберёмся, как честно показывать прогресс и частичные результаты;
  • аккуратно реализуем отмену таких задач.

Всё это — на примере нашего GiftGenius.

2. Модель состояний длинной операции в GiftGenius

Чтобы не превратить поток событий в кашу из if (event.type === …), удобно думать о долгой задаче как о конечном автомате (state machine) на клиенте. Для GiftGenius мы будем использовать следующие логические состояния, которые вы уже встречали в теории: pending, in_progress, partial_ready, completed, failed, canceled плюс состояние ожидания idle.

Сведём их в таблицу:

Статус Что значит на бэкенде Что видит пользователь в виджете
idle
Задачи ещё нет Обычная форма, кнопка «Подобрать подарок»
pending
Job создан, ждём старта воркера Кнопка задизейблена, лёгкий спиннер
in_progress
Воркер работает, шлёт job.progress Прогресс-бар или шаги «Шаг 1 из 3»
partial_ready
Есть первые результаты, работа продолжается Уже видны первые подарки + всё ещё прогресс
completed
Пришёл job.completed Финальный список подарков, CTA («Купить»)
failed
Пришёл job.failed Сообщение об ошибке + кнопка «Попробовать снова»
canceled
Пришёл job.canceled или cancel‑флаг Текст «Подбор остановлен» + «Начать заново»

Эта же модель отлично ложится на MCP‑события. Например, job.started переводит из pending в in_progress, job.progress может либо просто обновить проценты в in_progress, либо сказать «у нас появились первые карточки» и тогда вы переходите в partial_ready. job.completed, job.failed и job.canceled закрывают историю.

Выглядит это как небольшой автомат состояний:

stateDiagram-v2
    [*] --> idle
    idle --> pending: создать job
    pending --> in_progress: job.started
    in_progress --> partial_ready: первые partial results
    partial_ready --> completed: job.completed
    in_progress --> completed: job.completed (без partial)
    in_progress --> failed: job.failed
    partial_ready --> failed: job.failed
    in_progress --> canceled: job.canceled
    partial_ready --> canceled: job.canceled
    failed --> idle: повторный запуск
    canceled --> idle: повторный запуск

В коде виджета это можно отразить простым типом:

type JobStatus =
  | 'idle'
  | 'pending'
  | 'in_progress'
  | 'partial_ready'
  | 'completed'
  | 'failed'
  | 'canceled';

interface GiftJobState {
  status: JobStatus;
  percent?: number;
  stage?: string;
  error?: string;
}

Пока что это только форма данных. Дальше мы будем наполнять её содержимым по мере прихода событий из MCP или по стриму.

3. Состояние виджета: как React‑компонент «слушает» поток

Перенесём нашу модель состояний в React‑код виджета GiftGenius. Нам нужно хранить:

  • текущий jobId, чтобы знать, какие события относятся к этой задаче;
  • состояние задачи (status, percent, stage);
  • массив частичных результатов (карточки подарков);
  • флаги для кнопок: можно ли отменять, можно ли перезапускать.

Опишем это одним интерфейсом:

interface GiftSuggestion {
  id: string;
  title: string;
  price: string;
}

interface GiftWidgetState extends GiftJobState {
  jobId?: string;
  partialGifts: GiftSuggestion[];
}

Инициализация в компоненте может выглядеть совсем просто:

const [state, setState] = useState<GiftWidgetState>({
  status: 'idle',
  partialGifts: [],
});

Дальше у нас есть два ключевых момента.

Во‑первых, запуск задачи. Это может быть вызов MCP‑инструмента через Apps SDK (callTool) или HTTP‑запрос на ваш бэкенд, который создаёт job и возвращает jobId. В этой лекции мы не углубляемся в то, как именно устроен async‑pipeline — этим займёмся в следующей теме про очереди и воркеры. Сейчас нам важна только реакция UI на уже созданный jobId.

Во‑вторых, подписка на события этого jobId. На практике это может быть хук вроде useJobEvents(jobId) или обёртка subscribeToJobEvents, которые под капотом используют либо SSE‑подключение, либо MCP‑клиент, но снаружи отдают нам уже нормальные JS‑объекты. Ниже для простоты покажем вариант с subscribeToJobEvents внутри useEffect:

useEffect(() => {
  if (!state.jobId) return;

  const unsubscribe = subscribeToJobEvents(state.jobId, handleEvent);
  return () => unsubscribe();
}, [state.jobId]);

Где handleEvent просто обновляет state в зависимости от типа события. Дальше по очереди разберём три группы событий, которые он обрабатывает: прогресс, частичные результаты и отмену задачи.

4. Визуализация прогресса: проценты, этапы и честность

Прогресс в UX бывает двух видов: определённый (determinate) и неопределённый (indeterminate). В первом случае вы действительно знаете, сколько работы сделано: например, у вас 4 шага воркфлоу, или обработано 30 из 100 файлов. Во втором случае вы честно признаётесь, что не знаете, сколько ещё ждать, и показываете анимацию «думаем» вместо фейкового «73%».

В GiftGenius логика может быть такой. Если бэкенд реально считает прогресс — например, у него есть шаги collect_sources, analyze_preferences, rank_candidates, enrich_descriptions — вы можете вернуть в событии job.progress payload с полями stepCurrent, stepTotal, statusText и (опционально) разумный percent.

Тип события в TS:

interface JobProgressPayload {
  stepCurrent: number;
  stepTotal: number;
  percent?: number;
  statusText: string;
}

interface JobEvent {
  type:
    | 'job.started'
    | 'job.progress'
    | 'job.partial_result'
    | 'job.completed'
    | 'job.failed'
    | 'job.canceled';
  jobId: string;
  payload?: any;
}

Обработчик прогресса в компоненте:

function handleJobProgress(payload: JobProgressPayload) {
  setState(prev => ({
    ...prev,
    status: prev.status === 'idle' ? 'in_progress' : prev.status,
    percent: payload.percent,
    stage: `${payload.stepCurrent} / ${payload.stepTotal}: ${payload.statusText}`,
  }));
}

В JSX можно отрисовать и прогресс‑бар, и текст этапа:

{(state.status === 'pending' || state.status === 'in_progress' || state.status === 'partial_ready') && (
  <div>
    {typeof state.percent === 'number'
      ? <progress value={state.percent} max={100} />
      : <div className="spinner" />}
    {state.stage && <p>{state.stage}</p>}
  </div>
)}

Здесь важен один психологический нюанс. Если у вас нет честного процента, лучше показать просто «Шаг 2 из 3: анализируем предпочтения» плюс неопределённый прогресс‑бар (анимация полоски), чем «застывший» 99% в течение 30 секунд. Такой гибрид (этапы + индикатор неопределённого прогресса (indeterminate‑бар)) отлично работает для AI‑операций, где точный остаток подсчитать трудно.

5. Partial results: не надо ждать, пока всё станет идеально

Самая приятная часть потокового UX — частичные результаты. Зачем держать пользователя в ожидании, если уже через 5–7 секунд у вас есть первые релевантные подарки? Можно показать их сразу, а остальные догрузить позже.

В GiftGenius это может выглядеть так. Бэкенд по мере работы шлёт либо специальные события job.partial_result, либо, например, resource.updated с новой порцией рекомендаций. Каждое такое событие приносит массив подарков, которые добавляются к уже существующим.

Условная форма payload:

interface PartialResultPayload {
  gifts: GiftSuggestion[];
  isFinalChunk?: boolean;
}

Обработчик:

function handlePartialResult(payload: PartialResultPayload) {
  setState(prev => ({
    ...prev,
    status: 'partial_ready',
    partialGifts: [...prev.partialGifts, ...payload.gifts],
  }));
}

В JSX вы просто рендерите карточки, независимо от того, завершена задача или нет:

<section>
  {state.partialGifts.map(gift => (
    <GiftCard key={gift.id} gift={gift} />
  ))}
  {(state.status === 'in_progress' || state.status === 'partial_ready') && (
    <p>Мы продолжаем искать ещё варианты…</p>
  )}
</section>

Здесь есть несколько важных UX‑нюансов, о которых стоит помнить.

Во‑первых, старайтесь избегать резких скачков макета (layout shift). Если вы добавляете новые подарки сверху списка, пользователь будет терять место чтения. Безопаснее добавлять их в конец (append‑only) и мягко анимировать появление.

Во‑вторых, если вы используете стратегию refinement (сначала быстрый черновой список, потом его «дополировали» и переранжировали), нужно аккуратно обращаться с интерактивностью. Пока результаты «черновые», не давайте нажать «Купить» или явно пометьте такой список как «предварительный». Иначе пользователь выберет подарок, а через секунду он исчезнет или сменит цену — UX‑катастрофа.

И в‑третьих, состояние partial_ready должно быть визуально отличимо от completed. Пользователь должен понимать, что список ещё дополняется: либо текстом «Подбор продолжается», либо маленьким спиннером в углу, либо нейтральной подсветкой новых карточек.

6. Отмена длительных операций: UX и техника

Если вы даёте пользователю право запустить тяжёлый подбор подарков, вы почти всегда должны дать ему и право это остановить. Отмена — это не только экономия ресурсов LLM и воркеров, но и ощущение контроля: «я сам решаю, что происходит».

С точки зрения UX кнопка отмены должна быть достаточно заметной, но не кричащей красной плашкой посреди экрана. Хорошо работает пара: основная кнопка «Отменить подбор» и небольшой вторичный текст «можно запустить заново в любой момент». Важно, чтобы пользователю было понятно, что именно отменяется — текущий анализ, а не всё приложение.

С точки зрения техники у вас есть два уровня отмены.

Во‑первых, отмена на фронтенде: вы можете прервать локальный fetch или закрыть SSE‑подключение. Это экономит трафик, но само по себе не останавливает воркер на бэкенде.

Во‑вторых, настоящая отмена job’а: через MCP‑tool или HTTP‑эндпоинт POST /jobs/{jobId}/cancel, который помечает задачу как canceled и даёт воркеру шанс корректно завершиться. При этом сервер отправляет событие job.canceled, которое вы уже обрабатываете в виджете.

Вид с точки зрения виджета:

async function handleCancelClick() {
  if (!state.jobId) return;

  // Оптимистичное обновление UI
  setState(prev => ({ ...prev, status: 'canceled' }));

  try {
    await cancelJobOnServer(state.jobId); // MCP tool или HTTP
  } catch (e) {
    // Если отмена на сервере не удалась — откатим статус
    setState(prev => ({ ...prev, status: 'in_progress' }));
  }
}

И кнопка:

<button
  onClick={handleCancelClick}
  disabled={
    state.status !== 'pending' &&
    state.status !== 'in_progress' &&
    state.status !== 'partial_ready'
  }
>
  Отменить подбор
</button>

Здесь мы используем оптимистичный UI: сразу переключаемся в canceled, не дожидаясь подтверждения от сервера. Это полезно, когда отмена может занимать секунды — пользователь мгновенно видит, что его действие принято. Но нужно быть готовым к тому, что сервер всё же вернёт job.completed или job.failed, если воркер успел добежать до конца. В обработчике событий стоит фильтровать такие «запаздывающие» финалы и, например, не перезаписывать уже canceled состояние.

Более консервативный подход — пессимистичный UI: сначала показываем состояние «Отменяем…», блокируем кнопку, и только после job.canceled переводим задачу в canceled. Он проще в реализации, но визуально менее отзывчив. Подход можно выбирать в зависимости от SLA вашего бэкенда.

7. Собираем всё вместе: мини‑панель прогресса GiftGenius

Теперь соберём отдельные кусочки вместе. Мы уже написали:

  • обработчик прогресса handleJobProgress,
  • обработчик частичных результатов handlePartialResult,
  • и обработчик отмены handleCancelClick.

По сути это и есть тот самый общий handleEvent из предыдущего раздела: он реагирует на job.progress, job.partial_result, job.canceled и другие события и обновляет состояние одного компонента. Осталось обернуть всё это в небольшой компонент GiftJobPanel, который:

  • запускает подбор подарков;
  • слушает события по jobId;
  • показывает прогресс;
  • рендерит partial results;
  • позволяет отменить задачу.

Сильно упростим детали интеграции с Apps SDK / MCP и сосредоточимся на логике состояния.

export function GiftJobPanel() {
  const [state, setState] = useState<GiftWidgetState>({
    status: 'idle',
    partialGifts: [],
  });

  useEffect(() => {
    if (!state.jobId) return;
    const unsub = subscribeToJobEvents(state.jobId, event => {
      switch (event.type) {
        case 'job.started':
          setState(prev => ({ ...prev, status: 'in_progress' }));
          break;
        case 'job.progress':
          handleJobProgress(event.payload);
          break;
        case 'job.partial_result':
          handlePartialResult(event.payload);
          break;
        case 'job.completed':
          setState(prev => ({ ...prev, status: 'completed' }));
          break;
        case 'job.failed':
          setState(prev => ({
            ...prev,
            status: 'failed',
            error: event.payload?.message ?? 'Что-то пошло не так',
          }));
          break;
        case 'job.canceled':
          setState(prev => ({ ...prev, status: 'canceled' }));
          break;
      }
    });
    return () => unsub();
  }, [state.jobId]);

Запуск задачи может быть реализован через MCP‑tool start_gift_search:

async function handleStartClick() {
  setState({
    status: 'pending',
    partialGifts: [],
  });

  const jobId = await startGiftSearchOnServer(/* параметры пользователя */);
  setState(prev => ({ ...prev, jobId }));
}

Дальше в JSX:

return (
  <div>
    {state.status === 'idle' && (
      <button onClick={handleStartClick}>Подобрать подарок</button>
    )}

    {['pending', 'in_progress', 'partial_ready'].includes(state.status) && (
      <ProgressSection state={state} onCancel={handleCancelClick} />
    )}

    <GiftsList gifts={state.partialGifts} status={state.status} />

    {state.status === 'failed' && (
      <ErrorSection error={state.error} onRetry={handleStartClick} />
    )}

    {state.status === 'canceled' && (
      <p>Подбор остановлен. Можно запустить заново с другими параметрами.</p>
    )}
  </div>
);

Отдельные подкомпоненты вроде ProgressSection, GiftsList, ErrorSection помогают не превращать основной компонент в «лапшу». Но ключевая идея одна: весь виджет управляется одной понятной моделью состояния, которая напрямую соответствует MCP‑событиям и потоковым каналам, о которых вы уже знаете.

8. Немного про связку с ChatGPT‑диалогом

Хотя эта лекция сфокусирована на самом виджете, важно помнить, что пользователь по‑прежнему находится в диалоге с моделью. Хороший сценарий выглядит так: GPT сообщает пользователю, что запускает GiftGenius, потом виджет показывает прогресс, а GPT подпирает это текстом: «Я сейчас запустил расширенный подбор подарков, вы будете видеть, как список постепенно заполняется».

После завершения подбора ChatGPT может подхватить результат из ToolOutput и сформулировать человеческое резюме: «Я нашёл 10 вариантов, вот краткий обзор, а полный список — в виджете ниже». Такой дуэт текстового стриминга и потокового UI создаёт целостный опыт.

Эта связка будет ещё важнее в модулях про workflow и commerce, где каждый долгий шаг (анализ корзины, проверка наличия, ожидание оплаты) должен быть понятен и в тексте, и в интерфейсе.

9. Типичные ошибки в UX потоков

Ошибка №1: «Вечный спиннер без текста».
Самый частый анти‑паттерн — просто крутить анимацию и никак не пояснять, что происходит. Пользователь не понимает, делает ли система что‑то полезное или зависла. Лечится простым текстом этапа («Собираем популярные подарки…», «Анализируем отзывы»), а ещё лучше — явными статусами pending, in_progress, partial_ready, которые вы уже держите в состоянии виджета.

Ошибка №2: Фейковые проценты прогресса.
Попытка «поднять доверие» рисованием придуманного прогресса («73%» из воздуха) обычно даёт обратный эффект. Пользователь быстро замечает, что 99% может висеть 20 секунд, и перестаёт верить индикатору. Если у вас нет честной метрики, лучше используйте этапы и неопределённый прогресс‑бар, вместо того чтобы обманывать.

Ошибка №3: Partial results, которые всё ломают.
Иногда partial‑результаты реализуют как полностью пересобираемый список, который то исчезает, то перетасовывается при каждом событии. В итоге пользователь кликает по карточке, а она внезапно убегает вниз. Такая тряска особенно страшна в commerce‑сценариях. Правильнее добавлять карточки аккуратно (часто — только в конец), сохранять ключи и минимизировать скачки макета.

Ошибка №4: Отмена, которая ничего не отменяет.
Бывает и так: в виджете есть кнопка «Отменить», которая лишь скрывает UI, но не останавливает реальный job на сервере. В результате ресурсы продолжают тратиться, приходят поздние job.completed, а пользователь уже думает, что всё остановлено. Настоящая отмена должна затрагивать и фронтенд (отключить кнопки, остановить стрим), и бэкенд (передать cancel‑сигнал воркеру и получить job.canceled событие).

Ошибка №5: Игнорирование финала и «тупой» экран ошибки.
Иногда после job.completed виджет просто показывает список подарков без каких‑либо следующих шагов, а при job.failed — только техническое сообщение «Ошибка 500». И в том, и в другом случае UX обрывается. Правильнее в конце дать короткое резюме и явный CTA («Сохранить подбор», «Перейти к покупке»), а при ошибке — человеческое объяснение и кнопки «Попробовать снова» или «Изменить параметры» вместо бросания пользователя один на один с кодом статуса.

1
Задача
ChatGPT Apps, 13 уровень, 2 лекция
Недоступна
JobStatusPanel — честный прогресс (determinate vs indeterminate)
JobStatusPanel — честный прогресс (determinate vs indeterminate)
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ