1. Зачем виджету отдельная i18n‑архитектура в ChatGPT App
В обычном Next.js‑приложении вы часто опираетесь на URL (/en/..., /ru/...) или роутер, чтобы привязать язык к маршруту. В ChatGPT‑виджете всё веселее: ваш UI живёт внутри iframe в песочнице, а URL управляется не вами. Язык приходит в виде состояния от ChatGPT, например через openai/locale или хук вроде useOpenAiGlobal('locale'), а не из адресной строки.
Получается необычная ситуация. С точки зрения Next.js, ваш виджет — это условно одна страница /widget, но внутри она должна уметь рендерить себя на любом языке, который скажет платформа. Переключать язык надо не навигацией, а состоянием. Это автоматически подталкивает к архитектуре «один UI, много словарей» и ещё раз подчёркивает: хранить строки в коде — тупиковый путь.
Вдобавок, в одном и том же диалоге ChatGPT может запускать ваш App для пользователей из разных стран. Вы не можете «решить один раз, что App русскоязычный» и забыть. Виджет должен легко переинициализироваться под новый locale, не меняя бизнес‑логику — именно для этого и нужна аккуратная i18n‑прослойка.
2. Главный принцип: строк в коде быть не должно
Если коротко сформулировать философию UI‑локализации, она звучит так: React‑компонентам не нужны реальные тексты, им нужны ключи.
Вместо:
// ПЛОХО: строка захардкожена в компоненте
<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. Откуда брать locale в виджете Apps SDK
В классическом браузерном приложении вы бы полезли в navigator.language. В ChatGPT‑виджете так делать можно, но не нужно: ChatGPT уже вычислил за пользователя предпочтительную локаль и передаёт её в контекст Apps SDK. Это может быть поле locale в window.openai, которое можно прочитать напрямую или через удобный хук вроде useOpenAiGlobal('locale').
Типично для 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‑слой: контекст + хук useT
Чтобы виджет был самодостаточным и не превращался в учебник по react-i18next, реализуем лёгкий собственный i18n‑слой. Для маленького ChatGPT‑виджета этого более чем достаточно, а принципы те же, что и в популярных библиотеках.
Сначала опишем типы и создадим контекст в app/widget/i18n.tsx:
"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);
Теперь сделаем провайдер, который получает 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, который будет доставать строки по ключу:
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‑провайдер в корневой компонент виджета
Раньше мы уже видели GiftWidgetRoot, который просто читал locale из useOpenAiGlobal. Теперь используем I18nProvider в этом корневом компоненте и добавим загрузку словаря. Предположим, что он раньше выглядел примерно так:
"use client";
export function GiftWidgetRoot() {
return (
<div>
<h1>GiftGenius</h1>
{/* формы и результаты */}
</div>
);
}
Добавим загрузку словаря и провайдер. Для простоты используем синхронный require/import по locale, но в 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 создаст виджет с locale = "de-DE", вы сможете добавить locales/de/widget.json и одну строку в loadMessages, не трогая остальной код. Вот ради этого всё и затевалось.
7. Локализуемые форматы: числа, даты, валюты
Мы уже вынесли тексты в словари и обернули виджет в I18nProvider. Но тексты — это только половина UX: пользователь из США ожидает увидеть 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 даёт вам реактивный сигнал (через хук или событие), Code‑паттерн будет такой:
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 автоматически перерендерится с новыми переводами. В большинстве реальных сценариев локаль стабильна в рамках сессии, но заложить правильную реактивную модель всё равно полезно.
9. Немного про сложные строки: плейсхолдеры и плюрализация
С реактивностью по locale разобрались. Следующий естественный вопрос: что делать с динамическими частями текста — количествами, именами и т.д.? В подарочном приложении это может быть что‑то вроде «Найдено 3 подарка для Маши».
Самый простой способ справиться с такими фразами — поддержать плейсхолдеры в 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, у которой есть полноценная поддержка правил множественного числа (plural rules). Для учебного виджета ручной выбор по диапазонам (например, if count === 1, if count < 5 и т.д.) вполне терпим.
10. Где разместить i18n в структуре Next.js‑шаблона Apps SDK
С точки зрения Next.js 16 и официального шаблона Apps SDK, ваш виджет — это обычно специализированный entrypoint в app/ (например, 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, только вместо navigator.language используете openai/locale.
Если вам понадобится шарить переводы между несколькими виджетами одного App (например, основной мастер и «маленький inline‑виджет»), можно вынести I18nProvider в отдельный модуль и переиспользовать.
11. Мини‑тестирование локализации
Как только в системе появляется i18n‑слой, его стоит начать тестировать отдельно — иначе любая опечатка в ключе превращается в «полупереведённый UI». Раз уж мы архитектуру сделали, грех её не проверить.
Во‑первых, имеет смысл написать простые unit‑тесты на loadMessages и useT (с использованием React Testing Library или даже без React — просто тестируя функцию t). Такие тесты ловят опечатки в ключах и помогут, если вы или переводчик случайно удалите нужную ветку словаря.
Во‑вторых, удобно предусмотреть режим «локального прогона» виджета вне ChatGPT, где вы сможете принудительно указать locale через query‑параметр или кнопку в UI. Это полезно и для вас, и для QA: никто не обязан запускать весь Dev Mode и ChatGPT только для того, чтобы посмотреть, как выглядит немецкий перевод. С такими базовыми тестами и локальным прогоном по разным locale вы гораздо спокойнее будете развивать и UI, и тексты, и дальше переходить к локализации описаний tools.
Как всё это связано с поведением модели
Сразу глубоко в локализацию descriptions инструментов мы зайдём в следующей лекции, но уже сейчас важно увидеть связку: виджет и инструменты должны говорить на одном языке с пользователем. Вы уже строите UI, который подстраивается под openai/locale. MCP‑сервер по этому же сигналу выбирает правильный каталог и тексты. Логично, что и описание suggest_gifts, и поля recipient, budget будут объяснены модели на языке пользователя — это уменьшит количество странных tool‑calls и некорректных аргументов.
То есть 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: отсутствие форматирования дат/валют по локали.
Показать пользователю в Германии цену $1,234.56 вместо 1.234,56 $ — не баг, а UX‑анти‑паттерн. Но пользователи это воспринимают как «этот сервис сделан не для меня». Очень легко забыть про Intl.NumberFormat и Intl.DateTimeFormat, если вы привыкли жить в одном регионе. Поэтому полезно вынести форматтеры в хук вроде useFormatters() и всегда использовать их вместо ручной конкатенации строк.
Ошибка №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‑формат и т.д.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ