1. 為什麼應該從平台取得 locale,而不是每次都向使用者詢問
如果用「傳統」方式做在地化,邏輯通常是:顯示「選擇語言」的對話框,然後把結果存到 localStorage。 在 ChatGPT Apps 中做法不同:我們已經有智慧的平台,而且它會慷慨地提供關於語言與區域的訊號。 要學會利用這些訊號,而不是用多餘的問題打擾使用者。
ChatGPT 在發往你的 App 的每個請求中都會加到上下文:
- 使用者偏好的 locale(語言 + 區域)— 位於 openai/locale / _meta["openai/locale"] 欄位;
- 使用者的地理位置/區域 — 位於 _meta["openai/userLocation"]。
在小工具(前端)端,你可以透過 window.openai 或 SDK 的 hook 取得 locale;在 MCP/後端端,則能透過請求中的 _meta 取得。
因此一個正常的情境會是:使用者輸入「幫我在 50 歐元內為媽媽挑禮物」。ChatGPT 已知道他的 locale 與 userLocation,平台把這些訊號傳給你的 App,而你會:
- 用使用者懂的語言顯示 UI,
- 載入正確語言的目錄,
- 用正確的貨幣與格式呈現價格。
而不需要額外的對話「順便問一下,您的語言是?」。
2. 訊號 1:openai/locale — 使用者的語言與區域
這是什麼欄位,長什麼樣
openai/locale 是一個 BCP‑47 格式的字串,你大概很熟悉: "en"、"en-US"、"ru"、"ru-RU"、"uk-UA" 等等。
重點在於平台可能:
- 只提供語言("en"、"ru"),
- 或提供語言 + 區域("en-US"、"en-GB"、"fr-CA")。
BCP‑47 是一個標準,瀏覽器的 Intl API 與大部分 i18n 函式庫都能很好地處理。 也就是說,你幾乎可以直接把 openai/locale 傳給 Intl.NumberFormat、翻譯引擎,以及你的工具內部。
在小工具中哪裡能取得 locale
在 ChatGPT 內部渲染的自訂 UI 中,Apps SDK 會提供全域物件 window.openai,其中包含 locale。
典型程式碼如下(TypeScript、Next.js 16、我們的 GiftGenius 小工具):
// src/app/widgets/gift-widget.tsx
declare global {
interface Window {
openai?: { locale?: string };
}
}
function getOpenAiLocale(): string {
if (typeof window === "undefined") return "en";
return window.openai?.locale || "en";
}
在實際應用中,用 hook 更方便,既能在 ChatGPT 沙盒、也能在 Storybook 中運作:
// src/app/hooks/useOpenAiLocale.ts
import { useEffect, useState } from "react";
export function useOpenAiLocale(defaultLocale: string = "en") {
const [locale, setLocale] = useState(defaultLocale);
useEffect(() => {
if (typeof window === "undefined") return;
const next = window.openai?.locale || defaultLocale;
setLocale(next);
}, [defaultLocale]);
return locale;
}
現在在任何元件中:
import { useOpenAiLocale } from "../hooks/useOpenAiLocale";
export function GiftHeader() {
const locale = useOpenAiLocale();
return (
<h2>
{/* 之後這裡會是 t('titles.gift_search') */}
{locale.startsWith("ru") ? "挑選禮物" : "Gift search"}
</h2>
);
}
在第 4 講我們會把所有字串整理到字典中,不過現在我們已經把 UI 綁定到平台提供的真實訊號, 而不是隨機的 navigator.language。 這個 hook 很專用;在真實專案中,通常會把它建構在更通用的 ChatGPT 全域取用機制之上 — 我們稍後會在單獨章節回到這點。
在 MCP/後端哪裡能取得 locale
當 ChatGPT 呼叫 MCP 工具時,SDK 會在 JSON‑rpc 請求中傳遞 _meta["openai/locale"]。 在 TypeScript 伺服器(我們的 GiftGenius MCP)上,它通常可以在工具處理器的第二個參數取得。
範例:
// src/mcp/server.ts
import { McpServer } from "@openai/mcp-sdk";
const server = new McpServer();
server.registerTool(
"suggest_gifts",
{
title: "禮物建議",
description: "根據偏好提供禮物清單",
inputSchema: {
type: "object",
properties: {
recipient: { type: "string" },
budget: { type: "number" }
},
required: ["recipient", "budget"]
}
},
async ({ input }, extra) => {
const locale = extra?._meta?.["openai/locale"] || "en";
// 接著可以載入正確的目錄
const gifts = await loadGiftCatalog(locale);
// ...
return {
content: [
{
type: "text",
text: `Found ${gifts.length} gifts for locale ${locale}`
}
],
structuredContent: { gifts }
};
}
);
因此 locale 會穿越整個堆疊:ChatGPT → Apps SDK → 你的 MCP 伺服器。
Insight
伺服器上每個 mcp-tool 都有一個 extra 參數,mcp 伺服器會把放不進 inputSchema 的資料塞到這裡。 範例如下:
{
sessionId: undefined, // 永遠是 undefined,請改用 `openai/subject` 於下方
_meta: {
'openai/userAgent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
'openai/locale': 'en-US', // 使用者電腦的 locale,可能與聊天語言不同
'openai/userLocation': { // 相當精準的使用者位置
city: 'London',
region: 'London City',
country: 'GB',
timezone: 'Europe/London',
latitude: '5.45466',
longitude: '-0.52380'
},
timezone_offset_minutes: -240, // 時區偏移(分鐘)
'openai/subject': 'v1/sEtRuS92UEOPNdwzEUZORfeOKf7XSk2KZoIUGfAsb68BzZ8h5FAOgrH' // 這就是 sessionId
},
authInfo: undefined,
requestId: 1,
requestInfo: {
headers: {
accept: 'application/json, text/event-stream',
'accept-encoding': 'gzip, deflate, br, zstd',
'access-control-allow-headers': '*',
'access-control-allow-methods': 'GET,POST,PUT,DELETE,OPTIONS',
'access-control-allow-origin': '*',
'content-length': '542',
'content-type': 'application/json',
host: 'test.ngrok.app', // 應用程式原始網域
'mcp-protocol-version': '2025-11-25',
traceparent: '00-69399d3a000000004fb8cc13dc3a2203-8748a8698107eb34-00',
tracestate: 'dd=s:-1;p:01514e334c1ccef5;t.dm:-3',
'user-agent': 'openai-mcp/1.0.0',
'x-datadog-parent-id': '6089244476286233754',
'x-datadog-sampling-priority': '-1',
'x-datadog-tags': '_dd.p.tid=69399c3a00000000,_dd.p.dm=-3',
'x-datadog-trace-id': '5744565710382309891',
'x-forwarded-for': '199.210.139.232',
'x-forwarded-host': 'test.ngrok.app',
'x-forwarded-port': '3001',
'x-forwarded-proto': 'https'
}
},
}
或許部分標頭是由 ngrock 填入,但仍有許多有用的資訊。
3. 訊號 2:_meta["openai/userLocation"] — 使用者的地理資訊
結構與意義
_meta["openai/userLocation"] 是包含地理資訊的物件:國家、地區、城市、時區,甚至座標。大致如下:
{
"city": "London",
"region": "England",
"country": "GB",
"timezone": "Europe/London",
"latitude": 51.5074,
"longitude": -0.1278
}
在 GiftGenius 中你最常實際使用的欄位:
- country — 兩字母 ISO 國碼,對商品供給與貨幣至關重要;
- timezone — 對日期/時間格式與提醒很有用。
Insight
實驗證實 — userLocation 的判定非常可靠。 資料會在每次呼叫 MCP 工具時,透過 extra._meta["openai/userLocation"] 參數傳入。 開發應用時你可以信賴這些資料。
如何在 MCP 工具中使用 userLocation
在 MCP 伺服器上,userLocation 位於 _meta["openai/userLocation"], 與 _meta["openai/locale"] 並列。
擴充我們的工具範例:
server.registerTool(
"suggest_gifts",
{ /* 結構同上 */ },
async ({ input }, extra) => {
const meta = extra?._meta ?? {};
const locale = (meta["openai/locale"] as string) || "en";
const userLocation = meta["openai/userLocation"] as
| { country?: string; city?: string }
| undefined;
const country = userLocation?.country || "US";
const gifts = await loadGiftCatalog(locale, country);
return {
content: [
{
type: "text",
text: `Found ${gifts.length} gifts for locale=${locale}, country=${country}`
}
],
structuredContent: { gifts }
};
}
);
函式 loadGiftCatalog(locale, country) 已經可以:
- 選擇正確的 JSON 檔:gift_catalog.en-US.json、gift_catalog.ru-RU.json,
- 過濾無法配送至該國家的商品,
- 選擇基礎貨幣。
在 commerce 模組中,你之後會根據 country 選擇稅則、對應正確的 SKU,但在架構角度你仍舊依賴同一個訊號 — country。
userLocation 如何補足 locale
典型的例子:
locale = "en", userLocation.country = "DE"。
邏輯可以是:
- UI 與提示文字使用英文(尊重 locale);
- 貨幣與價格格式用歐元,因為使用者實際在德國;
- 禮物清單只顯示可配送至 DE 的商品。
在 GiftGenius 中可以用一個小小的 helper 函式描述:
export function deriveCurrency(locale: string, country?: string): string {
if (country === "DE") return "EUR";
if (country === "JP") return "JPY";
if (locale === "zh_CN") return "CNY";
return "USD";
}
並在後端/前端用來格式化價格:
const currency = deriveCurrency(locale, country);
const formatted = new Intl.NumberFormat(locale, {
style: "currency",
currency
}).format(price);
在後端我們已經學會用 locale 與 country 來選擇目錄與貨幣。 接著很重要的一點是把相同的訊號傳到小工具中的 UI,讓使用者看到預期的語言與價格格式。
4. 如何在 GiftGenius 小工具中取得 locale 與 userLocation
我們已經了解 locale 與 userLocation 如何在 MCP 端運作,並影響目錄與貨幣。 現在來看看如何把 locale 帶進 GiftGenius 小工具,直接在 React UI 使用。
重點:在小工具端我們只能直接取到 locale(透過 window.openai 與 SDK hooks)。 userLocation 位於 _meta,在 MCP/後端使用 — 上面已經示範過。
在 Apps SDK 中,除了「原始」的 window.openai,還有 React hooks 形式的工具。 文件中介紹了像 useOpenAiGlobal("locale") 這樣的 hook,可以把 ChatGPT 的全域上下文值取到 React 元件中。
我們自己來模擬這個 hook,理解其原理。
基礎 hook:useOpenAiGlobal
先前我們做了專用的 useOpenAiLocale。 實務上有一個通用的全域存取 hook 更方便 — 它可以很容易地包出 useOpenAiLocale 與其他封裝。 假設這個 hook 長這樣:
// src/app/hooks/useOpenAiGlobal.ts
import { useEffect, useState } from "react";
type OpenAiGlobals = {
locale?: string;
// 之後可以加上 theme、userAgent 等
};
export function useOpenAiGlobal<K extends keyof OpenAiGlobals>(
key: K,
fallback?: NonNullable<OpenAiGlobals[K]>
): NonNullable<OpenAiGlobals[K]> {
const [value, setValue] = useState<NonNullable<OpenAiGlobals[K]>>(
(fallback ?? "") as NonNullable<OpenAiGlobals[K]>
);
useEffect(() => {
if (typeof window === "undefined") return;
const globals = (window.openai || {}) as OpenAiGlobals;
const next = globals[key] ?? fallback;
if (next !== undefined) {
setValue(next as NonNullable<OpenAiGlobals[K]>);
}
}, [key, fallback]);
return value;
}
現在 useOpenAiGlobal("locale", "en") 就能提供 locale 的最新值,且有預設值 "en"。
在 GiftGenius 小工具中的應用
做一個小元件,顯示在地化的歡迎詞與目前的 locale(方便除錯):
// src/app/widgets/GiftWelcome.tsx
"use client";
import React from "react";
import { useOpenAiGlobal } from "../hooks/useOpenAiGlobal";
export function GiftWelcome() {
const locale = useOpenAiGlobal("locale", "en");
const greeting =
locale.startsWith("ru") || locale.startsWith("uk")
? "嗨!我會幫你挑選合適的禮物。"
: "Hi! I’ll help you find a great gift.";
return (
<div>
<p>{greeting}</p>
<small style={{ opacity: 0.6 }}>Debug locale: {locale}</small>
</div>
);
}
暫時先不引入字典或 i18n 函式庫 — 那會在之後處理。 現在重要的是:我們已經能如實地從 ChatGPT 取得語言,而不是靠猜測。
5. 什麼時候需要明確詢問使用者語言
若 openai/locale 與 userLocation 這麼強大,是否永遠不需要問使用者想用哪種語言? 很可惜,有時還是需要。
當訊號不足時
幾個常見情境:
- ChatGPT 帳號是英文(locale = "en"),但使用者用俄文輸入。模型會用俄文回覆,但你的 UI 仍是英文。
- 使用者在德國(userLocation.country = "DE")、locale = "en",而你準備了德文與英文介面。
- 應用對溝通語言很敏感:心理諮詢、法律諮詢、教學。在這些場景,理解的精確度比自動偵測的便利更重要。
這些情況下,適合在流程一開始用簡短又禮貌的方式問一次,之後記住選擇。
如何不打擾地詢問語言
常見做法是把措辭盡量簡單、視覺化,例如:
- 「您偏好哪種語言:English 或 Russian?」
- 「我們偵測到您的語言為 English。要切換到其他語言嗎?」
在 ChatGPT App 中有兩種方式:
- 透過小工具 UI:在上方顯示一個小型語言切換器。
- 透過 App 名義在聊天中發跟進訊息(follow‑up):發出文字詢問,並處理回覆。
程式碼:GiftGenius 中的簡易語言選擇
做一個切換元件,它會:
- 從 locale 取得起始語言,
- 讓使用者選擇 ru 或 en,
- 把選擇存到小工具的狀態(先用 React state)。
// src/app/widgets/LanguageSwitcher.tsx
"use client";
import React, { useState, useEffect } from "react";
import { useOpenAiGlobal } from "../hooks/useOpenAiGlobal";
type SupportedLocale = "en" | "ru";
export function LanguageSwitcher(props: {
onChange?: (locale: SupportedLocale) => void;
}) {
const initialLocale = useOpenAiGlobal("locale", "en");
const [locale, setLocale] = useState<SupportedLocale>("en");
useEffect(() => {
const normalized: SupportedLocale = initialLocale.startsWith("ru")
? "ru"
: "en";
setLocale(normalized);
props.onChange?.(normalized);
}, [initialLocale, props]);
const handleChange = (next: SupportedLocale) => {
setLocale(next);
props.onChange?.(next);
};
return (
<div style={{ marginBottom: 8 }}>
<span style={{ marginRight: 8 }}>
{locale === "ru" ? "語言:" : "Language:"}
</span>
<button
type="button"
onClick={() => handleChange("en")}
style={{ fontWeight: locale === "en" ? "bold" : "normal" }}
>
EN
</button>
<button
type="button"
onClick={() => handleChange("ru")}
style={{ fontWeight: locale === "ru" ? "bold" : "normal", marginLeft: 4 }}
>
RU
</button>
</div>
);
}
接著在 GiftGenius 的主要小工具中,就可以依據 selectedLocale 來選擇文字/字典,而不是用 ChatGPT 的「原始」資料。
在後續課程中,你會把本地 state 改成更穩定的儲存(例如,透過 _meta["openai/subject"] 把選擇的語言傳到 MCP / Gateway),但模式是一樣的。
6. 如何把 locale 和 userLocation 傳到後端並保存
來自 ChatGPT 的訊號只是起點。接下來要把這些資料傳到你的工具與服務,不在途中遺失,也不要讓模型一次次重新猜測語言。
在工具參數中顯式加入 locale 欄位
最可靠的做法 — 把 locale(以及需要的話 country)加入工具的 inputSchema 作為獨立欄位。 如此模型會收到明確的訊號:「需要填這個欄位」。
server.registerTool(
"suggest_gifts",
{
title: "Gift suggestions",
description: "Suggest gifts based on recipient and budget",
inputSchema: {
type: "object",
properties: {
recipient: { type: "string" },
budget: { type: "number" },
locale: {
type: "string",
description: "Current user UI locale, BCP-47 (e.g. en-US, fr-FR)"
},
country: {
type: "string",
description: "ISO country code (e.g. US, DE)"
}
},
required: ["recipient", "budget"]
}
},
async ({ input }, extra) => {
// 如果模型沒有填 locale/country,就從 _meta 補上:
const meta = extra?._meta ?? {};
const locale = input.locale || (meta["openai/locale"] as string) || "en";
const country =
input.country ||
(meta["openai/userLocation"] as any)?.country ||
"US";
// ...
}
);
這能減少後端的「魔術」:伺服器可以清楚看見模型打算使用的參數。
在會話/使用者層級儲存 locale
在包含 MCP Gateway 的架構(之後的模組)中,通常會保存「客戶端狀態」:locale、currency、偏好設定。 現在只要理解這個概念:從 ChatGPT 讀一次訊號 — 後續就把它當成會話狀態的一部分使用,而不是每次都重新判定。
概念性偽代碼:
// gateway.ts
const sessionState = new Map<string, { locale: string; country?: string }>();
function onMcpRequest(request: any) {
const subject = request._meta?.["openai/subject"]; // 匿名使用者 ID
const locale = request._meta?.["openai/locale"] || "en";
const country = request._meta?.["openai/userLocation"]?.country;
if (subject) {
sessionState.set(subject, { locale, country });
}
// 然後把 locale/country 傳遞給特定的 MCP 伺服器
}
在本講範圍內你不需要實作 Gateway;只要理解 locale 與 userLocation 是很適合納入「會話狀態」的候選者。
Insight
實驗結果: request._meta?.["openai/locale"] 會顯示目前使用者設定的 locale。 溝通語言可以透過工具的 inputSchema 作為參數傳入。
我把電腦上的 locale 設為 EN,並用德文(DE)和 ChatGPT 對話。結果:
- request._meta?.["openai/locale"] 等於 EN
- 透過 inputSchema 傳入工具的 locale 等於 DE
7. Locale 與依據文字自動判斷語言的比較
有時開發者會想:「乾脆直接用文字偵測語言吧,LLM 不是什麼都會嗎」。 實務上,這幾乎總是比不上依賴 openai/locale。
原因很務實:
- 使用者可能混用多種語言;
- 細微差異(如 uk-UA vs ru-RU)很難從單一訊息偵測;
- ChatGPT 已經幫你做了這件事,並提供了 locale。
自動偵測可以作為 fallback(例如 openai/locale 缺失或格式怪異 — 但現在很少見), 但不應該成為主要邏輯。簡單的原則:
- 先把 openai/locale 視為「事實來源」;
- 接著考慮 userLocation(貨幣、供給);
- 只有在爭議很大的情況下,才另外參考最後一則訊息的語言。
8. 不同的 locale 與 userLocation 組合:情境表
為了鞏固概念,我們看看 GiftGenius 在不同情境下該怎麼做。
| 情境 | locale | userLocation.country | UI 語言 | 貨幣 | 目錄 |
|---|---|---|---|---|---|
| 1 | |
|
EN | |
美國商品 |
| 2 | |
|
UKR/RU | |
烏克蘭商品 |
| 3 | |
|
EN | |
德國商品 |
| 4 | |
|
RU | |
德國商品 |
| 5 | |
(沒有資料) | EN | |
全域預設 |
這個視角在我們之後討論 commerce 時會很有用;但現在已經能看出,只要提供不同的 locale 與 country, 行為就能輕鬆調整。
9. 小型流程圖:locale 訊號的流向
為了統整思路,看看簡化的架構圖:
flowchart TD U[使用者<br/>輸入訊息] --> C[ChatGPT] C -->|判定| L[openai/locale<br/>+ userLocation] L -->|傳遞| W["Widget (Next.js)"] L -->|透過 _meta 傳遞| S[MCP Server] W -->|locale| UI[GiftGenius UI<br/>文字 + 數字格式] S -->|locale + country| DATA[目錄、價格、篩選器] style L fill:#e0f7ff,stroke:#00a style W fill:#f7fff0,stroke:#4b4 style S fill:#fdf0ff,stroke:#b4
要注意的是:這張圖上並沒有「選擇語言」的彈窗。它只在使用者期望與訊號矛盾時,作為額外的一層才有必要。
10. 實作:現在在你的 App 可以做什麼
為了避免只是理論,這是 GiftGenius 的簡短實作清單:
- 在小工具:加入 useOpenAiGlobal("locale") 或等價的 hook,並至少在一處做 RU/EN 的文字分支。
- 在 MCP 伺服器:在既有的一個工具(suggest_gifts)中讀取 _meta["openai/locale"] 與 _meta["openai/userLocation"],把它們寫到 log,並用來選擇目錄。
- 撰寫簡單的 deriveCurrency(locale, country) 函式,並在一處用它來格式化價格。
不需要一開始就搭建完整的 i18n 引擎與 15 種語言 — 現階段的目標是誠實地使用平台提供的訊號。
11. 使用 locale 與 userLocation 時的常見錯誤
錯誤 1:完全忽視 openai/locale,只依賴 navigator.language。
這是習慣一般網頁應用的人常見的做法。在 ChatGPT 中,使用者甚至不一定是用瀏覽器打開, 而你這端的 navigator.language 可能是通道伺服器或 Vercel 的語言,而不是使用者的。 結果就是 UI「莫名其妙」一直是英文,儘管 ChatGPT 穩定地傳來 ru-RU。
錯誤 2:每次都問使用者「你想用哪種語言?」
如果每次聊天小工具的第一句都是語言問卷,使用者會覺得像在機場被不停盤問是否遺失行李。平台已經知道語言與區域 — 尊重 openai/locale 即可,只有在明顯衝突時(例如 locale = "en",但輸入是俄文)再詢問。
錯誤 3:只在 UI 儲存選擇的語言,卻不傳到 MCP 工具。
小工具可能是俄文,但伺服器仍回傳英文目錄,因為它不知道語言已經切換。 務必思考端到端的路徑:如果 UI 有切換器,它的結果要傳達到後端 — 可以放在工具參數, 或透過 Gateway 會話。
錯誤 4:只靠訊息文字來「猜」語言,忽略 openai/locale。
只靠文字做自動偵測可能在純英文時表現不錯……但一旦混用語言或遇到相似片語,結果會不穩。 openai/locale 是平台提供、相當可靠的評估;應視為主要的真實來源,而文字偵測只是補充訊號。
錯誤 5:把商業邏輯與在地化混在一起,到處寫 if (locale === 'ru') { ... }。
在本講為求簡化我們還會稍微這樣做,但務必要預先規劃:字串、格式與目錄應與商業邏輯分離。 否則幾個月後你會發現每個函式都從 if (locale.startsWith("ru")) 開始,而新增一種語言會很痛苦。在第 44 講我們會處理這個問題,同時記得我們已經有且會使用 locale 的來源。
GO TO FULL VERSION