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。
表にまとめます:
| ステータス | バックエンド上の意味 | ウィジェットでのユーザー表示 |
|---|---|---|
|
まだジョブは存在しない | 通常のフォームと「ギフトを探す」ボタン |
|
ジョブが作成され、ワーカーの開始を待機 | ボタンは無効化、軽いスピナー表示 |
|
ワーカー稼働中、job.progress を送信 | プログレスバー、または「3 段階中 1 段階目」 |
|
最初の結果が出ており、処理は継続 | 先行のギフトが表示され、進捗表示も継続 |
|
job.completed を受信 | 最終的なギフト一覧、CTA(「購入」) |
|
job.failed を受信 | エラーメッセージ + 「再試行」ボタン |
|
job.canceled またはキャンセルフラグを受信 | 「選定を停止しました」+「やり直す」 |
このモデルは MCP のイベントにもよく対応します。例えば、job.started は pending から in_progress へ遷移させ、job.progress は in_progress のままパーセントを更新するだけの場合もあれば、「最初のカードが出た」と知らせて partial_ready に移す場合もあります。job.completed、job.failed、job.canceled がストーリーを閉じます。
状態遷移は次のような小さなステートマシンになります:
stateDiagram-v2
[*] --> idle
idle --> pending: ジョブ作成
pending --> in_progress: job.started
in_progress --> partial_ready: 最初の部分結果
partial_ready --> completed: job.completed
in_progress --> completed: job.completed(部分結果なし)
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 コンポーネントがストリームを「購読」する方法
状態モデルを GiftGenius の React コードに落とし込みます。保持すべきものは次のとおりです。
- 現在の jobId(どのイベントがこのジョブに属するかを識別するため);
- ジョブの状態(status、percent、stage);
- 部分結果の配列(ギフトカード);
- ボタン用のフラグ(キャンセル可/再実行可 など)。
これを 1 つのインターフェースで表します:
interface GiftSuggestion {
id: string;
title: string;
price: string;
}
interface GiftWidgetState extends GiftJobState {
jobId?: string;
partialGifts: GiftSuggestion[];
}
コンポーネントでの初期化はとてもシンプルです:
const [state, setState] = useState<GiftWidgetState>({
status: 'idle',
partialGifts: [],
});
ここからが 2 つの要点です。
第一に、ジョブの開始。これは Apps SDK での MCP ツール呼び出し(callTool)か、ジョブを作成して jobId を返す自前バックエンドへの HTTP リクエストかもしれません。本講義では非同期パイプラインの詳細には踏み込みません—それは次のキューとワーカーの回で扱います。ここでは、すでに作られた jobId に対する UI の反応だけに焦点を当てます。
第二に、その jobId のイベント購読。実装では useJobEvents(jobId) のようなフックや subscribeToJobEvents のラッパーを使い、内部では SSE 接続や MCP クライアントを利用しつつ、外側には扱いやすい JS オブジェクトを返すことが多いでしょう。以下では簡単のため、useEffect 内で subscribeToJobEvents を使う例を示します:
useEffect(() => {
if (!state.jobId) return;
const unsubscribe = subscribeToJobEvents(state.jobId, handleEvent);
return () => unsubscribe();
}, [state.jobId]);
ここで handleEvent は、イベント種別に応じて state を更新するだけです。このあと、その処理対象となる 3 つのイベント群(進捗、部分結果、キャンセル)を順に見ていきます。
4. 進捗の可視化: パーセント、段階、そして誠実さ
進捗には 2 種類あります。確定(determinate)と不確定(indeterminate)です。前者は本当にどれだけ処理が進んでいるかを把握できる場合です(ワークフローが 4 段階ある、100 ファイル中 30 件処理済みなど)。後者は、残り時間を正確には言えないことを認め、フェイクの「73%」ではなく「思考中」のアニメーションを見せるやり方です。
GiftGenius では次のように考えられます。バックエンドが実際に進捗を計算できる—たとえば collect_sources、analyze_preferences、rank_candidates、enrich_descriptions といった段階がある—場合、job.progress イベントで stepCurrent、stepTotal、statusText、必要なら妥当な percent を payload として返せます。
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>
)}
ここで心理的に重要な点が 1 つあります。正直なパーセントがないなら、「3 段階中 2 段階目: 嗜好を分析中」のようなテキストと、不確定のプログレスバー(インジケータが流れるアニメーション)の組み合わせを見せる方が、30 秒止まったままの 99% を見せるよりずっと良いということです。AI 系の処理では正確な残り時間の見積もりが難しいため、このハイブリッド(段階テキスト + indeterminate バー)がよく効きます。
5. 部分結果: すべてが完璧になるまで待たせない
ストリーミング 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 的には、キャンセルボタンは十分に目立つべきですが、画面のど真ん中の真っ赤な帯である必要はありません。「選定をキャンセル」のメインボタンと「いつでも再実行できます」といった小さな補助テキストの組み合わせが有効です。何がキャンセルされるのか—現在の分析だけで、アプリ全体ではない—が伝わることも重要です。
技術的には、キャンセルには 2 つのレベルがあります。
第一に、フロントエンドでのキャンセル。ローカルの fetch を中断したり、SSE 接続を閉じたりできます。これはトラフィック節約になりますが、それだけではバックエンドのワーカーは止まりません。
第二に、本当のジョブのキャンセル。MCP ツール経由、または POST /jobs/{jobId}/cancel のような HTTP エンドポイントでジョブを canceled にし、ワーカーに正しく終了する機会を与えます。同時にサーバーは job.canceled を送信し、ウィジェット側で処理します。
ウィジェット側の見え方:
async function handleCancelClick() {
if (!state.jobId) return;
// 楽観的な UI 更新
setState(prev => ({ ...prev, status: 'canceled' }));
try {
await cancelJobOnServer(state.jobId); // MCP ツール or 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 のイベントを購読する;
- 進捗を表示する;
- 部分結果をレンダリングする;
- ジョブのキャンセルを可能にする。
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 ツール 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: 破壊的な部分結果の出し方。
部分結果を、毎回リスト全体を組み直しては消えたり並び替えたりする実装にすると、ユーザーがカードをクリックした瞬間にそれがどこかへ移動してしまう、といった事態が起きます。特に commerce シナリオでは致命的です。新規カードは慎重に追加し(多くの場合は末尾のみ)、キーを安定させ、レイアウトシフトを最小限に抑えるのが正解です。
誤り №4: 何もキャンセルしない「キャンセル」。
ウィジェットに「キャンセル」ボタンはあるのに、UI を隠すだけでサーバー上の実ジョブを止めないケースがあります。その結果、リソースは消費され続け、遅れて job.completed が届き、ユーザーはすでに止まったと思い込んでいます。本当のキャンセルはフロントエンド(ボタンを無効化、ストリームを停止)とバックエンド(ワーカーにキャンセル信号を送り、job.canceled を受信)を両方含むべきです。
誤り №5: フィナーレの軽視と「無味乾燥な」エラー画面。
job.completed の後、ウィジェットがただギフト一覧を見せるだけで次のアクションがない、あるいは job.failed で「エラー 500」といった技術的メッセージだけを出す。これでは UX が途切れます。最後に短い要約と明確な CTA(「選定を保存」「購入に進む」など)を出し、エラー時には人間に分かる説明と「再試行」や「パラメータを変更」といった選択肢を提示するのが望ましいです。
GO TO FULL VERSION