1. Từ ToolOutput đến React‑component: luồng dữ liệu tổng quát
Trong bài trước, chúng ta đã xem cách server tool tạo ra ToolOutput — phản hồi có cấu trúc cho mô hình và widget. Giờ hãy nhìn vào nửa còn lại của hành trình: cách ToolOutput này đi vào widget và trở thành UI.
Để không xem mọi thứ như “phép màu”, hãy một lần nữa điểm lại đường đi dữ liệu từ người dùng đến widget của bạn. Ở dạng đơn giản, mọi thứ như sau:
- Người dùng đặt câu hỏi trong chat.
- GPT phân tích yêu cầu, nhìn vào danh sách công cụ và quyết định: “Bây giờ suggest_gifts sẽ giúp tôi”.
- GPT tạo tool call với tên và tham số (ToolInput) và gửi đến server của bạn (MCP hoặc backend).
- Server thực thi logic của công cụ và trả kết quả dưới dạng ToolOutput — JSON có cấu trúc chứa dữ liệu, cộng bản tóm tắt văn bản cho mô hình.
- ChatGPT nhận ToolOutput và chuyển tiếp: cho mô hình (để tiếp tục hội thoại) và cho widget của bạn qua Apps SDK (window.openai.toolOutput hoặc hooks).
- Widget của bạn — một React‑component thông thường — đọc toolOutput và render UI.
Sơ đồ có thể minh họa như sau:
flowchart TD U[Người dùng] -->|yêu cầu trong chat| GPT[GPT] GPT -->|callTool: suggest_gifts| B[Backend/MCP] B -->|"ToolOutput (JSON)"| GPT GPT -->|truyền toolOutput| W["Widget (React)"] W -->|thẻ, danh sách| U
Điều quan trọng cần ghi nhớ: ToolOutput không chỉ là “phản hồi của server”. Nó cũng là mệnh lệnh render cho widget của bạn và đồng thời là ngữ cảnh cho mô hình. Một App tốt là nơi JSON này được chuyển thành giao diện hữu ích, chứ không phải để developer lướt qua trong DevTools.
2. Giải phẫu ToolOutput: bên trong có gì
Định dạng kết quả công cụ trong Apps SDK được chia thành ba khối logic: structuredContent, content và _meta (phần này đi vào widget dưới tên toolResponseMetadata).
Có thể hình dung như sau:
{
"structuredContent": { /* dữ liệu cho UI + mô hình */ },
"content": "Bản tóm tắt ngắn cho mô hình và người dùng",
"_meta": { /* dữ liệu kỹ thuật chỉ dành cho widget */ }
}
Bảng dưới đây cho thấy ai nhìn thấy gì:
| Trường | Ai nhìn thấy | Mục đích sử dụng |
|---|---|---|
|
Mô hình + widget | Dữ liệu có cấu trúc chính (danh sách, đối tượng, tham số) |
|
Mô hình + người dùng (trong văn bản) | Tóm tắt ngắn mà GPT có thể chèn vào câu trả lời của nó |
|
Chỉ widget | Dữ liệu kỹ thuật không cần cho mô hình (ID, phiên bản, khóa, v.v.) |
Tài liệu của Apps SDK nhấn mạnh rằng cặp structuredContent / content đi vào mô hình và có thể được dùng trong các câu trả lời tiếp theo của nó. Trường _meta vẫn ẩn và chỉ có trong widget qua toolResponseMetadata.
Ví dụ ToolOutput cho GiftGenius
Giả sử công cụ suggest_gifts trên server trả về thân dữ liệu như sau:
{
"structuredContent": {
"items": [
{
"id": "boardgame-cozy-strategy",
"title": "Cozy Strategy Board Game",
"price": 39.99,
"currency": "USD",
"score": 0.92,
"tags": ["board_game","strategy","2-4_players"]
}
]
},
"content": "Đã tìm thấy vài ý tưởng quà tặng. Bên dưới, widget sẽ hiển thị chúng dưới dạng thẻ.",
"_meta": {
"giftGenius": {
"catalogVersion": "2025-10-01",
"experimentBucket": "A"
}
}
}
Ở đây structuredContent.items — là phần mà React‑widget của bạn sẽ render; content có thể được mô hình dùng để giải thích cho người dùng điều gì đang diễn ra; _meta.giftGenius — thông tin nội bộ chỉ cần cho UI hoặc phân tích (ví dụ phiên bản catalog dùng cho liên kết).
Chính structuredContent là đối tượng mà bạn sẽ nhìn vào trong JSX thay vì tự tay parse JSON tùy ý từ server.
3. Nhận ToolOutput trong widget: window.openai và hooks
Giờ là lúc chuyển từ nói chuyện JSON sang code. Vậy ToolOutput này đi vào React‑component của bạn như thế nào?
Template của Apps SDK làm việc này theo hai cách chính: hoặc đọc trực tiếp qua window.openai.toolOutput, hoặc tiện hơn là qua các React hook sẵn có (useWidgetProps, useToolOutput và tương tự). Cách khuyến nghị là dùng hooks để không phải chạm vào window.openai trực tiếp và có code an toàn, dễ test hơn.
Cách đơn giản nhất: trực tiếp từ window.openai
Để hiểu, có thể xem phiên bản “raw”:
'use client';
function RawToolOutputDebug() {
const toolOutput = (window as any).openai?.toolOutput;
return (
<pre>{JSON.stringify(toolOutput, null, 2)}</pre>
);
}
Tất nhiên không nên làm vậy trong production, nhưng để debug và “nhìn thử bước đầu” — hoàn toàn ổn.
Phương án thực tế: qua React‑hook
Tiện hơn nhiều là bọc việc truy cập window.openai vào một hook nhỏ và làm việc với đối tượng đã được định kiểu. Giả sử SDK của chúng ta cung cấp hook useWidgetProps, trả về toolOutput và toolResponseMetadata.
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftWidgetRoot() {
const { toolOutput, toolResponseMetadata } = useWidgetProps();
// Tạm thời chỉ hiển thị số lượng quà
const items = toolOutput?.structuredContent?.items ?? [];
return (
<div>
Tìm thấy quà: {items.length}
</div>
);
}
Trong template thực tế, tên hook có thể khác, nhưng ý tưởng luôn giống nhau: SDK lấy dữ liệu từ window.openai và cung cấp cho component của bạn dưới dạng props hoặc qua context. Cách này đơn giản hơn nhiều so với việc mỗi lần lại chọc vào đối tượng toàn cục, và còn cho phép dễ dàng thay thế nguồn dữ liệu trong test (ví dụ, truyền fixture toolOutput).
4. Render quà tặng: từ structuredContent đến JSX
Đến phần hấp dẫn: lấy structuredContent.items và vẽ thành các thẻ. Đừng quên widget của chúng ta là một React client component trong Next.js ('use client' ở đầu file).
Trước hết, định nghĩa kiểu cho một món quà:
type GiftItem = {
id: string;
title: string;
price: number;
currency: string;
tags?: string[];
};
Giờ viết một component thẻ nhỏ:
function GiftCard({ gift }: { gift: GiftItem }) {
return (
<div className="gift-card">
<div className="gift-title">{gift.title}</div>
<div className="gift-price">
{gift.price} {gift.currency}
</div>
</div>
);
}
Và component danh sách lấy dữ liệu từ toolOutput:
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftList() {
const { toolOutput } = useWidgetProps();
const items = (toolOutput?.structuredContent?.items ?? []) as GiftItem[];
return (
<div className="gift-list">
{items.map(gift => (
<GiftCard key={gift.id} gift={gift} />
))}
</div>
);
}
Hãy chú ý mức độ quen thuộc với code React thông thường. “Phép màu” duy nhất là nguồn dữ liệu: thay vì props hay fetch, chúng ta đọc toolOutput từ môi trường chứa của ChatGPT.
Và đúng vậy, không sao nếu ban đầu bạn dùng as GiftItem[]. Sau này có thể định kiểu cẩn thận cho structuredContent qua các kiểu chung với backend (ví dụ dùng Zod / JSON Schema → TS types), nhưng để demo thì vậy là đủ.
5. Các trạng thái UI xung quanh ToolOutput: tải, rỗng, lỗi
Một ứng dụng chỉ hiển thị thẻ khi “gặp may” và im lặng trong các trường hợp khác — không thân thiện cho lắm. Cần xử lý rõ ràng tối thiểu bốn trạng thái: khi công cụ đang chạy, khi chưa có dữ liệu, khi có kết quả và khi có lỗi.
Apps SDK thường cung cấp một số thông tin về trạng thái gọi công cụ: qua danh sách tool invocations (useToolInvocations) hoặc các cờ liên quan đến toolOutput. Trong bài này, chúng ta dùng mô hình đơn giản: nếu chưa có toolOutput — tức là “đang tải”; nếu có nhưng danh sách trống — “rỗng”; nếu có lỗi — “lỗi”.
Để đơn giản, giả sử server khi gặp lỗi sẽ đặt trong structuredContent trường error, và cờ ok ở gốc toolOutput có giá trị false. Sơ đồ này chúng ta đã bàn trong chủ đề trước về server khi thiết kế hợp đồng phản hồi của công cụ.
type ToolOutput = {
ok: boolean;
structuredContent?: {
items?: GiftItem[];
error?: { code: string; message: string };
};
};
Giờ cập nhật component danh sách của chúng ta:
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftListWithStates() {
const { toolOutput } = useWidgetProps() as { toolOutput?: ToolOutput };
if (!toolOutput) {
return <div>Đang chọn quà…</div>;
}
if (!toolOutput.ok) {
const msg = toolOutput.structuredContent?.error?.message
?? 'Không thể lấy gợi ý.';
return <div>Lỗi: {msg}</div>;
}
const items = toolOutput.structuredContent?.items ?? [];
if (items.length === 0) {
return <div>Không tìm thấy quà phù hợp với điều kiện của bạn. Hãy thử thay đổi tham số.</div>;
}
return (
<div className="gift-list">
{items.map(gift => (
<GiftCard key={gift.id} gift={gift} />
))}
</div>
);
}
Đoạn code như vậy đã mang lại trải nghiệm hợp lý cho người dùng:
- Khi công cụ đang chạy, có thể thấy là hệ thống đang làm việc.
- Nếu có lỗi — có thông báo dễ hiểu, thay vì màn hình trống.
- Nếu không tìm thấy gì — chúng ta nói rõ điều gì đã xảy ra, chứ không giả vờ như đó là bình thường.
Trong production, rất có thể bạn sẽ thay “Đang chọn quà…” bằng skeleton hoặc spinner nhỏ. Với lỗi phức tạp, có thể cho GPT cơ hội tạo lời giải thích dễ hiểu. Nhưng cấu trúc cơ bản của component vẫn sẽ như vậy.
6. Dùng _meta và toolResponseMetadata trong UI
Chúng ta đã học cách render dữ liệu chính từ structuredContent và xử lý các trạng thái cơ bản loading/empty/error. Còn một mảnh quan trọng nữa của ToolOutput mà mô hình không dùng — trường _meta.
Quay lại trường _meta. Nó không hiển thị với mô hình, nhưng đi vào widget của bạn dưới tên toolResponseMetadata (tên có thể khác, nhưng ý là thế).
Đây là nơi tuyệt vời cho những thứ không nên ảnh hưởng đến lập luận của GPT nhưng quan trọng cho UI:
- phiên bản catalog hoặc cấu hình;
- ID nội bộ của chiến dịch / thử nghiệm A/B;
- các cờ quy định “nút” nào sẽ hiển thị cho người dùng;
- bất kỳ chi tiết kỹ thuật nào bạn không muốn trộn với dữ liệu miền.
Ví dụ, server có thể trả về _meta như sau:
"_meta": {
"giftGenius": {
"catalogVersion": "2025-10-01",
"showExperimentalBadges": true
}
}
Widget có thể đọc phần này và, chẳng hạn, hiển thị huy hiệu “Ý tưởng mới” trên một số thẻ.
type GiftMeta = {
giftGenius?: {
catalogVersion: string;
showExperimentalBadges?: boolean;
};
};
export function GiftListWithMeta() {
const { toolOutput, toolResponseMetadata } = useWidgetProps() as {
toolOutput?: ToolOutput;
toolResponseMetadata?: GiftMeta;
};
const meta = toolResponseMetadata?.giftGenius;
const items = toolOutput?.structuredContent?.items ?? [];
return (
<div>
{meta && (
<div className="catalog-version">
Danh mục ngày {meta.catalogVersion}
</div>
)}
<div className="gift-list">
{items.map(gift => (
<GiftCard
key={gift.id}
gift={gift}
/>
))}
</div>
</div>
);
}
Mô hình hoàn toàn không liên quan ở đây: nó không biết về catalogVersion và showExperimentalBadges, nhưng UI của bạn có thể dùng chúng tùy ý.
Tài liệu nhấn mạnh sự tách bạch này: dữ liệu quan trọng cho hội thoại và lập luận của mô hình thì đặt vào structuredContent và content; mọi thứ chỉ mang tính kỹ thuật cho UI — để vào _meta / toolResponseMetadata.
7. Vài điều về trạng thái ToolInvocation và “Đang thực thi X…”
Khi công cụ đang chạy, ChatGPT tự hiển thị cho người dùng biết chuyện gì đang diễn ra: ở phần trên của khung chat sẽ có trạng thái như “Đang thực thi GiftGenius…” hoặc “Đang gọi ứng dụng bên ngoài”. Đây không phải là bạn tự in chuỗi ra, mà là môi trường host của ChatGPT phản ứng theo metadata của lần gọi công cụ.
Bên dưới, điều này được mô tả qua các khóa kỹ thuật dạng _meta["openai/toolInvocation/invoking"] và _meta["openai/toolInvocation/invoked"], báo hiệu hành động đang được thực hiện hoặc đã hoàn tất. Các trường này do chính nền tảng dùng để hiển thị trạng thái và thường bạn không cần động vào: SDK lo việc đó phía server.
Về UX, đây là điểm cộng dễ chịu: ngay cả khi widget chưa kịp render skeleton, người dùng đã thấy hệ thống đang làm gì đó. Nhiệm vụ của bạn là bổ sung trạng thái toàn cục đó bằng các trạng thái cục bộ như “Đang chọn quà…” và skeleton trong widget, như chúng ta đã làm ở trên.
8. Kích thước dữ liệu và hiệu năng: đừng nhồi nhét mọi thứ vào structuredContent
Cần nói riêng về chủ đề “rốt cuộc có thể nhét bao nhiêu vào structuredContent”. Trực giác có thể mách bảo: “Mình có cả catalog quà tặng — cứ trả hết, widget tự lọc”. Thực tế không nên làm vậy.
Thứ nhất, structuredContent đi vào ngữ cảnh của mô hình (LLM), và tổng số token là hữu hạn. Tài liệu và các hướng dẫn thực tiễn khuyên mạnh nên giữ dung lượng gọn: đây không phải kho dữ liệu, mà là kết quả của một hành động.
Thứ hai, payload càng lớn thì phản hồi càng chậm và càng dễ chạm giới hạn hoặc phát sinh cắt xén/lỗi bất ngờ.
Cách tiếp cận hợp lý:
- Backend lọc và sắp xếp trước, chỉ trả đúng những gì cần cho bước hiện tại: ví dụ 10–20 món quà tốt nhất.
- Nếu cần các trang tiếp theo, đó là một hành động riêng (tool call mới, ToolOutput mới).
- Với những thứ thuần UI (ví dụ danh sách tất cả tag có thể lọc), có thể dùng _meta, nhưng cũng đừng lạm dụng.
Trong module về trạng thái, chúng ta đã bàn khái niệm “backend là nguồn sự thật, còn widget là bộ nhớ đệm/biểu diễn”. Ở đây cũng vậy: kết quả của công cụ là một “lát cắt” gọn gàng của trạng thái tại thời điểm gọi, chứ không phải bản sao đầy đủ của database.
9. Liên kết với trạng thái widget và cuộc hội thoại tiếp theo
Dù bài này chính thức nói về ToolOutput → UI, cũng không thể bỏ qua mảnh ghép quan trọng khác — widgetState. Chính nó cho phép ghi nhớ lựa chọn của người dùng giữa các lần render và biến widget của bạn từ một “tủ trưng bày” thành một wizard hay “trình cấu hình quà tặng” thực thụ.
Kịch bản điển hình như sau:
- ToolOutput đầu tiên mang đến danh sách quà tặng.
- Người dùng nhấp vào một trong các thẻ.
- Widget ghi vào widgetState món quà đã chọn và, có thể, gửi follow‑up hoặc tool call mới để lấy chi tiết.
- Các ToolOutput tiếp theo dựa trên lựa chọn đó.
Về code, điều này trông như state React thông thường cộng với lời gọi setWidgetState, vốn lưu lựa chọn phía ChatGPT. Khác biệt chỉ ở chỗ trạng thái này có sẵn cho cả mô hình và backend của bạn, nên cần giữ gọn và không lưu secret ở đó.
Chúng ta sẽ phân tích chi tiết trong các module về workflow nhiều bước và follow‑up. Ngay từ bây giờ, hãy nghĩ như sau: ToolOutput cho bạn “lát cắt dữ liệu” từ server, còn widgetState — ngữ cảnh lựa chọn của người dùng xung quanh lát cắt đó.
Các lỗi thường gặp khi làm việc với ToolOutput → UI
Lỗi số 1: “UI render cây JSON thô mà không điều chỉnh cho người dùng”.
Đôi khi để debug, người ta chỉ muốn làm <pre>{JSON.stringify(toolOutput)}</pre> và dừng lại ở đó. Với phát triển thì ổn, nhưng trong production, người dùng sẽ thấy cấu trúc mà bạn tự hào nhưng họ không hiểu. Hãy sớm bọc structuredContent vào các component có ý nghĩa (danh sách, thẻ, bảng), thay vì bắt người dùng đọc phản hồi đã được token hóa từ server.
Lỗi số 2: Trộn lẫn dữ liệu miền và metadata kỹ thuật trong structuredContent.
Code sẽ sạch hơn nhiều nếu tách bạch: “cái cần hiển thị cho mô hình và người dùng” với “cái chỉ cần cho UI và analytics”. Các trường kỹ thuật — cờ thử nghiệm, phiên bản catalog, idempotency key — nên đặt ở _meta / toolResponseMetadata. Khi mọi thứ bị trộn trong structuredContent, việc tiến hóa hợp đồng và test hành vi mô hình sẽ khó hơn.
Lỗi số 3: Thiếu các trạng thái rõ ràng cho tải, rỗng và lỗi.
Một <div></div> trống thay vì “Không tìm thấy gì” hoặc “Đã có lỗi xảy ra” — là con đường ngắn nhất để người dùng kết luận: “App không hoạt động”. Ngay cả placeholder tối thiểu và skeleton đơn giản cũng cải thiện UX đáng kể. Đừng chỉ dựa vào trạng thái hệ thống của ChatGPT “Đang thực thi X…” — widget cũng phải nói rõ nó đang làm gì.
Lỗi số 4: Cố nhét cả thế giới vào một ToolOutput.
Trả về cả catalog sản phẩm, lịch sử người dùng và cả log server trong một structuredContent — là ý tưởng tệ. Điều này chạm giới hạn của mô hình, làm chậm phản hồi và làm UI phức tạp. Hãy trả về đúng lượng dữ liệu cần cho bước hiện tại (trang danh sách, chi tiết phần tử đã chọn, v.v.), và các bước sau là những lần gọi công cụ riêng.
Lỗi số 5: Gắn chặt UI vào dạng phản hồi không ổn định và không có kiểu.
Nếu ở mọi nơi bạn viết toolOutput.structuredContent.items[0].whatever mà không kiểm tra sự tồn tại của trường và không có kiểu, bất kỳ thay đổi schema nào ở server cũng có thể làm widget sập. Hãy đồng bộ kiểu từ JSON Schema (sinh TS types) hoặc ít nhất mô tả thủ công các interface (GiftItem, ToolOutput) và làm việc cẩn thận với các trường optional.
Lỗi số 6: Bỏ qua _meta và làm quá tải mô hình bằng các trường “thừa”.
Thường có cám dỗ nhét mọi thứ vào structuredContent vì “đó là JSON, thừa cũng chẳng sao”. Nhưng mỗi trường đều tăng ngữ cảnh của mô hình, và nhiều thứ mô hình hoàn toàn không cần. Nếu thông tin không nên ảnh hưởng đến lập luận của GPT và không cần trong câu trả lời văn bản, hãy đặt nó ở _meta và chỉ xử lý trong widget.
Lỗi số 7: Truy cập trực tiếp window.openai từ hàng chục component.
Đúng là window.openai.toolOutput hoạt động, nhưng khi nửa ứng dụng đều thò tay vào biến toàn cục, việc debug và test sẽ trở nên ác mộng. Tốt hơn nhiều là bọc một lần vào hook/context (useWidgetProps/useToolOutput) rồi dùng props và đối tượng đã định kiểu. Cách này vừa gọn, vừa dễ thay bằng fixture trong Storybook/test.
GO TO FULL VERSION