1. 為什麼 ChatGPT App 的小工具需要獨立的 i18n 架構
在一般的 Next.js 應用中,你常會依賴 URL(例如 /en/...、/ru/...)或路由器,把語言綁定到路由上。到了 ChatGPT 小工具則更有趣:你的 UI 活在沙盒裡的 iframe 中,而 URL 並不由你控制。語言是以狀態的形式由 ChatGPT 傳入,例如透過 openai/locale 或像 useOpenAiGlobal('locale') 這樣的 hook,而不是從位址列來的。
於是出現一種不尋常的情況。對 Next.js 而言,你的小工具大致上是一個頁面 /widget,但它在內部必須能用平台指定的任何語言進行渲染。語言切換不是靠導航,而是靠狀態。這自然推向「單一 UI、多份字典」的架構,也再次強調:把字串放在程式碼裡是死路一條。
此外,在同一個 ChatGPT 對話中,平台可能會為不同國家的使用者啟動你的 App。你不能「一次決定這個 App 只支援俄文」就不管了。小工具必須能在不改動商業邏輯的情況下,輕鬆針對新的 locale 重新初始化——這正是我們需要仔細設計 i18n 中介層的原因。
2. 核心原則:程式碼裡不應該有實際文字
如果要用一句話說明 UI 在地化的理念,就是:React 元件不需要真實文案,它們需要的是鍵(key)。
不要這樣:
// 不好:在元件裡硬寫死字串
<button>挑選禮物</button>
小工具應該長這樣:
// 好:元件只知道鍵
<button>{t('buttons.pick_gift')}</button>
而實際字串「挑選禮物」與「Pick a gift」分別存放在字典 ru.json 與 en.json 中。
為什麼不直接寫個 if(locale === 'ru')就好?
首先是可擴充性。一旦你要加入第三種語言,if/else 就會失控。其次是職責分離。翻譯或產品可以在 JSON 檔中修改文字而不碰程式碼;開發者則能重構元件而不會不小心弄壞半個 UI 文案。第三是一致性:用單一真實來源管理文本,可以避免同一個動作在不同地方出現「購買」與「付款」這種風格不一致的字樣。
在 ChatGPT App 的世界裡這更有用:有時你會想先用 LLM 產生翻譯再加入字典。把所有文本放在 JSON 檔要比把它們散落在元件裡方便得多。
3. 為 GiftGenius 小工具規劃字典結構
繼續我們的教學應用 GiftGenius——禮物挑選小工具。現在至少需要兩種語言:ru 與 en。先建立基本結構:
/app
/widget
GiftWidget.tsx
/locales
/en
widget.json
/ru
widget.json
最簡單的字典 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."
}
}
以及對應的 locales/ru/widget.json:
{
"title": "GiftGenius",
"forms": {
"recipient": {
"label": "收禮者",
"placeholder": "要替誰找禮物?"
},
"budget": {
"label": "預算",
"placeholder": "例如 50"
}
},
"buttons": {
"pick_gift": "找禮物",
"try_again": "再試一次"
},
"errors": {
"no_gifts": "找不到符合條件的禮物。"
}
}
請注意兩種語言的鍵結構是完全一致的。這點至關重要:元件依賴的是鍵,而不是具體字串。如果你在某種語言中忘了加入 errors.no_gifts,你會得到清楚的錯誤,而不是半翻的 UI。
在實際專案中,把字典按領域拆分是合理的:widget、checkout、errors 等。在教學應用中,每個語言一個檔就足夠,避免複雜化。
4. 在 Apps SDK 小工具裡從哪裡取得 locale
在傳統的瀏覽器應用中,你可能會讀取 navigator.language。在 ChatGPT 小工具裡可以這樣做,但沒必要:ChatGPT 已替使用者算好偏好的地區語系,並把它傳入 Apps SDK 的上下文。這可能是 window.openai 上的 locale 欄位,你可以直接讀取,或透過像 useOpenAiGlobal('locale') 這樣的便利 hook。
在 Apps SDK 的起始範本中,你通常會有小工具的根元件,可以取得 ChatGPT 的全域資料。大致如下:
"use client";
import { useOpenAiGlobal } from "openai-apps-sdk/react";
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
// ...
}
上面的例子只是示意;確切 API 取決於 SDK 的版本,但核心觀念是正確的:locale 是從 ChatGPT 傳入的外部真相,而不是來自使用者的瀏覽器。
區域(userLocation)也會透過 _meta["openai/userLocation"] 傳入。稍後在格式化價格與考量幣別時會用到。對文本來說,locale 就足夠了——通常以 BCP‑47 格式傳入(en、en-US、ru-RU 等)。
5. 撰寫最小 i18n 層:Context + useT hook
為了讓小工具自給自足、同時不變成一篇 react-i18next 教科書,我們自己實作一個輕量的 i18n 層。對小型的 ChatGPT 小工具來說綽綽有餘,原理也和熱門函式庫相同。
先在 app/widget/i18n.tsx 定義型別並建立 Context:
"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);
接著寫一個 provider,接收 locale 與字典:
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>
);
}
重頭戲是 useT hook,用來依鍵取字串:
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 };
}
我們支援像 forms.recipient.label 這樣的巢狀鍵;若缺少翻譯就回傳鍵本身——總比靜默顯示空字串有用。
6. 把 i18n provider 嵌入小工具根元件
前面我們已經看到只負責讀取 locale 的 GiftWidgetRoot。現在在這個根元件中使用 I18nProvider 並加入字典載入。假設它原本長這樣:
"use client";
export function GiftWidgetRoot() {
return (
<div>
<h1>GiftGenius</h1>
{/* 表單與結果 */}
</div>
);
}
加入字典載入與 provider。為了簡單,依 locale 使用同步的 require/import,但在 Next.js 16 若字典很大也可以使用非同步載入(透過 dynamic import)。
"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>
);
}
GiftWidget 元件現在完全不需要思考語言,只知道有個 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>
{/* 其他 UI */}
</div>
);
}
如果明天 ChatGPT 建立的 widget 帶入的 locale 是 = "de-DE",你只要新增 locales/de/widget.json 並在 loadMessages 補上一行,而不必改動其餘程式碼。這正是整套做法的價值所在。
7. 可在地化的格式:數字、日期、貨幣
我們已經把文本抽到字典,並以 I18nProvider 包住小工具。但文本只是一半的使用體驗:美國使用者期望看到 12/31/2025,德國使用者則期望 31.12.2025。數字與貨幣亦然。若向台灣使用者顯示「1,234.56 USD」,很容易讓人覺得你的「智慧」助理其實不怎麼用心。
幸好在瀏覽器(以及 ChatGPT 沙盒)中可以使用標準的 Intl API。我們在 i18n.tsx 加入幾個會使用目前 locale 的工具函式:
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 };
}
接著在顯示預算或禮物價格的元件裡使用它(假設我們已經從 MCP 伺服器取得並帶有 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>
);
}
如果想讓格式化更「聰明」(例如根據 userLocation 選擇幣別),可以同時考量 locale 與地區。這在架構上延續了你對 MCP Gateway 的設計:locale 影響文本語言,userLocation 影響商務規則與幣別。
8. 對語言切換的反應:如果 ChatGPT 途中改了 locale
在一般網站裡,使用者自己按下「EN / RU」,你知道什麼時候要換語言。在 ChatGPT App 中,模型理論上可能決定使用者用別種語言更方便(或使用者在設定中改了介面語言),此時 openai/locale 會改變。
如果 SDK 提供你反應式的訊號(透過 hook 或事件),程式碼模式大致如下:
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
const messages = useMemo(() => loadMessages(locale), [locale]);
return (
<I18nProvider locale={locale} messages={messages}>
<GiftWidget />
</I18nProvider>
);
}
這裡 loadMessages 會在 locale 變更時重新執行,整個 UI 也會自動以新翻譯重渲染。在多數場景中,locale 在同一工作階段內是穩定的,但設計正確的反應式模型依然是有益的。
9. 談談較複雜的字串:佔位符與複數
我們已經處理了對 locale 的反應。下一個自然問題是:如何處理文本中的動態部分——數量、名稱等?在禮物應用中,可能會有「找到 3 個送給 Masha 的禮物」這樣的語句。
最簡單的辦法是讓 t() 支援佔位符,並在執行時帶入數值。為此,我們把 useT 改成可接受第二個參數(值物件):
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 };
}
現在在 widget.json 中新增一條字串:
"results": {
"summary": "Found {{count}} gifts for {{name}}"
}
並這樣使用:
const { t } = useT();
<p>{t("results.summary", { count, name: recipientName })}</p>
複數處理有多種做法:可以建立多個鍵(one、few、many)並手動選取,也可以接入像 react-intl/i18next 這樣的函式庫,享受完整的複數規則支援。對教學小工具而言,按區間手動選(例如 if count === 1、if count < 5 等)也完全可以接受。
10. 在 Next.js Apps SDK 模板中該把 i18n 放哪裡
就 Next.js 16 與官方 Apps SDK 範本而言,你的小工具通常是在 app/ 下面的一個專用 entrypoint(例如 app/widget/page.tsx),或是由 Apps SDK 在 ChatGPT 中渲染的一個獨立元件。
典型模式:
// app/widget/page.tsx
"use client";
import { GiftWidgetRoot } from "./GiftWidgetRoot";
export default function WidgetPage() {
return <GiftWidgetRoot />;
}
i18n 層完全運作在用戶端——以上我們寫的都是 client components。重點是,在 ChatGPT 環境中,你的渲染本來就發生在 iframe 的用戶端,因此可以暫時把傳統的 SSR i18n 模式(在伺服器輸出已在地化的 HTML)放下。這大幅簡化了工作:就像操作一般的 SPA,只是你用的是 openai/locale 而不是 navigator.language。
若你需要在同一個 App 的多個小工具之間共享翻譯(例如主精靈與「小型 inline 小工具」),可以把 I18nProvider 抽到獨立模組並重複使用。
11. 小規模測試在地化
一旦系統裡有了 i18n 層,就該開始對它做測試——否則任何鍵的拼寫錯誤都會變成「半翻的 UI」。既然已經設計了架構,那就好好驗證吧。
首先,建議為 loadMessages 與 useT 寫些簡單的單元測試(可以用 React Testing Library,甚至不使用 React,直接測 t 函式)。這類測試能抓到鍵的拼寫問題,也能在你或翻譯人員不小心刪掉字典分支時提供協助。
其次,規劃一個在 ChatGPT 之外的「本機執行」模式,讓你可以在 UI 中透過 query 參數或按鈕強制指定 locale。這對你與 QA 都很方便:沒有人需要啟動整個 Dev Mode 與 ChatGPT 只為了看德文翻譯長什麼樣。有了這些基本測試與跨 locale 的本機執行,你在持續開發 UI 與文本、進一步在地化 tools 的描述時也會更踏實。
這與模型行為有何關聯
對 tools 的 descriptions 進行深入在地化會留到下一講,但現在就能看到關聯:小工具與工具都應該用使用者的語言與之互動。你已經建立會依 openai/locale 調整的 UI;MCP 伺服器也會依同一訊號選擇正確的目錄與文本。合乎邏輯地,suggest_gifts 的描述、recipient 與 budget 欄位也應該用使用者的語言對模型解釋——這能降低奇怪的 tool‑call 與不正確參數的機率。
也就是說,小工具的 i18n 架構不只是裝飾。它是整體系統的第一塊基石:UI 層、MCP 層與模型共用同一個語系脈絡。
12. 在地化小工具時的常見錯誤
錯誤 1:在 JSX 內硬寫字串。
非常常見:小工具先以單一語言快速打樣,突然又變成「還要英文」。結果 UI 充滿原本語言的字串,之後想加英文就淪為整個專案的全域搜尋取代。越早建立字典與 t() 函式,後面問題就越少。
錯誤 2:在每個地方寫 if(locale === 'ru')。
這種條件看似「快速解」,但一出現第三種語言或像 ru-RU、ru、ru-UA 之類的變體就立即崩壞。最好一次寫好 loadMessages(locale) 並做正規化(locale.split('-')[0]),之後就不必把檢查分散在整個程式碼中。
錯誤 3:把商業邏輯與文本混在一起。
有時開發者會在元件中同時處理商業分支與文本選擇,例如「如果沒有禮物,就顯示這句;如果預算太小,就顯示另一句」。最後文案很難改、邏輯擴散、翻譯也跑進 TypeScript。更好的作法是讓元件只回報字典鍵(errors.no_gifts、errors.budget_too_low),而把文本分開編輯。
錯誤 4:忽略依 locale 格式化日期/貨幣。
對德國使用者顯示 $1,234.56 而不是 1.234,56 $,不算 bug,但卻是 UX 反模式。使用者會覺得「這服務不是為我做的」。如果你長期活在同一地區,很容易忘了 Intl.NumberFormat 與 Intl.DateTimeFormat。因此,最好把格式器抽成像 useFormatters() 的 hook,永遠用它而不是手動組字串。
錯誤 5:沒有考慮 locale 可能在執行期間變更。
有些開發者在掛載時讀一次 locale 就當它是常數。多數情況下沒問題,但若 ChatGPT 或平台真的換了語系(例如使用者切換介面語言),你的小工具就會停留在舊語言。把 locale 視為反應式狀態的一部分,並以 useMemo/useEffect 綁定它才是正確的做法。
錯誤 6:不同語言使用不同結構的字典。
有時不同語言交給不同人負責,結果 widget.en.json 與 widget.ru.json 的結構分岔。在一個檔中有 forms.budget.placeholder,另一個只有 forms.budget.label。在執行期就會變成 undefined 與奇怪的錯誤。務必維持一個「標準檔」(通常是英文),其他語言在結構上與之對齊。甚至可以寫腳本檢查鍵的對應情況,輔助產生新字典。
錯誤 7:一開始就上過重的 i18n 框架,想一次解決所有問題。
像 react-i18next 或 next-intl 這類熱門方案很強大,但對小型 ChatGPT 小工具可能太重。多半從輕量自製層開始(I18nProvider、useT、JSON 字典)會更簡單;當應用成長,真的需要複雜的複數規則、ICU 格式等,再遷移到完整函式庫也不遲。
GO TO FULL VERSION