1. Tại sao widget cần kiến trúc i18n riêng trong ChatGPT App
Trong ứng dụng Next.js thông thường, bạn thường dựa vào URL (/en/..., /ru/...) hoặc router để gắn ngôn ngữ với route. Với widget ChatGPT thì thú vị hơn: UI của bạn sống bên trong iframe sandbox, và URL không do bạn kiểm soát. Ngôn ngữ đến dưới dạng state từ ChatGPT, ví dụ qua openai/locale hoặc hook như useOpenAiGlobal('locale'), chứ không phải từ thanh địa chỉ.
Thành ra tình huống hơi khác thường. Về phía Next.js, widget của bạn về điều kiện chỉ là một trang /widget, nhưng bên trong nó phải biết tự render ở bất kỳ ngôn ngữ nào mà nền tảng chỉ định. Việc chuyển ngôn ngữ không bằng điều hướng, mà bằng state. Điều này tự động hướng đến kiến trúc “một UI, nhiều dictionary” và càng nhấn mạnh: nhét chuỗi vào code là một ngõ cụt.
Thêm nữa, trong cùng một cuộc đối thoại, ChatGPT có thể chạy App của bạn cho người dùng ở các quốc gia khác nhau. Bạn không thể “quyết một lần rằng App dùng tiếng Nga” rồi bỏ qua. Widget phải dễ dàng khởi tạo lại theo locale mới mà không đổi business logic — đó là lý do cần một lớp i18n gọn gàng.
2. Nguyên tắc chính: không được có chuỗi trong code
Tóm gọn triết lý bản địa hóa UI như sau: các React component không cần text thực, chúng cần key.
Thay vì:
// TỆ: chuỗi bị hardcode trong component
<button>Chọn quà</button>
widget nên trông như sau:
// TỐT: component chỉ biết key
<button>{t('buttons.pick_gift')}</button>
Còn các chuỗi thực “Chọn quà” và “Pick a gift” được lưu trong dictionary ru.json và en.json.
Tại sao phải phức tạp thế, trong khi có thể chỉ cần if (locale === 'ru')?
Thứ nhất, khả năng mở rộng. Vừa thêm ngôn ngữ thứ ba là if/else biến thành mớ hỗn độn. Thứ hai, tách biệt trách nhiệm. Biên dịch viên hay product có thể thay đổi text trong các file JSON mà không đụng đến code, còn developer có thể refactor component mà không lo vô tình làm hỏng nửa UI copy. Thứ ba, tính nhất quán: một nguồn sự thật duy nhất cho text giúp tránh cảnh nút này “Mua”, nút kia “Thanh toán” chỉ vì tác giả component đặt tên theo cảm hứng.
Trong thế giới ChatGPT App điều này càng hữu ích: đôi khi bạn muốn tạo bản dịch qua LLM rồi bổ sung vào dictionary. Lưu tất cả text trong JSON tiện hơn rất nhiều so với rải chúng trong component.
3. Cấu trúc dictionary cho widget GiftGenius
Tiếp tục phát triển ứng dụng minh họa GiftGenius — widget gợi ý quà tặng. Ta cần tối thiểu hai ngôn ngữ: ru và en. Tạo cấu trúc cơ bản:
/app
/widget
GiftWidget.tsx
/locales
/en
widget.json
/ru
widget.json
Nội dung đơn giản của dictionary locales/en/widget.json:
{
"title": "GiftGenius",
"forms": {
"recipient": {
"label": "Recipient",
"placeholder": "Who is this gift for?"
},
"budget": {
"label": "Budget",
"placeholder": "For example, 50"
}
},
"buttons": {
"pick_gift": "Find gifts",
"try_again": "Try again"
},
"errors": {
"no_gifts": "No gifts found for your criteria."
}
}
Và locales/ru/widget.json tương ứng:
{
"title": "GiftGenius",
"forms": {
"recipient": {
"label": "Người nhận",
"placeholder": "Tặng cho ai?"
},
"budget": {
"label": "Ngân sách",
"placeholder": "Ví dụ: 50"
}
},
"buttons": {
"pick_gift": "Tìm quà",
"try_again": "Thử lại"
},
"errors": {
"no_gifts": "Không tìm thấy quà phù hợp với tiêu chí của bạn."
}
}
Lưu ý cấu trúc key giống hệt cho cả hai ngôn ngữ. Điều này rất quan trọng: component dựa vào key chứ không vào chuỗi cụ thể. Nếu bạn quên thêm errors.no_gifts ở một ngôn ngữ, bạn sẽ nhận được lỗi rõ ràng thay vì một UI bán dịch.
Trong dự án thực tế, nên chia dictionary theo miền: widget, checkout, errors, v.v. Với ứng dụng học tập, một file mỗi ngôn ngữ là đủ để tránh phức tạp.
4. Lấy locale ở widget Apps SDK từ đâu
Trong ứng dụng trình duyệt cổ điển, bạn có thể xem navigator.language. Với widget ChatGPT thì làm vậy được nhưng không cần: ChatGPT đã xác định locale ưa thích cho người dùng và truyền nó vào ngữ cảnh Apps SDK. Có thể là trường locale trong window.openai, có thể đọc trực tiếp hoặc qua hook tiện như useOpenAiGlobal('locale').
Với các starter của Apps SDK, bạn thường có component gốc của widget, nơi dữ liệu global từ ChatGPT khả dụng. Ví dụ:
"use client";
import { useOpenAiGlobal } from "openai-apps-sdk/react";
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
// ...
}
Ví dụ trên chỉ minh họa; API chính xác phụ thuộc phiên bản SDK, nhưng ý tưởng chung là đúng: locale là sự thật bên ngoài, đến từ ChatGPT, không phải từ trình duyệt người dùng.
Vùng (userLocation) cũng được truyền qua _meta["openai/userLocation"]. Ta sẽ cần nó sau khi định dạng giá và xét đến tiền tệ. Với text thì chỉ cần locale — thường theo định dạng BCP‑47 (en, en-US, ru-RU, v.v.).
5. Viết lớp i18n tối thiểu: context + hook useT
Để widget tự đủ và không biến thành giáo trình react-i18next, ta triển khai một lớp i18n nhẹ nhàng của riêng mình. Với widget ChatGPT nhỏ như vậy là dư sức, và nguyên lý giống các thư viện phổ biến.
Đầu tiên mô tả kiểu và tạo context trong app/widget/i18n.tsx:
"use client";
import React, { createContext, useContext } from "react";
type Messages = Record<string, any>;
type I18nContextValue = {
locale: string;
messages: Messages;
};
const I18nContext = createContext<I18nContextValue | null>(null);
Bây giờ tạo provider nhận locale và dictionary:
type Props = {
locale: string;
messages: Messages;
children: React.ReactNode;
};
export function I18nProvider({ locale, messages, children }: Props) {
return (
<I18nContext.Provider value={{ locale, messages }}>
{children}
</I18nContext.Provider>
);
}
Phần thú vị nhất — hook useT để lấy chuỗi theo key:
export function useT() {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useT must be used within I18nProvider");
function t(path: string): string {
return path.split(".").reduce((obj: any, part) => obj?.[part], ctx.messages)
?? path;
}
return { t, locale: ctx.locale };
}
Ta hỗ trợ key lồng như forms.recipient.label và khi thiếu bản dịch sẽ trả về chính key — hữu ích hơn là âm thầm hiển thị rỗng.
6. Nhúng i18n provider vào component gốc của widget
Trước đó ta đã thấy GiftWidgetRoot chỉ đọc locale từ useOpenAiGlobal. Giờ dùng I18nProvider trong component gốc này và thêm tải dictionary. Giả sử trước đó nó trông như sau:
"use client";
export function GiftWidgetRoot() {
return (
<div>
<h1>GiftGenius</h1>
{/* biểu mẫu và kết quả */}
</div>
);
}
Thêm tải dictionary và provider. Đơn giản dùng require/import đồng bộ theo locale, nhưng trong Next.js 16 bạn có thể import bất đồng bộ (qua dynamic import) nếu dictionary lớn.
"use client";
import { useOpenAiGlobal } from "openai-apps-sdk/react";
import { I18nProvider } from "./i18n";
import { GiftWidget } from "./GiftWidget";
function loadMessages(locale: string) {
if (locale.startsWith("ru")) {
return require("/locales/ru/widget.json");
}
return require("/locales/en/widget.json");
}
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
const messages = loadMessages(locale);
return (
<I18nProvider locale={locale} messages={messages}>
<GiftWidget />
</I18nProvider>
);
}
Component GiftWidget giờ không nghĩ gì về ngôn ngữ, nó chỉ biết có hàm t:
"use client";
import { useT } from "./i18n";
export function GiftWidget() {
const { t } = useT();
return (
<div>
<h1>{t("title")}</h1>
<label>{t("forms.recipient.label")}</label>
{/* phần UI còn lại */}
<div>
);
}
Nếu ngày mai ChatGPT tạo widget với locale = "de-DE", bạn chỉ cần thêm locales/de/widget.json và một dòng trong loadMessages, không đụng phần code còn lại. Đây chính là mục đích của toàn bộ cách làm.
7. Các định dạng có thể bản địa hóa: số, ngày, tiền tệ
Ta đã đưa text vào dictionary và bọc widget bằng I18nProvider. Nhưng text chỉ là nửa UX: người dùng ở Mỹ trông đợi 12/31/2025, còn ở Đức là 31.12.2025. Tương tự với số và tiền tệ. Hiển thị cho người dùng ở Nga giá “1,234.56 USD” là cách hay để cho thấy “trợ lý” của bạn thực ra không tinh ý lắm.
May mắn thay, trong trình duyệt (và sandbox của ChatGPT) có sẵn API chuẩn Intl. Thêm vào i18n.tsx vài tiện ích dùng locale hiện tại:
export function useFormatters() {
const { locale } = useT();
const formatCurrency = (value: number, currency: string) =>
new Intl.NumberFormat(locale, {
style: "currency",
currency,
maximumFractionDigits: 2,
}).format(value);
const formatDate = (date: Date) =>
new Intl.DateTimeFormat(locale).format(date);
return { formatCurrency, formatDate };
}
Bây giờ trong component hiển thị ngân sách hoặc giá quà (giả sử ta đã nhận từ MCP server với currency):
import { useFormatters } from "./i18n";
type GiftCardProps = {
name: string;
price: number;
currency: string;
};
export function GiftCard({ name, price, currency }: GiftCardProps) {
const { formatCurrency } = useFormatters();
return (
<div>
<div>{name}</div>
<div>{formatCurrency(price, currency)}</div>
</div>
);
}
Nếu muốn “thông minh” hơn (ví dụ chọn tiền tệ dựa trên userLocation), bạn có thể kết hợp locale và vùng. Về kiến trúc, đây là sự tiếp nối với điều bạn đã bàn cho MCP‑Gateway: locale quyết định ngôn ngữ text, còn userLocation quyết định business rule và tiền tệ.
8. Phản ứng khi đổi ngôn ngữ: nếu ChatGPT đổi locale ngay trong phiên
Trên web thông thường, người dùng tự bấm “EN / RU” và bạn biết rõ khi nào đổi ngôn ngữ. Trong ChatGPT App, về lý thuyết model có thể quyết định rằng người dùng tiện ngôn ngữ khác (hoặc người dùng đổi ngôn ngữ giao diện trong cài đặt), và openai/locale thay đổi.
Nếu SDK cho tín hiệu reactive (qua hook hoặc event), pattern code sẽ như sau:
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
const messages = useMemo(() => loadMessages(locale), [locale]);
return (
<I18nProvider locale={locale} messages={messages}>
<GiftWidget />
</I18nProvider>
);
}
Ở đây loadMessages sẽ chạy lại khi locale đổi, và toàn bộ UI tự động render lại với bản dịch mới. Trong đa số kịch bản thực tế, locale ổn định trong phiên, nhưng xây đúng mô hình reactive vẫn rất hữu ích.
9. Một chút về chuỗi phức tạp: placeholder và số nhiều
Reactive theo locale đã rõ. Câu hỏi tiếp theo: làm gì với phần text động — số lượng, tên người, v.v.? Trong app quà tặng có thể là câu kiểu “Đã tìm thấy 3 món quà cho Masha”.
Cách đơn giản nhất là hỗ trợ placeholder trong t() và truyền giá trị khi render. Để làm vậy, sửa useT để nhận đối số thứ hai là object giá trị:
type Values = Record<string, string | number>;
export function useT() {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useT must be used within I18nProvider");
function t(path: string, values?: Values): string {
let text =
path.split(".").reduce((obj: any, part) => obj?.[part], ctx.messages) ??
path;
if (values) {
Object.entries(values).forEach(([key, value]) => {
text = text.replace(`{{${key}}}`, String(value));
});
}
return text;
}
return { t, locale: ctx.locale };
}
Giờ thêm chuỗi vào widget.json:
"results": {
"summary": "Found {{count}} gifts for {{name}}"
}
Và sử dụng:
const { t } = useT();
<p>{t("results.summary", { count, name: recipientName })}</p>
Xử lý số nhiều có thể theo nhiều cách: hoặc tạo vài key (one, few, many) và tự chọn, hoặc dùng thư viện như react-intl/i18next với hỗ trợ đầy đủ plural rules. Với widget học tập, chọn thủ công theo khoảng (ví dụ, if count === 1, if count < 5, v.v.) là chấp nhận được.
10. Đặt i18n ở đâu trong cấu trúc Next.js template của Apps SDK
Với Next.js 16 và template chính thức của Apps SDK, widget của bạn thường là entrypoint chuyên biệt trong app/ (ví dụ, app/widget/page.tsx hoặc một component riêng mà Apps SDK render trong ChatGPT).
Pattern điển hình:
// app/widget/page.tsx
"use client";
import { GiftWidgetRoot } from "./GiftWidgetRoot";
export default function WidgetPage() {
return <GiftWidgetRoot />;
}
Lớp i18n sống hoàn toàn ở phía client — mọi thứ ta viết ở trên đều là client components. Quan trọng là trong môi trường ChatGPT bạn vốn render ở client trong iframe, nên các pattern SSR‑i18n (HTML được bản địa hóa trên server) có thể tạm bỏ qua. Việc này đơn giản hóa rất nhiều: bạn làm như một SPA bình thường, chỉ khác là thay vì navigator.language thì dùng openai/locale.
Nếu cần chia sẻ bản dịch giữa nhiều widget của cùng một App (ví dụ, wizard chính và “widget inline nhỏ”), có thể tách I18nProvider thành module riêng để tái sử dụng.
11. Mini testing cho bản địa hóa
Ngay khi có lớp i18n, bạn nên test nó riêng — nếu không, bất kỳ lỗi gõ key nào cũng biến thành “UI bán dịch”. Đã xây kiến trúc thì đừng tiếc công kiểm thử.
Thứ nhất, đáng viết unit test đơn giản cho loadMessages và useT (dùng React Testing Library hoặc thậm chí không cần React — chỉ test hàm t). Các test này bắt lỗi gõ key và hữu ích nếu bạn hay biên dịch viên lỡ xóa nhánh cần thiết trong dictionary.
Thứ hai, tiện có chế độ “chạy local” widget ngoài ChatGPT, nơi bạn có thể chỉ định locale qua query param hoặc nút trong UI. Hữu ích cho bạn và QA: không ai bắt buộc phải bật cả Dev Mode và ChatGPT chỉ để xem bản dịch tiếng Đức ra sao. Với những test cơ bản và chạy local qua các locale, bạn sẽ yên tâm phát triển UI, text và tiến xa hơn đến bản địa hóa mô tả tools.
Mối liên hệ với hành vi của model
Ta sẽ đi sâu vào bản địa hóa description của công cụ ở bài sau, nhưng ngay bây giờ cần thấy sự liên kết: widget và công cụ phải nói cùng ngôn ngữ với người dùng. Bạn đã xây UI tự điều chỉnh theo openai/locale. MCP server theo cùng tín hiệu sẽ chọn đúng catalog và text. Hợp lý khi mô tả suggest_gifts và các trường recipient, budget cũng được giải thích cho model bằng ngôn ngữ của người dùng — điều này giảm tool‑call kỳ lạ và đối số sai.
Tức là kiến trúc i18n của widget không chỉ là “làm đẹp”. Nó là viên gạch đầu tiên trong hệ thống chung, nơi lớp UI, lớp MCP và model dùng chung một ngữ cảnh locale.
12. Lỗi thường gặp khi bản địa hóa widget
Lỗi №1: chuỗi hardcode trực tiếp trong JSX.
Câu chuyện rất thường gặp: widget bắt đầu như prototype nhanh với một ngôn ngữ, rồi bất ngờ “cần thêm tiếng Anh”. Kết quả là UI chi chít chuỗi tiếng Nga, và cố gắng thêm tiếng Anh biến thành tìm‑và‑thay thế toàn dự án. Càng sớm tạo dictionary và hàm t(), bạn càng ít rắc rối về sau.
Lỗi №2: if (locale === 'ru') ở khắp nơi.
Điều kiện như vậy đôi khi tưởng “giải pháp nhanh”, nhưng vỡ ngay khi có ngôn ngữ thứ ba hoặc biến thể như ru-RU, ru, ru-UA. Tốt hơn là viết một lần loadMessages(locale) kèm chuẩn hóa (locale.split('-')[0]) và thôi không bận tâm nữa, thay vì rải kiểm tra khắp code.
Lỗi №3: trộn business logic và text.
Đôi khi developer tạo trong component điều kiện phức tạp vừa cho nhánh nghiệp vụ vừa chọn text. Ví dụ “nếu không có quà thì hiển thị câu này, nếu ngân sách nhỏ thì câu khác”. Kết quả là sửa copy khó, logic loang lổ, và bản dịch chui vào TypeScript. Tốt hơn khi component chỉ trả về key cho dictionary (errors.no_gifts, errors.budget_too_low), còn text chỉnh riêng.
Lỗi №4: không định dạng ngày/tiền theo locale.
Hiển thị cho người dùng ở Đức giá $1,234.56 thay vì 1.234,56 $ — không phải bug, mà là anti‑pattern UX. Nhưng người dùng sẽ cảm nhận “dịch vụ này không dành cho tôi”. Rất dễ quên Intl.NumberFormat và Intl.DateTimeFormat nếu bạn quen sống ở một vùng. Vì vậy nên tách formatter vào hook như useFormatters() và luôn dùng chúng thay cho việc nối chuỗi thủ công.
Lỗi №5: không tính đến khả năng đổi locale.
Một số developer đọc locale một lần khi mount và xem nó là hằng số. Phần lớn trường hợp sẽ ổn, nhưng nếu ChatGPT hay nền tảng đổi locale (ví dụ người dùng đổi ngôn ngữ giao diện), widget của bạn sẽ ở lại ngôn ngữ cũ. Đúng hơn là xem locale như một phần của state reactive và gắn useMemo/useEffect vào nó.
Lỗi №6: lưu cấu trúc dictionary khác nhau giữa các ngôn ngữ.
Đôi khi bản dịch ngôn ngữ này giao cho một người, ngôn ngữ khác cho người khác, và kết quả widget.en.json và widget.ru.json lệch cấu trúc. Một bên có forms.budget.placeholder, bên kia chỉ có forms.budget.label. Lúc chạy sẽ thành undefined và lỗi lạ. Luôn giữ một file “chuẩn” (thường là tiếng Anh) để các ngôn ngữ khác kế thừa cấu trúc. Bạn thậm chí có thể viết script kiểm tra tính tương ứng của key khi tạo dictionary mới.
Lỗi №7: cố gắng giải quyết mọi thứ ngay bằng i18n framework nặng.
Những giải pháp phổ biến như react-i18next hay next-intl mạnh mẽ và hữu ích, nhưng với widget ChatGPT nhỏ chúng có thể quá tay. Thường dễ hơn là bắt đầu với lớp nhẹ của riêng mình (I18nProvider, useT, dictionary trong JSON), rồi khi ứng dụng lớn lên thật sự cần plural phức tạp, ICU format, v.v., hãy chuyển sang thư viện đầy đủ.
GO TO FULL VERSION