1. Vì sao UX theo luồng đặc biệt quan trọng trong ChatGPT App
Trên web thông thường, người dùng đã quen với thanh tiến trình khi tải tệp, spinner quay và màn hình skeleton. Nhưng trong các ứng dụng ChatGPT, bạn có thêm một “đối thủ”: chính mô hình có thể stream văn bản theo thời gian thực. Nếu widget lúc đó chỉ vẽ một spinner tĩnh không lời giải thích, trải nghiệm sẽ thua kém — GPT thì “sống động”, còn App lại “đơ”.
UX cho các tác vụ dài giải quyết nhiều việc cùng lúc. Thứ nhất, giảm lo lắng của người dùng: thay vì “bị treo hay vẫn đang nghĩ?” họ thấy trạng thái, các bước, phần trăm và thậm chí cả những kết quả đầu tiên. Thứ hai, tăng niềm tin: khi App nói rõ đang làm gì (phân tích đánh giá, so giá, lọc quà tặng), điều đó tạo nên operational transparency — sự minh bạch của quy trình. Người dùng hiểu rằng bên dưới không phải phép màu, mà là một chuỗi bước dễ hiểu.
Cuối cùng, UX theo luồng không chỉ là chuyện tiến trình. Nó còn là quyền kiểm soát. Khả năng dừng quá trình chọn quà nặng, đổi tham số và chạy lại ngay — là phần quan trọng của cảm giác “tôi kiểm soát, chứ không phải chờ máy chủ đoái hoài”.
Trong bài giảng này, chúng ta sẽ:
- thiết kế một mô hình trạng thái đơn giản cho tác vụ dài (pending / in_progress / partial_ready / …);
- chuyển hóa nó thành trạng thái React của widget;
- tìm hiểu cách hiển thị tiến trình một cách trung thực và các kết quả từng phần;
- triển khai việc hủy các tác vụ như vậy một cách chỉn chu.
Tất cả sẽ dựa trên ví dụ GiftGenius của chúng ta.
2. Mô hình trạng thái cho tác vụ dài trong GiftGenius
Để không biến luồng sự kiện thành “cháo” của if (event.type === …), hãy coi tác vụ dài như một máy trạng thái (state machine) ở phía client. Với GiftGenius, chúng ta dùng các trạng thái logic sau mà bạn đã gặp trong lý thuyết: pending, in_progress, partial_ready, completed, failed, canceled cùng với trạng thái chờ idle.
Tổng hợp trong bảng:
| Trạng thái | Ý nghĩa ở backend | Người dùng thấy gì trong widget |
|---|---|---|
|
Chưa có job nào | Form bình thường, nút “Chọn quà tặng” |
|
Job đã được tạo, chờ worker khởi động | Nút bị vô hiệu hóa, spinner nhẹ |
|
Worker đang chạy và gửi job.progress | Thanh tiến trình hoặc các bước “Bước 1 trong 3” |
|
Đã có kết quả đầu tiên, tác vụ vẫn tiếp tục | Đã thấy các gợi ý quà đầu tiên + vẫn còn tiến trình |
|
Nhận được job.completed | Danh sách quà tặng cuối cùng, CTA (“Mua”) |
|
Nhận được job.failed | Thông báo lỗi + nút “Thử lại” |
|
Nhận được job.canceled hoặc cờ hủy | Văn bản “Đã dừng quá trình chọn” + “Bắt đầu lại” |
Mô hình này cũng khớp rất tốt với các sự kiện MCP. Ví dụ, job.started chuyển từ pending sang in_progress, job.progress có thể chỉ cập nhật phần trăm trong in_progress, hoặc nói “chúng ta đã có những thẻ đầu tiên” và khi đó bạn chuyển sang partial_ready. job.completed, job.failed và job.canceled khép lại câu chuyện.
Trông như một máy trạng thái nhỏ:
stateDiagram-v2
[*] --> idle
idle --> pending: tạo job
pending --> in_progress: job.started
in_progress --> partial_ready: các kết quả từng phần đầu tiên
partial_ready --> completed: job.completed
in_progress --> completed: job.completed (không có partial)
in_progress --> failed: job.failed
partial_ready --> failed: job.failed
in_progress --> canceled: job.canceled
partial_ready --> canceled: job.canceled
failed --> idle: chạy lại
canceled --> idle: chạy lại
Trong code của widget có thể phản ánh bằng kiểu đơn giản:
type JobStatus =
| 'idle'
| 'pending'
| 'in_progress'
| 'partial_ready'
| 'completed'
| 'failed'
| 'canceled';
interface GiftJobState {
status: JobStatus;
percent?: number;
stage?: string;
error?: string;
}
Hiện tại đây mới chỉ là hình dạng dữ liệu. Tiếp theo chúng ta sẽ lấp đầy nó khi sự kiện từ MCP hoặc từ stream đến.
3. Trạng thái widget: cách React‑component “lắng nghe” luồng
Đưa mô hình trạng thái vào code React của GiftGenius. Chúng ta cần lưu:
- jobId hiện tại để biết sự kiện nào thuộc về job này;
- trạng thái của job (status, percent, stage);
- mảng kết quả từng phần (các thẻ quà tặng);
- các cờ cho nút: có thể hủy, có thể chạy lại.
Mô tả bằng một interface:
interface GiftSuggestion {
id: string;
title: string;
price: string;
}
interface GiftWidgetState extends GiftJobState {
jobId?: string;
partialGifts: GiftSuggestion[];
}
Khởi tạo trong component có thể rất đơn giản:
const [state, setState] = useState<GiftWidgetState>({
status: 'idle',
partialGifts: [],
});
Tiếp theo có hai điểm mấu chốt.
Thứ nhất, khởi chạy job. Đây có thể là gọi MCP‑tool qua Apps SDK (callTool) hoặc một HTTP request lên backend của bạn để tạo job và trả về jobId. Trong bài này, chúng ta không đi sâu vào cách async‑pipeline vận hành — vấn đề này sẽ nằm ở chủ đề tiếp theo về hàng đợi và worker. Hiện giờ, điều quan trọng là phản ứng của UI với jobId đã được tạo.
Thứ hai, subscribe vào các sự kiện của jobId đó. Trên thực tế có thể là một hook như useJobEvents(jobId) hoặc wrapper subscribeToJobEvents, bên dưới dùng kết nối SSE hoặc MCP client, nhưng bên ngoài trả về các object JS bình thường. Dưới đây, để đơn giản, chúng ta dùng subscribeToJobEvents bên trong useEffect:
useEffect(() => {
if (!state.jobId) return;
const unsubscribe = subscribeToJobEvents(state.jobId, handleEvent);
return () => unsubscribe();
}, [state.jobId]);
Ở đây handleEvent chỉ cập nhật state tùy theo loại sự kiện. Tiếp theo chúng ta lần lượt xem ba nhóm sự kiện mà nó xử lý: tiến trình, kết quả từng phần và hủy job.
4. Trực quan hóa tiến trình: phần trăm, các bước và tính trung thực
Tiến trình trong UX thường có hai loại: xác định (determinate) và không xác định (indeterminate). Với loại đầu, bạn thật sự biết đã làm được bao nhiêu: chẳng hạn có 4 bước của workflow, hoặc đã xử lý 30 trên 100 tệp. Với loại sau, bạn thẳng thắn thừa nhận rằng không biết còn phải đợi bao lâu, và hiển thị hoạt ảnh “đang xử lý” thay vì con số “73%” bịa đặt.
Trong GiftGenius, logic có thể như sau. Nếu backend thực sự tính được tiến độ — ví dụ có các bước collect_sources, analyze_preferences, rank_candidates, enrich_descriptions — bạn có thể trả về trong sự kiện job.progress payload với các trường stepCurrent, stepTotal, statusText và (tùy chọn) percent hợp lý.
Kiểu sự kiện trong 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;
}
Trình xử lý tiến trình trong component:
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}`,
}));
}
Trong JSX có thể render thanh tiến trình và văn bản bước:
{(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>
)}
Có một điểm tâm lý quan trọng: nếu bạn không có phần trăm “thật”, tốt hơn là chỉ hiển thị “Bước 2 trong 3: đang phân tích sở thích” kèm một progress‑bar không xác định (indeterminate) thay vì “99%” đứng im suốt 30 giây. Kiểu kết hợp (các bước + indeterminate‑bar) này rất hiệu quả cho các tác vụ AI, nơi khó ước tính chính xác phần còn lại.
5. Partial results: không cần chờ mọi thứ hoàn hảo
Phần thú vị nhất của UX theo luồng là các kết quả từng phần. Tại sao bắt người dùng chờ khi chỉ sau 5–7 giây bạn đã có những gợi ý quà tặng đầu tiên? Hãy hiển thị ngay, còn phần còn lại tải dần sau.
Trong GiftGenius, backend trong quá trình làm việc có thể gửi các sự kiện job.partial_result, hoặc chẳng hạn resource.updated với một mẻ gợi ý mới. Mỗi sự kiện như vậy mang về một mảng quà, được thêm vào những gì đã có.
Dạng payload giả định:
interface PartialResultPayload {
gifts: GiftSuggestion[];
isFinalChunk?: boolean;
}
Trình xử lý:
function handlePartialResult(payload: PartialResultPayload) {
setState(prev => ({
...prev,
status: 'partial_ready',
partialGifts: [...prev.partialGifts, ...payload.gifts],
}));
}
Trong JSX, bạn chỉ cần render các thẻ, bất kể job đã xong hay chưa:
<section>
{state.partialGifts.map(gift => (
<GiftCard key={gift.id} gift={gift} />
))}
{(state.status === 'in_progress' || state.status === 'partial_ready') && (
<p>Chúng tôi vẫn đang tìm thêm gợi ý…</p>
)}
</section>
Có vài lưu ý UX quan trọng cần nhớ.
Thứ nhất, tránh các cú nhảy bố cục (layout shift) đột ngột. Nếu bạn thêm quà mới lên đầu danh sách, người dùng sẽ mất vị trí đang đọc. An toàn hơn là thêm vào cuối (append‑only) và animate mềm khi xuất hiện.
Thứ hai, nếu bạn dùng chiến lược refinement (ban đầu là danh sách nháp nhanh, sau đó “đánh bóng” và xếp hạng lại), hãy cẩn trọng với tính tương tác. Khi kết quả còn “nháp”, đừng cho bấm “Mua” hoặc gắn nhãn rõ ràng là “tạm thời”. Nếu không, người dùng chọn một món quà, rồi ngay sau đó nó biến mất hoặc đổi giá — thảm họa UX.
Thứ ba, trạng thái partial_ready nên khác biệt rõ ràng so với completed. Người dùng cần hiểu rằng danh sách vẫn đang được bổ sung: hoặc bằng dòng “Đang tiếp tục chọn”, hoặc một spinner nhỏ ở góc, hoặc tô sáng nhẹ các thẻ mới.
6. Hủy các tác vụ dài: UX và kỹ thuật
Nếu bạn cho phép người dùng khởi chạy quá trình chọn quà nặng, gần như luôn phải cho họ quyền dừng lại. Hủy không chỉ tiết kiệm tài nguyên LLM và worker, mà còn mang lại cảm giác kiểm soát: “tôi quyết định điều gì đang diễn ra”.
Về UX, nút hủy nên đủ dễ thấy nhưng không phải một mảng đỏ chói giữa màn hình. Một cặp hiệu quả là: nút chính “Hủy quá trình chọn” và văn bản phụ nhỏ “có thể chạy lại bất cứ lúc nào”. Quan trọng là người dùng hiểu chính xác cái gì bị hủy — phân tích hiện tại, chứ không phải cả ứng dụng.
Về kỹ thuật, bạn có hai tầng hủy.
Thứ nhất, hủy ở frontend: bạn có thể dừng fetch cục bộ hoặc đóng kết nối SSE. Điều này tiết kiệm băng thông nhưng tự thân không dừng worker ở backend.
Thứ hai, hủy job thực sự: thông qua MCP‑tool hoặc HTTP endpoint POST /jobs/{jobId}/cancel, đánh dấu job là canceled và cho worker cơ hội kết thúc gọn gàng. Lúc này server gửi sự kiện job.canceled, và bạn xử lý trong widget.
Nhìn từ phía widget:
async function handleCancelClick() {
if (!state.jobId) return;
// Cập nhật UI theo hướng lạc quan
setState(prev => ({ ...prev, status: 'canceled' }));
try {
await cancelJobOnServer(state.jobId); // MCP tool hoặc HTTP
} catch (e) {
// Nếu hủy trên server thất bại — hoàn nguyên trạng thái
setState(prev => ({ ...prev, status: 'in_progress' }));
}
}
Và nút:
<button
onClick={handleCancelClick}
disabled={
state.status !== 'pending' &&
state.status !== 'in_progress' &&
state.status !== 'partial_ready'
}
>
Hủy quá trình chọn
</button>
Ở đây chúng ta dùng UI lạc quan: chuyển sang canceled ngay mà không chờ xác nhận từ server. Điều này hữu ích khi thao tác hủy có thể mất vài giây — người dùng lập tức thấy hành động của mình đã được chấp nhận. Nhưng cũng cần sẵn sàng cho trường hợp server vẫn trả về job.completed hoặc job.failed nếu worker kịp chạy đến cuối. Trong trình xử lý sự kiện, nên lọc các “kết thúc muộn” như vậy và, chẳng hạn, không ghi đè trạng thái đã canceled.
Cách thận trọng hơn — UI bi quan: trước tiên hiển thị trạng thái “Đang hủy…”, khóa nút, và chỉ sau job.canceled mới chuyển job sang canceled. Cách này dễ triển khai hơn nhưng kém linh hoạt về cảm nhận. Bạn có thể chọn cách nào tùy theo SLA của backend.
7. Ghép mọi thứ lại: mini‑panel tiến trình cho GiftGenius
Bây giờ ghép các mảnh lại với nhau. Chúng ta đã viết:
- trình xử lý tiến trình handleJobProgress,
- trình xử lý kết quả từng phần handlePartialResult,
- và trình xử lý hủy handleCancelClick.
Thực chất đây chính là handleEvent tổng quát từ phần trước: nó phản ứng với job.progress, job.partial_result, job.canceled và các sự kiện khác, rồi cập nhật trạng thái của một component. Giờ chỉ còn bọc tất cả vào một component nhỏ GiftJobPanel, cái mà:
- khởi chạy quá trình chọn quà;
- lắng nghe sự kiện theo jobId;
- hiển thị tiến trình;
- render partial results;
- cho phép hủy job.
Chúng ta sẽ giản lược mạnh các chi tiết tích hợp với Apps SDK / MCP và tập trung vào logic trạng thái.
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 ?? 'Đã xảy ra sự cố',
}));
break;
case 'job.canceled':
setState(prev => ({ ...prev, status: 'canceled' }));
break;
}
});
return () => unsub();
}, [state.jobId]);
Khởi chạy job có thể được thực hiện qua MCP‑tool start_gift_search:
async function handleStartClick() {
setState({
status: 'pending',
partialGifts: [],
});
const jobId = await startGiftSearchOnServer(/* tham số của người dùng */);
setState(prev => ({ ...prev, jobId }));
}
Tiếp theo trong JSX:
return (
<div>
{state.status === 'idle' && (
<button onClick={handleStartClick}>Chọn quà tặng</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>Đã dừng quá trình chọn. Bạn có thể chạy lại với tham số khác.</p>
)}
</div>
);
Các sub‑component như ProgressSection, GiftsList, ErrorSection giúp component chính không bị “mì sợi”. Nhưng ý tưởng cốt lõi chỉ có một: toàn bộ widget được điều khiển bởi một mô hình trạng thái rõ ràng, trực tiếp tương ứng với các sự kiện MCP và các kênh stream mà bạn đã biết.
8. Vài điều về liên kết với cuộc hội thoại ChatGPT
Dù bài giảng tập trung vào chính widget, cần nhớ rằng người dùng vẫn đang trong một cuộc hội thoại với mô hình. Kịch bản tốt sẽ như sau: GPT thông báo cho người dùng rằng nó đang khởi chạy GiftGenius, sau đó widget hiển thị tiến trình, còn GPT bổ trợ bằng văn bản: “Mình vừa khởi chạy quá trình chọn quà nâng cao, bạn sẽ thấy danh sách dần dần được điền.”
Sau khi hoàn tất chọn quà, ChatGPT có thể lấy kết quả từ ToolOutput và viết tóm tắt thân thiện: “Mình đã tìm được 10 lựa chọn, đây là tổng quan ngắn; danh sách đầy đủ ở widget phía dưới.” Sự kết hợp giữa streaming văn bản và UI theo luồng tạo nên trải nghiệm liền mạch.
Sự liên kết này còn quan trọng hơn trong các mô‑đun về workflow và commerce, nơi mỗi bước dài (phân tích giỏ hàng, kiểm tra tồn kho, chờ thanh toán) phải dễ hiểu cả trong văn bản lẫn trong giao diện.
9. Những lỗi thường gặp trong UX theo luồng
Lỗi số 1: “Spinner vĩnh viễn không có văn bản”.
Anti‑pattern phổ biến nhất là chỉ quay hoạt ảnh mà không giải thích điều gì đang diễn ra. Người dùng không hiểu hệ thống đang làm gì hữu ích hay đã treo. Cách khắc phục là thêm văn bản mô tả bước (“Đang thu thập quà tặng phổ biến…”, “Đang phân tích đánh giá”), và tốt hơn nữa là dùng các trạng thái rõ ràng pending, in_progress, partial_ready mà bạn đã giữ trong state của widget.
Lỗi số 2: Phần trăm tiến trình giả mạo.
Cố “tăng niềm tin” bằng cách vẽ tiến trình bịa (“73%” từ không khí) thường cho hiệu ứng ngược. Người dùng nhanh chóng nhận ra 99% có thể đứng yên 20 giây và ngừng tin chỉ báo. Nếu bạn không có số liệu thật, tốt hơn hãy dùng các bước và progress‑bar không xác định, thay vì đánh lừa.
Lỗi số 3: Partial results làm vỡ trải nghiệm.
Đôi khi partial results được triển khai như một danh sách được lắp ráp lại hoàn toàn, lúc thì biến mất, lúc thì xáo trộn sau mỗi sự kiện. Kết quả là người dùng bấm vào thẻ thì nó bất ngờ chạy xuống dưới. Sự rung lắc như vậy đặc biệt nguy hiểm trong kịch bản commerce. Đúng hơn là thêm thẻ một cách cẩn thận (thường chỉ thêm vào cuối), giữ key ổn định và tối thiểu hóa layout shift.
Lỗi số 4: Nút hủy nhưng chẳng hủy gì.
Cũng có khi widget có nút “Hủy” nhưng chỉ ẩn UI, không dừng job thực sự trên server. Hệ quả là tài nguyên vẫn tiếp tục bị tiêu tốn, các job.completed đến muộn, còn người dùng thì tưởng rằng mọi thứ đã dừng. Hủy đúng nghĩa phải bao gồm cả frontend (tắt nút, dừng stream) lẫn backend (gửi tín hiệu hủy cho worker và nhận sự kiện job.canceled).
Lỗi số 5: Bỏ qua phần kết và màn lỗi “cụt ngủn”.
Đôi khi sau job.completed, widget chỉ hiển thị danh sách quà mà không có bước tiếp theo rõ ràng; còn khi job.failed thì chỉ là thông báo kỹ thuật “Lỗi 500”. Cả hai đều làm UX bị cụt. Đúng hơn là ở cuối nên có phần tóm tắt ngắn và CTA rõ ràng (“Lưu danh sách”, “Tiến hành mua”), còn khi lỗi thì giải thích bằng ngôn ngữ đời thường và có nút “Thử lại” hoặc “Thay đổi tham số” thay vì bỏ người dùng bơ vơ với mã trạng thái.
GO TO FULL VERSION