CodeGym /課程 /ChatGPT Apps /小工具在地化:Next + React(i18n 架構)

小工具在地化:Next + React(i18n 架構)

ChatGPT Apps
等級 9 , 課堂 2
開放

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.jsonen.json 中。

為什麼不直接寫個 iflocale === 'ru')就好?

首先是可擴充性。一旦你要加入第三種語言,if/else 就會失控。其次是職責分離。翻譯或產品可以在 JSON 檔中修改文字而不碰程式碼;開發者則能重構元件而不會不小心弄壞半個 UI 文案。第三是一致性:用單一真實來源管理文本,可以避免同一個動作在不同地方出現「購買」與「付款」這種風格不一致的字樣。

在 ChatGPT App 的世界裡這更有用:有時你會想先用 LLM 產生翻譯再加入字典。把所有文本放在 JSON 檔要比把它們散落在元件裡方便得多。

3. 為 GiftGenius 小工具規劃字典結構

繼續我們的教學應用 GiftGenius——禮物挑選小工具。現在至少需要兩種語言:ruen。先建立基本結構:

/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。

在實際專案中,把字典按領域拆分是合理的:widgetcheckouterrors 等。在教學應用中,每個語言一個檔就足夠,避免複雜化。

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 格式傳入(enen-USru-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 嵌入小工具根元件

前面我們已經看到只負責讀取 localeGiftWidgetRoot。現在在這個根元件中使用 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>

複數處理有多種做法:可以建立多個鍵(onefewmany)並手動選取,也可以接入像 react-intl/i18next 這樣的函式庫,享受完整的複數規則支援。對教學小工具而言,按區間手動選(例如 if count === 1if 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」。既然已經設計了架構,那就好好驗證吧。

首先,建議為 loadMessagesuseT 寫些簡單的單元測試(可以用 React Testing Library,甚至不使用 React,直接測 t 函式)。這類測試能抓到鍵的拼寫問題,也能在你或翻譯人員不小心刪掉字典分支時提供協助。

其次,規劃一個在 ChatGPT 之外的「本機執行」模式,讓你可以在 UI 中透過 query 參數或按鈕強制指定 locale。這對你與 QA 都很方便:沒有人需要啟動整個 Dev Mode 與 ChatGPT 只為了看德文翻譯長什麼樣。有了這些基本測試與跨 locale 的本機執行,你在持續開發 UI 與文本、進一步在地化 tools 的描述時也會更踏實。

這與模型行為有何關聯

對 tools 的 descriptions 進行深入在地化會留到下一講,但現在就能看到關聯:小工具與工具都應該用使用者的語言與之互動。你已經建立會依 openai/locale 調整的 UI;MCP 伺服器也會依同一訊號選擇正確的目錄與文本。合乎邏輯地,suggest_gifts 的描述、recipientbudget 欄位也應該用使用者的語言對模型解釋——這能降低奇怪的 tool‑call 與不正確參數的機率。

也就是說,小工具的 i18n 架構不只是裝飾。它是整體系統的第一塊基石:UI 層、MCP 層與模型共用同一個語系脈絡。

12. 在地化小工具時的常見錯誤

錯誤 1:在 JSX 內硬寫字串。
非常常見:小工具先以單一語言快速打樣,突然又變成「還要英文」。結果 UI 充滿原本語言的字串,之後想加英文就淪為整個專案的全域搜尋取代。越早建立字典與 t() 函式,後面問題就越少。

錯誤 2:在每個地方寫 iflocale === 'ru')。
這種條件看似「快速解」,但一出現第三種語言或像 ru-RUruru-UA 之類的變體就立即崩壞。最好一次寫好 loadMessages(locale) 並做正規化(locale.split('-')[0]),之後就不必把檢查分散在整個程式碼中。

錯誤 3:把商業邏輯與文本混在一起。
有時開發者會在元件中同時處理商業分支與文本選擇,例如「如果沒有禮物,就顯示這句;如果預算太小,就顯示另一句」。最後文案很難改、邏輯擴散、翻譯也跑進 TypeScript。更好的作法是讓元件只回報字典鍵(errors.no_giftserrors.budget_too_low),而把文本分開編輯。

錯誤 4:忽略依 locale 格式化日期/貨幣。
對德國使用者顯示 $1,234.56 而不是 1.234,56 $,不算 bug,但卻是 UX 反模式。使用者會覺得「這服務不是為我做的」。如果你長期活在同一地區,很容易忘了 Intl.NumberFormatIntl.DateTimeFormat。因此,最好把格式器抽成像 useFormatters() 的 hook,永遠用它而不是手動組字串。

錯誤 5:沒有考慮 locale 可能在執行期間變更。
有些開發者在掛載時讀一次 locale 就當它是常數。多數情況下沒問題,但若 ChatGPT 或平台真的換了語系(例如使用者切換介面語言),你的小工具就會停留在舊語言。把 locale 視為反應式狀態的一部分,並以 useMemo/useEffect 綁定它才是正確的做法。

錯誤 6:不同語言使用不同結構的字典。
有時不同語言交給不同人負責,結果 widget.en.jsonwidget.ru.json 的結構分岔。在一個檔中有 forms.budget.placeholder,另一個只有 forms.budget.label。在執行期就會變成 undefined 與奇怪的錯誤。務必維持一個「標準檔」(通常是英文),其他語言在結構上與之對齊。甚至可以寫腳本檢查鍵的對應情況,輔助產生新字典。

錯誤 7:一開始就上過重的 i18n 框架,想一次解決所有問題。
react-i18nextnext-intl 這類熱門方案很強大,但對小型 ChatGPT 小工具可能太重。多半從輕量自製層開始(I18nProvideruseT、JSON 字典)會更簡單;當應用成長,真的需要複雜的複數規則、ICU 格式等,再遷移到完整函式庫也不遲。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION