CodeGym /課程 /ChatGPT Apps /串流式 UX:進度、partial results、取消長時間作業

串流式 UX:進度、partial results、取消長時間作業

ChatGPT Apps
等級 13 , 課堂 2
開放

1. 為什麼串流式 UX 對 ChatGPT App 特別重要

在一般網頁上,使用者早已習慣檔案上傳進度條、旋轉的 spinner 與 skeleton 畫面。但在 ChatGPT 應用中,你還有一個額外的「競爭者」:模型本身能即時串流文字。如果此時你的元件只畫出靜態的 spinner 而沒任何說明,體感上就會輸一截——GPT 很「有生命感」,App 卻像「卡住」了。

長時間作業的 UX 一次解決好幾件事。首先,降低使用者焦慮:與其心想「是當掉了還是在思考?」,他會看到狀態、階段、百分比,甚至第一批結果。其次,提升信任感:當 App 清楚地顯示自己在做什麼(分析評價、比對價格、篩選禮物),就形成所謂的 operational transparency——作業透明度。使用者理解到:底下不是魔法,而是可理解的一連串步驟。

最後,串流式 UX 不只是進度,也是控制感。讓使用者能停止耗時的禮物挑選、改變參數並立刻重新啟動——這是「我在掌控,而不是被伺服器擺布」的重要體驗。

本講座我們將:

  • 設計長任務的簡單狀態模型(pending / in_progress / partial_ready / …);
  • 把它落到小工具的 React 狀態;
  • 搞懂如何誠實地顯示進度與部分結果;
  • 細緻地實作此類任務的取消。

以上都會以我們的 GiftGenius 為例。

2. GiftGenius 中長任務的狀態模型

為了不要把事件串流寫成一大團 ifevent.type === …),把長任務視為前端的一個有限狀態機(state machine)會很方便。對於 GiftGenius,我們使用你在理論中已見過的幾個邏輯狀態:pendingin_progresspartial_readycompletedfailedcanceled,以及等待狀態 idle

把它們彙整成表格:

狀態 在後端代表什麼 使用者在小工具中看到什麼
idle
尚未建立任務 一般表單,「挑選禮物」按鈕
pending
已建立 job,等待 worker 啟動 按鈕停用,顯示輕量轉圈
in_progress
worker 執行中,發送 job.progress 進度條或步驟(「第 1 步,共 3 步」)
partial_ready
已有初步結果,仍在進行中 已可看到第一批禮物,仍在顯示進度
completed
收到 job.completed 最終禮物清單、CTA(「購買」)
failed
收到 job.failed 錯誤訊息 + 「重試」按鈕
canceled
收到 job.canceled 或 cancel‑旗標 「已停止挑選」文字 + 「重新開始」

這套模型同樣非常適合對應 MCP 事件。比如,job.started 會把狀態從 pending 轉到 in_progressjob.progress 不是單純更新 in_progress 中的百分比,也可以表示「出現第一批卡片」,此時你就進入 partial_readyjob.completedjob.failedjob.canceled 則收尾整個流程。

看起來像一個小型狀態機:

stateDiagram-v2
    [*] --> idle
    idle --> pending: 建立 job
    pending --> in_progress: job.started
    in_progress --> partial_ready: 第一批部分結果
    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 元件如何「監聽」串流

把狀態模型帶進 GiftGenius 的 React 程式碼。我們需要儲存:

  • 目前的 jobId,用來知道哪些事件屬於這個任務;
  • 任務狀態(statuspercentstage);
  • 部分結果的陣列(禮物卡片);
  • 按鈕旗標:是否可取消、是否可重新啟動。

用一個介面描述:

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

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

在元件中的初始化可以很簡單:

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

接著有兩個關鍵點。

第一,啟動任務。它可以是透過 Apps SDK 的 MCP 工具呼叫(callTool),或是打到你後端的 HTTP 請求,用以建立 job 並回傳 jobId。在本講座我們不深入 async pipeline 的細節——這會在下一個關於佇列與 worker 的主題中處理。此處我們只關注 UI 對既有 jobId 的反應。

第二,訂閱該 jobId 的事件。實務上可以是像 useJobEvents(jobId) 的 hook,或封裝好的 subscribeToJobEvents。它們底層可能使用 SSE 或 MCP client,但對外會交付已整理好的 JS 物件。下方為了簡化,在 useEffect 中示範使用 subscribeToJobEvents 的寫法:

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

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

其中 handleEvent 會依事件型別更新 state。接下來依序說明三類事件處理:進度、部分結果與取消任務。

4. 進度視覺化:百分比、階段與誠實性

UX 中的進度有兩種:可確定(determinate)與不可確定(indeterminate)。可確定時,你真的知道完成度:例如有 4 個工作階段,或處理了 100 個檔案中的 30 個。不可確定時,你誠實地承認「不知道還要多久」,以「思考中」的動畫取代虛假的「73%」。

在 GiftGenius 中,邏輯可以是這樣:如果後端能實際計算進度——例如有 collect_sourcesanalyze_preferencesrank_candidatesenrich_descriptions 等步驟——你可以在 job.progress 事件中回傳包含 stepCurrentstepTotalstatusText,以及(選用的)合理 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>
)}

這裡有個心理層面的重點:如果你沒有「誠實的百分比」,最好顯示「第 2 步,共 3 步:分析偏好」加上一個不可確定的進度條(動畫),也不要讓 99% 卡住 30 秒。這種「階段文字 + 不可確定進度條」的組合,在很難精準估算剩餘時間的 AI 作業中特別好用。

5. Partial results:不必等到一切完美才顯示

串流式 UX 最令人愉快的一部分就是部分結果(partial results)。既然在 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_readycompleted 必須在視覺上可區分。使用者要明白清單仍在擴充:例如加上「仍在挑選中」的文字、角落的小型 spinner,或對新卡片做中性醒目的高亮。

6. 取消長時間作業:UX 與技術

既然你允許使用者啟動耗時的禮物挑選,幾乎總是也該讓他能停止。取消不僅節省 LLM 與 worker 的資源,也帶來控制感:「我決定接下來會發生什麼」。

從 UX 角度來看,取消按鈕應該夠明顯,但不必是畫面中央的大紅警示。有效的組合是:主要按鈕「取消挑選」,以及一段次要文字「隨時都可以重新啟動」。同時要讓使用者清楚知道到底在取消什麼——是目前這次的分析,而不是整個應用。

從技術角度有兩個層級的取消。

其一是前端取消:你可以中止本地的 fetch 或關閉 SSE 連線。這能省流量,但本身不會停止後端的 worker。

其二是真正的 job 取消:透過 MCP 工具或 HTTP 端點 POST /jobs/{jobId}/cancel,把任務標記為 canceled,並讓 worker 有機會優雅結束。此時伺服器會發送 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.completedjob.failed(例如 worker 已經跑到終點)。在事件處理器中,應過濾這些「延遲到達」的終態,例如避免覆寫已經是 canceled 的狀態。

更保守的作法是悲觀式 UI:先顯示「正在取消…」,鎖住按鈕,等收到 job.canceled 才切到 canceled。它實作較簡單,但視覺回饋較慢。可以依後端 SLA 選擇策略。

7. 全部整合:GiftGenius 迷你進度面板

現在把各部分組合起來。我們已經寫了:

  • 進度處理器 handleJobProgress
  • 部分結果處理器 handlePartialResult
  • 以及取消處理器 handleCancelClick

本質上,這就是前一節提到的通用 handleEvent:它對 job.progressjob.partial_resultjob.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>
);

ProgressSectionGiftsListErrorSection 這樣的子元件有助於避免把主元件寫成「麵條」。但核心理念只有一個:整個小工具由一個清楚的狀態模型驅動,且它與你已熟悉的 MCP 事件與串流通道直接對應。

8. 關於與 ChatGPT 對話的銜接

雖然本講座專注在小工具本身,但別忘了使用者仍在與模型對話。良好的情境是:GPT 告訴使用者它要啟動 GiftGenius,接著小工具顯示進度,而 GPT 用文字補充:「我剛啟動進階的禮物挑選,你會看到清單逐步增加。」

挑選完成後,ChatGPT 可以從 ToolOutput 接手結果並給出人性化摘要:「我找到了 10 個選項,以下是重點,完整清單請看下方的小工具。」文字串流與串流式 UI 的組合能帶來一致的體驗。

在 workflow 與 commerce 模組中,這種銜接更為重要:每個耗時步驟(分析購物車、檢查庫存、等待付款)都必須在文字與介面中同時說清楚。

9. 串流 UX 常見錯誤

錯誤 1:「永遠轉的 spinner,沒有任何文字」。
最常見的反模式,是只轉動畫,卻完全不解釋正在發生什麼。使用者不明白系統是否在做有用的事,還是當機了。加上一段階段文字(「正在收集熱門禮物…」、「正在分析評價」)就能改善;更好的是在小工具狀態中維持清楚的 pendingin_progresspartial_ready 等狀態,並據此顯示。

錯誤 2:虛假的進度百分比。
想用「捏造的進度」(憑空而來的「73%」)提升信任,往往產生反效果。使用者很快會發現 99% 能卡 20 秒,於是再也不相信指示器。如果你沒有誠實的量測,請使用階段文字與不可確定的進度條,別欺騙使用者。

錯誤 3:讓一切崩壞的 partial results。
有時 partial 結果會被實作成每次事件就整份清單「重建」:清單忽隱忽現、順序不斷重排。結果使用者正在點卡片,它突然往下跑。這種抖動在 commerce 場景特別可怕。較佳作法是溫和地新增(多半只加在尾端)、保留穩定的 key,並把版面跳動降到最低。

錯誤 4:名為取消,實際什麼都沒取消。
也會遇到這種情況:小工具有「取消」按鈕,但它只隱藏 UI,並未停止伺服器上的真實 job。結果資源持續被消耗、晚到的 job.completed 不停冒出,而使用者以為早就停了。真正的取消必須同時影響前端(停用按鈕、關閉串流)與後端(把 cancel 訊號傳給 worker,並接收 job.canceled 事件)。

錯誤 5:忽略收尾,只有「笨拙」的錯誤畫面。
有時在 job.completed 後,小工具只顯示禮物清單、沒有任何下一步;而在 job.failed 時,只有技術訊息「錯誤 500」。兩者都讓 UX 截斷。較佳做法是在結尾給出簡短摘要與明確 CTA(「儲存這次挑選」、「前往購買」);若發生錯誤,提供人性化說明與「重試」、「修改參數」等選項,而不是把使用者丟在狀態碼前。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION