1. 毎回ユーザーに聞くのではなく、プラットフォームから locale を得ることが重要な理由
ローカリゼーションを「昔ながら」に進めると、「言語を選んでください」というモーダルを出し、その結果を localStorage に保存する、という流れになりがちです。 ChatGPT Apps では考え方が異なります。すでに賢いプラットフォームがあり、言語と地域に関するシグナルを豊富に提供してくれます。 それらを活用して、ユーザーに余計な質問をしないようにしましょう。
ChatGPT はあなたの App への各リクエストで、コンテキストに以下を追加します:
- ユーザーの好みのロケール(言語 + 地域)— フィールド openai/locale / _meta["openai/locale"];
- ユーザーの位置情報/地域 — フィールド _meta["openai/userLocation"]。
フロントエンドのウィジェット側では、window.openai または SDK のフックから locale を取得します。MCP/バックエンド側では、MCP リクエストの _meta から取得します。
結果として、理想的な流れはこうです。ユーザーが「50 ユーロ以内で母へのプレゼントを選んで」と入力します。ChatGPT はすでにそのユーザーの locale と userLocation を知っており、プラットフォームがそれらのシグナルをあなたの App に渡します。あなたの 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、翻訳エンジン、そしてあなたの 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";
}
実際のアプリでは、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 のグローバルにアクセスする汎用メカニズムの上に組むのが便利です — これについては後のセクションで触れます。
MCP/バックエンドで locale が参照できる場所
ChatGPT が MCP ツールを呼び出す際、SDK は JSON‑rpc リクエストに _meta["openai/locale"] を渡します。 TypeScript サーバー(ここでは GiftGenius MCP)では、これは通常ツールハンドラーの第 2 引数で利用できます。
例:
// 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 サーバー。
インサイト
各 mcp-tool のサーバー側ハンドラーには extra パラメータがあり、inputSchema に入らなかったデータを mcp サーバーがそこに入れます。 次はそのようなオブジェクトの例です:
{
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 — 2 文字の ISO 国コード。品揃えと通貨に極めて重要;
- timezone — 日付/時刻のフォーマットやリマインダーに役立つ。
インサイト
実験で確認済み — 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,
- その国に配送できない商品をフィルターする、
- 基準通貨を選ぶ。
後のコマースモジュールでは、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 のフック経由)。 userLocation は _meta にあり、MCP/バックエンド側で使用します — これは先ほど扱いました。
Apps SDK には「生」の window.openai 以外に、React フックのユーティリティもあります。 ドキュメントでは useOpenAiGlobal("locale") のようなフックが紹介され、ChatGPT のグローバルコンテキストの値を React コンポーネントへ引き上げられます。
仕組みを理解するため、ここではそのフックを自作してみます。
基本フック useOpenAiGlobal
先ほどは用途特化の useOpenAiLocale を作りました。 実際には、ChatGPT のグローバルにアクセスする汎用フックが 1 つあると便利で、その上に 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 ウィジェットでの適用
ローカライズされた挨拶とデバッグ用の現在のロケールを表示する小さなコンポーネントを作ります:
// 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"、アプリ側はドイツ語と英語の両方の UI を提供可能。
- コミュニケーションの言語が極めて重要なアプリ(心理療法、法的相談、教育など)。そこでは自動検出の快適さより、正確な理解が優先されます。
このような場合は、シナリオの最初に 1 回だけ、短く丁寧な質問をし、その後は選択を記憶すると良いでしょう。
押し付けがましくない言語の聞き方
一般に、できる限り簡潔で視覚的な表現にします。例:
- 「どの言語が使いやすいですか: English それとも Russian?」
- 「推定された言語は English です。別の言語に切り替えますか?」
ChatGPT App では次の 2 通りがあります:
- ウィジェットの UI で: 上部に小さな言語トグルを描画する。
- App 名義のフォローアップメッセージで: テキストのフォローアップを送り、回答を処理する。
コード: 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 ウィジェットでは、ChatGPT からの「生」のシグナルではなく、selectedLocale を基に文言/辞書を選択できます。
将来の講義では、ローカル state ではなく、より安定した保存先(たとえば _meta["openai/subject"] を通じて選択言語を MCP / Gateway に渡すなど)へ置き換えますが、パターン自体は同じです。
6. locale と userLocation をバックエンドへ渡し、保持する方法
ChatGPT からのシグナルは「上から降って」きますが、そこで終わりではありません。これらのデータをツールや各種サービスまで届け、途中で失わず、モデルに言語を推測させ直さないことが大切です。
tools の引数に locale を明示する
最も堅牢なのは、ツールの inputSchema に locale(必要なら country も)を独立フィールドとして追加する方法です。 これにより、モデルは「このフィールドを埋めるべきだ」という明確なシグナルを受け取ります。
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 が「セッション状態」の有力候補であることを理解しておけば十分です。
インサイト
実験的知見: request._meta?.["openai/locale"] はユーザーに現在設定されているロケールを示します。 会話言語は inputSchema のツール引数として受け取ることができます。
私の端末のロケールを EN にしつつ、ChatGPT とはドイツ語(DE)でやり取りしました。その結果:
- request._meta?.["openai/locale"] は EN
- inputSchema 経由でツールの引数として取得した locale は DE
7. locale vs テキストの自動言語判定
開発者はつい「ユーザーテキストから言語を自動判定しよう、LLM は何でもできるから」と考えがちです。 実際には、openai/locale に依拠する方がほぼ常に優れています。
理由は実務的です:
- ユーザーが複数言語を混ぜて入力する可能性がある;
- 微妙な違い(uk-UA と ru-RU など)は 1 通のメッセージでは判定が難しい;
- ChatGPT はすでにこの作業を済ませ、locale を提供してくれている。
自動判定は、openai/locale が異常な形式で来る、もしくは存在しない(今では稀)場合のフォールバックとしては有用ですが、主ロジックをそれに依存させるべきではありません。経験則:
- まず openai/locale を「真実」として見る;
- 次に userLocation を加味する(通貨、品揃え);
- どうしても判断が難しい場合のみ、最後のメッセージの言語も参照する。
8. locale と userLocation の組み合わせ別シナリオ
理解を定着させるため、GiftGenius が各シナリオでどう振る舞うべきかを見てみましょう。
| シナリオ | locale | userLocation.country | UI 言語 | 通貨 | カタログ |
|---|---|---|---|---|---|
| 1 | |
|
EN | |
US 向け商品 |
| 2 | |
|
ウクライナ語/ロシア語 | |
UA 向け商品 |
| 3 | |
|
EN | |
DE 向け商品 |
| 4 | |
|
RU | |
DE 向け商品 |
| 5 | |
(データなし) | EN | |
グローバル既定 |
この視点は後でコマースを扱う際に役立ちますが、今の時点でも 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") などのフックを追加し、少なくとも 1 箇所で RU/EN の分岐を作る。
- MCP サーバー: 既存のツールの 1 つ(suggest_gifts)で _meta["openai/locale"] と _meta["openai/userLocation"] を取得してログに出し、カタログ選択に使用する。
- deriveCurrency(locale, country) を実装し、価格フォーマットのどこかで使ってみる。
最初から完全な i18n エンジンや 15 言語を用意する必要はありません。今は、プラットフォームのシグナルを正しく使いこなすことが目標です。
11. locale と userLocation の扱いでよくある誤り
誤り 1: openai/locale を完全に無視し、navigator.language のみに依存する。
従来の Web アプリに慣れているとやりがちです。ChatGPT ではユーザーがブラウザーを開かない場合もあり、 あなた側の navigator.language はユーザーではなくトンネルサーバーや Vercel の言語である可能性があります。 その結果、ChatGPT は安定して ru-RU を送っているのに、UI は「謎に」英語のままになります。
誤り 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