CodeGym /课程 /ChatGPT Apps /流式 UX:进度、partial results、取消长耗时操作

流式 UX:进度、partial results、取消长耗时操作

ChatGPT Apps
第 13 级 , 课程 2
可用

1. 为什么流式 UX 在 ChatGPT App 中尤为重要

在常规的 Web 中,用户已经习惯了文件上传进度条、旋转的 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 启动 按钮禁用,小型 spinner
in_progress
Worker 正在运行,发送 job.progress 进度条或步骤指示(“第 1/3 步”)
partial_ready
已有首批结果,任务仍在进行 已可看到首批礼物 + 依然显示进度
completed
收到 job.completed 最终礼物清单,CTA(“购买”)
failed
收到 job.failed 错误消息 + “重试”按钮
canceled
收到 job.canceled 或 cancel 标记 “挑选已停止”文本 + “重新开始”

同一套模型也非常契合 MCP 事件。例如,job.startedpending 转为 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(无部分结果)
    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 客户端,但对外返回的是正常的 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 事件的 payload 中返回 stepCurrentstepTotalstatusText,以及(可选的)合理 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 秒。阶段文案 + 不可确定进度条的组合在 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 在视觉上可区分。用户需要知道列表仍在补充:可以通过“正在继续筛选”的文字、角落里的小 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。当取消可能需要几秒时,这很有帮助——用户能立刻看到操作被接受。但也要准备好,若 worker 恰好跑到终点,服务端仍可能回 job.completedjob.failed。在事件处理器里,最好过滤这些“延到的”终态,例如不要覆盖已经是 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 工具 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 的二重奏,能创造连续一致的体验。

在工作流与电商模块中,这种衔接更为重要:每一个长步骤(购物车分析、库存校验、支付等待)都应在文本和界面上同样清晰可见。

9. 流式 UX 的常见错误

错误 1:“永远转圈且没有文本说明”。
最常见的反模式就是只转动动画,却不解释发生了什么。用户不清楚系统是否在做有用的事,还是已经卡住。一个简单的阶段文案(“正在收集热门礼物…”、“正在分析评价”)就能改善,若能配合明确的状态 pendingin_progresspartial_ready(你已经在组件状态中维护它们)则更佳。

错误 2:虚假的进度百分比。
试图用捏造的进度(凭空来的“73%”)“提升信任”,通常适得其反。用户很快会发现 99% 能挂 20 秒,于是再也不相信指示器。如果没有诚实的度量,请使用阶段文案与不可确定进度条,而不是欺骗。

错误 3:把一切弄乱的部分结果。
有时部分结果会被实现成“每次事件就完全重建列表”,列表时而消失、时而洗牌。结果是用户点了卡片,它突然跑到下面去了。这样的抖动在电商场景尤其可怕。正确做法是谨慎追加卡片(通常只追加到末尾)、稳定 key,并尽量减少布局突变。

错误 4:名义上的取消,实际上什么也没取消。
也会出现这种情况:组件里有“取消”按钮,但它只是把 UI 隐藏起来,后端的 job 并未停止。资源依然被消耗、job.completed 仍会迟到,而用户以为已经停了。真正的取消应同时作用于前端(禁用按钮、停止流)与后端(向 worker 传递 cancel 信号,并收到 job.canceled 事件)。

错误 5:忽视结尾与“呆板”的错误页。
有时在 job.completed 后,小部件只是罗列礼物列表,没有任何下一步;在 job.failed 时,只给出技术性“错误 500”。两种情况下 UX 都是半途而止。更好的做法是在末尾给出简短总结与明确的 CTA(“保存选品”、“前往购买”);而在错误时,提供人类友好的说明与“重试”或“修改参数”的按钮,而不是把用户丢在状态码面前。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION