1. 为什么要从平台获取 locale,而不是每次都问用户
如果用“老办法”做本地化,通常的逻辑是:弹出“选择语言”的模态框,并把结果存到 localStorage。 在 ChatGPT Apps 中做法不同:平台已经很“聪明”,它会慷慨地提供语言与地区的信号。 我们需要学会使用这些信号,而不是用多余的问题打扰用户。
ChatGPT 在发给你的 App 的每个请求里,都会在上下文中附带:
- 用户的偏好本地化(语言 + 地区)——在字段 openai/locale / _meta["openai/locale"];
- 用户的地理位置/地区——在字段 _meta["openai/userLocation"]。
在小部件(前端)侧,你可以通过 window.openai 或 SDK 的 hook 获取 locale;在 MCP/后端侧,则在 MCP 请求中的 _meta 里获取。
因此一个正常的场景是:用户输入“给妈妈挑一份 50 欧元以内的礼物”。ChatGPT 已经知道他的 locale 和 userLocation,平台把这些信号传给你的 App,然后你:
- 用用户能看懂的语言展示 UI,
- 加载正确语言的商品目录,
- 用合适的货币与格式显示价格。
无需再来一段“顺便问一下,您用什么语言?”的对话。
2. 信号一: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、你的翻译引擎,以及你的 tools 中。
小部件里哪里能拿到 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'
}
},
}
部分头部可能是由 ngrok 填充的,但仍然包含许多有价值的信息。
3. 信号二:_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",
{ /* schema 同上 */ },
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,
- 过滤无法配送到该国家的商品,
- 选择基础货币。
在后续的电商模块里,你还会基于 country 选择税务规则并映射到正确的 SKU,但从架构角度看,你依然是基于同一个信号——country 来做决策。
userLocation 如何补充 locale
一个经典示例:
locale = "en", userLocation.country = "DE"。
可能的逻辑:
- UI 与提示用英语(尊重 locale);
- 货币与价格格式用欧元,因为用户身处德国;
- 商品列表只展示可配送到 DE 的商品。
在 GiftGenius 中,你可以写一个小工具函数来表达这一点:
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:useOpenAiGlobal
之前我们写了一个更专用的 useOpenAiLocale。 在实践中,做一个统一访问 ChatGPT 全局的 hook 更方便——基于它可以很容易封装出 useOpenAiLocale 等其他工具。 可以这么写:
// 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") 会返回带有默认值 "en" 的最新 locale。
在 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 还是 俄语?”
- “我们检测到您的语言是 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 发来的信号只是“上游开始”,接下来你需要把这些数据传递给工具与服务,沿途不丢失,也不要让模型反复去“猜语言”。
在 tools 的参数中显式加入 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,但与 ChatGPT 用德语(DE)交流。结果是:
- request._meta?.["openai/locale"] 等于 EN
- 通过 inputSchema 作为工具参数传入的 locale 等于 DE
7. Locale vs 基于文本的语言自动识别
有时开发者会想:“我们直接按用户文本做语言检测吧,LLM 不是啥都能做么。” 在实践中,这几乎总是不如依赖 openai/locale 来得可靠。
原因很实际:
- 用户可能混用多种语言;
- 细微差异(如 uk-UA vs ru-RU)很难靠一条消息准确识别;
- ChatGPT 已经替你做了这件事,并把 locale 发给你了。
自动识别作为兜底很有用——当 openai/locale 缺失或异常(现今较少见)时, 但不建议把它作为主要逻辑。一个朴素的规则:
- 首先把 openai/locale 当作“真相”;
- 其后考虑 userLocation(货币、商品);
- 只有在非常纠结的情况下,再额外看看最近一条消息的语言。
8. 不同 locale 与 userLocation 组合:场景表
为了加深印象,来看 GiftGenius 在不同场景下应如何表现。
| 场景 | locale | userLocation.country | UI 语言 | 货币 | 目录 |
|---|---|---|---|---|---|
| 1 | |
|
EN | |
US 商品 |
| 2 | |
|
UKR/RU | |
UA 商品 |
| 3 | |
|
EN | |
DE 商品 |
| 4 | |
|
RU | |
DE 商品 |
| 5 | |
(无数据) | EN | |
Global default |
这种视角在后面讨论电商时会很有用,但现在已经可以看出:只要提供不同的 locale 与 country,行为就能轻松切换。
9. 本地化信号的简化流程图
为了更好地理解,看看这张简化示意:
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"],把它们写到日志,并用来选择目录。
- 写一个简单函数 deriveCurrency(locale, country),并在至少一个地方用于价格格式化。
无需一开始就搭完整的 i18n 引擎与 15 种语言——当前目标是学会如实使用平台提供的信号。
11. 处理 locale 与 userLocation 的常见错误
错误一:完全忽略 openai/locale,只依赖 navigator.language。
这通常是习惯于传统 Web 应用的人会犯的错。在 ChatGPT 中,用户可能根本没打开你的网页, 而你这边的 navigator.language 代表的是隧道服务器或 Vercel 的语言,而不是用户的。 最终 UI 总是“神秘地”显示为英文,尽管 ChatGPT 一直稳定地给你传 ru-RU。
错误二:每次都问用户“你更喜欢用哪种语言?”
如果你的小部件每个会话的第一条消息都是语言调查,用户会有在机场被反复询问行李的感觉。 平台已经知道语言与地区了——尊重 openai/locale,只在明显冲突(例如 locale 是 "en",但请求为俄语)时再询问。
错误三:只在 UI 中保存所选语言,却不把它传到 MCP 工具。
小部件可能用俄语显示,但服务器还在返回英文目录,因为它不知道语言已切换。 始终考虑“端到端”的路径:如果 UI 有切换器,它的结果需要传到后端——要么作为工具参数,要么通过 Gateway 会话。
错误四:只根据文本去“猜语言”,却忽略 openai/locale。
基于文本的自动检测在“纯英文”时可能还不错;一旦出现混合语言或相似表达,结果就会飘。 openai/locale 是平台提供的、足够可靠的评估,应把它作为主要事实来源,而文本检测只是补充信号。
错误五:把业务逻辑与本地化混在一起,到处写 if (locale === 'ru') { ... }。
在本讲的示例里我们为了简单还会这样做一点,但必须尽早规划:把字符串、格式与目录跟业务逻辑分离。 否则几个月后你会面对满地的 if(locale.startsWith("ru")) 开头的函数,再加一种语言就会很痛苦。到第 44 讲我们会系统地解决这个问题——好消息是:我们已经有了可靠的 locale 来源并会用它了。
GO TO FULL VERSION