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 中长耗时操作的状态模型
为了避免把事件流写成一锅 if(event.type === …)的粥,建议把长任务在客户端视为一个有限状态机(state machine)。在 GiftGenius 中,我们将使用你在理论部分已见过的这些逻辑状态:pending、in_progress、partial_ready、completed、failed、canceled,外加等待态 idle。
将其汇总如下:
| 状态 | 后端含义 | 用户在小部件里看到什么 |
|---|---|---|
|
尚未创建任务 | 普通表单,“挑选礼物”按钮 |
|
Job 已创建,等待 worker 启动 | 按钮禁用,小型 spinner |
|
Worker 正在运行,发送 job.progress | 进度条或步骤指示(“第 1/3 步”) |
|
已有首批结果,任务仍在进行 | 已可看到首批礼物 + 依然显示进度 |
|
收到 job.completed | 最终礼物清单,CTA(“购买”) |
|
收到 job.failed | 错误消息 + “重试”按钮 |
|
收到 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_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);
- 部分结果数组(礼物卡片);
- 按钮状态:能否取消、能否重启。
用一个接口来描述:
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_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 秒。阶段文案 + 不可确定进度条的组合在 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.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 工具 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 的二重奏,能创造连续一致的体验。
在工作流与电商模块中,这种衔接更为重要:每一个长步骤(购物车分析、库存校验、支付等待)都应在文本和界面上同样清晰可见。
9. 流式 UX 的常见错误
错误 1:“永远转圈且没有文本说明”。
最常见的反模式就是只转动动画,却不解释发生了什么。用户不清楚系统是否在做有用的事,还是已经卡住。一个简单的阶段文案(“正在收集热门礼物…”、“正在分析评价”)就能改善,若能配合明确的状态 pending、in_progress、partial_ready(你已经在组件状态中维护它们)则更佳。
错误 2:虚假的进度百分比。
试图用捏造的进度(凭空来的“73%”)“提升信任”,通常适得其反。用户很快会发现 99% 能挂 20 秒,于是再也不相信指示器。如果没有诚实的度量,请使用阶段文案与不可确定进度条,而不是欺骗。
错误 3:把一切弄乱的部分结果。
有时部分结果会被实现成“每次事件就完全重建列表”,列表时而消失、时而洗牌。结果是用户点了卡片,它突然跑到下面去了。这样的抖动在电商场景尤其可怕。正确做法是谨慎追加卡片(通常只追加到末尾)、稳定 key,并尽量减少布局突变。
错误 4:名义上的取消,实际上什么也没取消。
也会出现这种情况:组件里有“取消”按钮,但它只是把 UI 隐藏起来,后端的 job 并未停止。资源依然被消耗、job.completed 仍会迟到,而用户以为已经停了。真正的取消应同时作用于前端(禁用按钮、停止流)与后端(向 worker 传递 cancel 信号,并收到 job.canceled 事件)。
错误 5:忽视结尾与“呆板”的错误页。
有时在 job.completed 后,小部件只是罗列礼物列表,没有任何下一步;在 job.failed 时,只给出技术性“错误 500”。两种情况下 UX 都是半途而止。更好的做法是在末尾给出简短总结与明确的 CTA(“保存选品”、“前往购买”);而在错误时,提供人类友好的说明与“重试”或“修改参数”的按钮,而不是把用户丢在状态码面前。
GO TO FULL VERSION