1. Почему вообще важно знать locale из платформы, а не спрашивать у пользователя каждый раз
Если подойти к теме локализации «по‑старинке», логика обычно такая: показать модальное окно «Выберите язык» и хранить результат в localStorage. В ChatGPT Apps подход другой: у нас уже есть умная платформа, и она щедро подсовывает сигналы о языке и регионе. Нужно научиться их использовать и не мучить пользователя лишними вопросами.
ChatGPT в каждом запросе к вашему App добавляет в контекст:
- предпочтительную локаль пользователя (язык + регион) — в поле openai/locale / _meta["openai/locale"];
- геолокацию/регион пользователя — в поле _meta["openai/userLocation"].
На стороне виджета (фронтенд) вы получаете locale через window.openai или хук SDK; на стороне MCP/бэкенда — через _meta в запросе MCP.
В результате нормальный сценарий выглядит так: пользователь пишет «Подбери подарок маме в пределах 50 евро». ChatGPT уже знает его locale и userLocation, платформа передаёт эти сигналы вашему App, и вы:
- показываете UI на понятном языке,
- подгружаете правильный язык каталога,
- форматируете цены в нужной валюте и формате.
Без отдельного диалога «Кстати, а какой у вас язык?».
2. Сигнал №1: openai/locale — язык и регион пользователя
Что это за поле и как оно выглядит
openai/locale — это строка в формате BCP‑47, который вы наверняка видели: "en", "en-US", "bg", "bg-BG", "it", "it-IT" и т.д.
Важно, что платформа:
- может прислать просто язык ("en", "ru"),
- может прислать язык + регион ("en-US", "en-GB", "fr-CA").
BCP‑47 — стандарт, с которым отлично работают и Intl-API в браузере, и большинство i18n‑библиотек. То есть openai/locale можно почти напрямую прокидывать в Intl.NumberFormat, в движок переводов и внутрь ваших tools.
Где locale доступен в виджете
В кастомном UI, который рендерится внутри ChatGPT, 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";
}
В реальном приложении проще сделать хук, который будет работать и в песочнице 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. Этот хук узкоспециализированный; в реальном проекте его удобно построить поверх более общего механизма доступа к глобалам ChatGPT — к нему мы вернёмся в отдельном разделе ниже.
Где locale доступен в MCP/бэкенде
Когда ChatGPT вызывает MCP‑инструмент, SDK передаёт _meta["openai/locale"] в JSON‑rpc‑запросе. На 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-tool через параметр extra._meta["openai/userLocation"]. Можете рассчитывать на них при разработке своих приложений.
Как использовать userLocation в MCP‑инструментах
На 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,
- отфильтровать товары, которые нельзя доставить в эту страну,
- выбрать базовую валюту.
Чуть позже в commerce‑модулях вы будете на базе country выбирать налоговые правила и маппить на правильные SKU, но с архитектурной точки зрения вы всё равно опираетесь на один и тот же сигнал — country.
Как userLocation дополняет locale
Классический пример:
locale = "en", userLocation.country = "DE".
Логика может быть такой:
- UI и подсказки — на английском (respect 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. Как получать locale и userLocation в виджете GiftGenius
Мы уже посмотрели, как locale и userLocation живут на стороне MCP и влияют на каталоги и валюту. Теперь разберёмся, как аккуратно забрать locale в виджет GiftGenius и использовать его прямо в React‑UI.
Важно: в виджете у нас есть прямой доступ только к locale (через window.openai и хуки SDK). userLocation живёт в _meta и используется на стороне MCP/бэкенда — с ним мы уже работали выше.
В Apps SDK помимо «сырого» window.openai есть утилиты в виде React‑хуков. В документации описываются хуки вроде useOpenAiGlobal("locale"), которые вытаскивают значения глобального контекста ChatGPT в React‑компоненты.
Смоделируем такой хук сами, чтобы было понятно, что под капотом происходит.
Базовый хук useOpenAiGlobal
Раньше мы сделали узкоспециализированный useOpenAiLocale. На практике удобнее иметь один универсальный хук для доступа к глобалам ChatGPT — на его основе легко собрать и 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") даёт нам актуальное значение locale с дефолтным значением "en".
Применение в виджете GiftGenius
Сделаем небольшой компонент, который показывает локализованное приветствие и текущую локаль для отладки:
// 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 виджета: отрисовать небольшой переключатель языков вверху.
- Через follow‑up сообщение в чат от имени 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 на более устойчивое хранение (например, пробросите выбранный язык в MCP / Gateway по _meta["openai/subject"]), но паттерн останется тем же.
6. Как передавать locale и userLocation в backend и хранить их
Сигналы от ChatGPT приходят «сверху», но жизнь на этом не заканчивается. Дальше эти данные надо донести до ваших инструментов и сервисов, не потерять по дороге и не заставлять модель угадывать язык заново.
Явное поле locale в аргументах tools
Самый надёжный приём — добавить 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"]; // анонимный user 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 пользователя. Язык общения можно получить в качестве параметра tool через inputSchema.
Я выставил у себя на компьютере EN-локаль, а общался с ChatGPT на немецком (DE). В результате:
- request._meta?.["openai/locale"] было равно EN
- locale, полученный как параметр tool через inputSchema, был равен DE
7. Locale vs автоопределение языка по тексту
Иногда разработчиков тянет к идее: «Давайте просто будем детектить язык по тексту пользователя, 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 | |
US‑товары |
| 2 | |
|
BG | |
BG‑товары |
| 3 | |
|
EN | |
DE‑товары |
| 4 | |
|
RU | |
DE‑товары |
| 5 | |
(нет данных) | EN | |
Global default |
Этот взгляд вам пригодится позже, когда мы будем обсуждать commerce, но уже сейчас видно, как легко менять поведение, просто подсовывая разные 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") или его аналог и хотя бы в одном месте завести развилку RU/EN для текста.
- В MCP‑сервере: в одном из существующих инструментов (suggest_gifts) достать _meta["openai/locale"] и _meta["openai/userLocation"], вывести их в лог и использовать для выбора каталога.
- Написать простую функцию 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 === 'bg') { ... } по всему коду.
На этой лекции мы ещё немного так делаем ради простоты, но важно заранее планировать, что строки, форматы и каталоги должны быть отделены от бизнес‑логики. В противном случае через пару месяцев вы окажетесь в коде, где каждая функция начинается с if (locale.startsWith("bg")), а добавить ещё один язык будет больно. В лекции 44 мы будем лечить как раз эту проблему, запоминая, что источник locale у нас уже есть и пользоваться им умеем.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ