JavaRush /Курсы /ChatGPT Apps /Локализация виджетов: Next + React (i18n‑архитектура)

Локализация виджетов: Next + React (i18n‑архитектура)

ChatGPT Apps
9 уровень , 2 лекция
Открыта

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‑формат и т.д.

1
Задача
ChatGPT Apps, 9 уровень, 2 лекция
Недоступна
DebugLocale — диагностический tool для трассировки локали
DebugLocale — диагностический tool для трассировки локали
1
Задача
ChatGPT Apps, 9 уровень, 2 лекция
Недоступна
Локализованный compare_jets — locale как явный аргумент инструмента
Локализованный compare_jets — locale как явный аргумент инструмента
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ