返回 DeepSeek-Reasonix
i18n.tsx
根目录 / desktop / frontend / src / lib / i18n.tsx
1 // i18n is the desktop's localization seam. It mirrors theme.ts's "persist a choice
2 // and apply it" shape, but UI text must re-render on a switch, so the active locale
3 // lives in React state behind a context — flipping it re-renders the whole tree
4 // (App is a child of the provider). A module-level mirror (`currentLocale`) lets
5 // non-React code (lib/tools.ts) translate too; it stays fresh because the provider
6 // updates it on every render.
7 //
8 // Desktop UI language is intentionally separate from the CLI/kernel `language`
9 // config for prompts and terminal text. The desktop preference is persisted in
10 // the user-level [desktop] config; localStorage is only read once for legacy
11 // migration from older desktop builds.
12
13 import { createContext, useCallback, useContext, useEffect, useState } from "react";
14 import type { ReactNode } from "react";
15 import { en, type DictKey } from "../locales/en";
16
17 export type Locale = "en" | "zh" | "zh-TW";
18 export type { DictKey };
19 // LangPref is the stored preference: "" means auto-detect from the OS.
20 export type LangPref = "" | "en" | "zh" | "zh-TW";
21
22 type Dict = Record<DictKey, string>;
23
24 const DICTS: Partial<Record<Locale, Dict>> = { en };
25 const localeLoads = new Map<Locale, Promise<void>>();
26 const STORAGE_KEY = "reasonix-lang";
27
28 // currentLocale mirrors the active locale for callers outside React (lib/tools.ts).
29 let currentLocale: Locale = "en";
30
31 // Whimsical present-participles cycled in the status line while a turn runs. Kept
32 // out of the dict (it's an array, and purely decorative) but localized all the same.
33 export const SPINNER_WORDS: Record<Locale, string[]> = {
34 en: [
35 "Frolicking", "Pondering", "Noodling", "Brewing", "Conjuring", "Cogitating",
36 "Percolating", "Ruminating", "Simmering", "Synthesizing", "Tinkering",
37 "Marinating", "Crunching", "Hatching", "Mulling", "Whirring", "Forging",
38 "Spelunking", "Puttering", "Vibing",
39 ],
40 zh: [
41 "嬉游中", "沉思中", "鼓捣中", "酝酿中", "施法中", "苦思中",
42 "渗滤中", "反刍中", "文火慢炖", "合成中", "修补中",
43 "腌制入味", "嘎吱运算", "孵化中", "盘算中", "嗡嗡运转", "锻造中",
44 "探洞中", "摆弄中", "来感觉了",
45 ],
46 "zh-TW": [
47 "嬉遊中", "沉思中", "鼓搗中", "醞釀中", "施法中", "苦思中",
48 "滲濾中", "反芻中", "文火慢燉", "合成中", "修補中",
49 "醃製入味", "嘎吱運算", "孵化中", "盤算中", "嗡嗡運轉", "鍛造中",
50 "探洞中", "擺弄中", "來感覺了",
51 ],
52 };
53
54 export function detectLocale(pref: LangPref): Locale {
55 if (pref === "en" || pref === "zh" || pref === "zh-TW") return pref;
56 const nav = typeof navigator !== "undefined" ? navigator.language.toLowerCase() : "en";
57 if (nav.startsWith("zh-tw") || nav.startsWith("zh-hant") || nav === "zh-hk" || nav === "zh-mo") return "zh-TW";
58 return nav.startsWith("zh") ? "zh" : "en";
59 }
60
61 export function preloadLocale(locale: Locale): Promise<void> {
62 if (DICTS[locale]) return Promise.resolve();
63 const pending = localeLoads.get(locale);
64 if (pending) return pending;
65 const load = locale === "zh"
66 ? import("../locales/zh").then(({ zh }) => { DICTS.zh = zh; })
67 : import("../locales/zh-TW").then(({ zhTW }) => { DICTS["zh-TW"] = zhTW; });
68 localeLoads.set(locale, load);
69 void load.catch(() => localeLoads.delete(locale));
70 return load;
71 }
72
73 export function preloadDetectedLocale(pref: LangPref = ""): Promise<void> {
74 return preloadLocale(detectLocale(pref));
75 }
76
77 function readPref(): LangPref {
78 return "";
79 }
80
81 export function normalizeLangPref(v: unknown): LangPref {
82 return v === "en" || v === "zh" || v === "zh-TW" ? v : "";
83 }
84
85 export function readLegacyLangPref(): LangPref {
86 const v = typeof localStorage !== "undefined" ? localStorage.getItem(STORAGE_KEY) : null;
87 return normalizeLangPref(v);
88 }
89
90 export function clearLegacyLangPref(): void {
91 try {
92 localStorage.removeItem(STORAGE_KEY);
93 } catch {
94 /* private mode / no storage */
95 }
96 }
97
98 // translate resolves a key for a locale and fills {placeholders}. Missing keys fall
99 // back to English, then to the raw key, so the UI never renders blank.
100 function translate(locale: Locale, key: DictKey, vars?: Record<string, string | number>): string {
101 const s = DICTS[locale]?.[key] ?? en[key] ?? key;
102 if (!vars) return s;
103 return s.replace(/\{(\w+)\}/g, (_, k) => (vars[k] !== undefined ? String(vars[k]) : `{${k}}`));
104 }
105
106 // t is the non-reactive translator for code outside React (e.g. lib/tools.ts). It
107 // reads the module mirror, which the provider keeps in sync.
108 export function t(key: DictKey, vars?: Record<string, string | number>): string {
109 return translate(currentLocale, key, vars);
110 }
111
112 export function getLocale(): Locale {
113 return currentLocale;
114 }
115
116 export type Translator = (key: DictKey, vars?: Record<string, string | number>) => string;
117
118 interface I18nValue {
119 locale: Locale;
120 pref: LangPref;
121 setPref: (pref: LangPref) => void;
122 t: Translator;
123 }
124
125 const I18nContext = createContext<I18nValue | null>(null);
126
127 export function LocaleProvider({ children }: { children: ReactNode }) {
128 const [pref, setPrefState] = useState<LangPref>(() => readPref());
129 const [dictionaryVersion, setDictionaryVersion] = useState(0);
130 const locale = detectLocale(pref);
131 currentLocale = locale; // keep the mirror fresh for non-React callers
132
133 useEffect(() => {
134 if (typeof document === "undefined") return;
135 document.documentElement.lang = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en";
136 }, [locale]);
137
138 useEffect(() => {
139 if (DICTS[locale]) return;
140 let cancelled = false;
141 void preloadLocale(locale).then(() => {
142 if (!cancelled) setDictionaryVersion((version) => version + 1);
143 });
144 return () => {
145 cancelled = true;
146 };
147 }, [locale]);
148
149 // setPref updates only the live UI; persistence is handled by desktop config.
150 const setPref = useCallback((next: LangPref) => {
151 setPrefState(normalizeLangPref(next));
152 }, []);
153
154 const tt = useCallback<Translator>(
155 (key, vars) => translate(detectLocale(pref), key, vars),
156 [dictionaryVersion, pref],
157 );
158
159 return <I18nContext.Provider value={{ locale, pref, setPref, t: tt }}>{children}</I18nContext.Provider>;
160 }
161
162 export function useI18n(): I18nValue {
163 const ctx = useContext(I18nContext);
164 if (!ctx) throw new Error("useI18n must be used within a LocaleProvider");
165 return ctx;
166 }
167
168 // useT is the common shorthand: just the translator.
169 export function useT(): Translator {
170 return useI18n().t;
171 }
172
172 lines Plain Text