LazyBoy2/web/src/i18n.tsx

339 lines
12 KiB
TypeScript
Raw Normal View History

2026-09-15 03:20:42 +00:00
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
export const LOCALES = ["zh-Hant", "zh-Hans", "en", "ja"] as const;
export type Locale = (typeof LOCALES)[number];
export const LOCALE_OPTIONS: { id: Locale; native: string }[] = [
{ id: "zh-Hant", native: "繁體中文" },
{ id: "zh-Hans", native: "简体中文" },
{ id: "en", native: "English" },
{ id: "ja", native: "日本語" },
];
const STORAGE_KEY = "grokboy.locale";
const zhHant = {
language: "語言",
settings: "設定",
close: "關閉",
addAgent: "新增 Agent",
deleteAgent: "刪除 Agent",
deleteAgentConfirm: "刪除 {name}?對話與記憶會一併刪掉,無法復原。",
search: "搜尋",
agentNamePlaceholder: "Agent 名稱",
agents: "Agent",
noAgents: "還沒有 Agent",
noMatchingAgents: "沒有符合的 Agent",
queuedWork: "等待開始…",
activityUnknown: "暫時無法確認工作狀態,正在重新連線。",
agentWorking: "工作中",
newChat: "新對話",
localWorkspace: "本機工作區",
pickAgent: "選擇 Agent",
workTools: "工作工具",
stop: "停止",
computer: "電腦",
startWorkWith: "開始跟 {name} 工作",
startWorkHint: "傳一則訊息,或打開右邊的電腦看它動手做。",
workingWith: "{name} 正在工作…",
moreActions: "更多動作",
messageTo: "傳訊息給 {name}",
pickAgentFirst: "先選一個 Agent",
send: "送出",
collapse: "收合",
computerOf: "{name} 的電腦",
enlarge: "放大",
loginHint: "在這台機器人自己的瀏覽器登入。Session 留在它的電腦,不要把密碼打在聊天裡。",
chat: "聊天",
mainMenu: "主選單",
restart: "重啟",
update: "更新",
restartTitle: "重啟這台電腦",
updateTitle: "重建映像並在有更新時重開",
restartConfirm: "重啟會暫時中斷桌面與正在跑的操作。瀏覽器登入會保留。確定重啟?",
updateConfirm:
"更新會依目前的 Dockerfile 重建這台電腦的映像。登入與 /workspace 會留在 volume。若映像有變就會重開桌面,可能需要幾分鐘。確定更新?",
computerStarting: "電腦啟動中…",
computerRestarting: "正在重啟電腦…",
computerUpdating: "正在更新電腦映像…",
computerRunning: "執行中",
computerUpToDate: "映像已是最新",
computerNotReady: "電腦未就緒",
computerNotStarted: "尚未啟動",
computerOff: "未啟動",
thinking: "思考中",
needsReply: "需要回覆",
stepComputer: "操作電腦",
stepSearch: "搜尋中",
stepFetch: "讀取網頁",
stepBrowser: "操作瀏覽器",
stepBox: "在電腦上執行",
stepShell: "執行指令",
stepWrite: "改檔案",
stepRead: "讀檔",
stepWorking: "處理中",
} as const;
export type MessageKey = keyof typeof zhHant;
export type Messages = Record<MessageKey, string>;
const zhHans: Messages = {
language: "语言",
settings: "设置",
close: "关闭",
addAgent: "新增 Agent",
deleteAgent: "删除 Agent",
deleteAgentConfirm: "删除 {name}?对话和记忆会一并删掉,无法恢复。",
search: "搜索",
agentNamePlaceholder: "Agent 名称",
agents: "Agent",
noAgents: "还没有 Agent",
noMatchingAgents: "没有符合的 Agent",
queuedWork: "等待开始…",
activityUnknown: "暂时无法确认工作状态,正在重新连接。",
agentWorking: "工作中",
newChat: "新对话",
localWorkspace: "本地工作区",
pickAgent: "选择 Agent",
workTools: "工作工具",
stop: "停止",
computer: "电脑",
startWorkWith: "开始和 {name} 工作",
startWorkHint: "发一条消息,或打开右边的电脑看它动手做。",
workingWith: "{name} 正在工作…",
moreActions: "更多操作",
messageTo: "发消息给 {name}",
pickAgentFirst: "先选一个 Agent",
send: "发送",
collapse: "收起",
computerOf: "{name} 的电脑",
enlarge: "放大",
loginHint: "在这台机器人自己的浏览器登录。Session 留在它的电脑,不要把密码打在聊天里。",
chat: "聊天",
mainMenu: "主菜单",
restart: "重启",
update: "更新",
restartTitle: "重启这台电脑",
updateTitle: "重建镜像并在有更新时重开",
restartConfirm: "重启会暂时中断桌面和正在跑的操作。浏览器登录会保留。确定重启?",
updateConfirm:
"更新会按当前的 Dockerfile 重建这台电脑的镜像。登录和 /workspace 会留在 volume。若镜像有变就会重开桌面,可能需要几分钟。确定更新?",
computerStarting: "电脑启动中…",
computerRestarting: "正在重启电脑…",
computerUpdating: "正在更新电脑镜像…",
computerRunning: "运行中",
computerUpToDate: "镜像已是最新",
computerNotReady: "电脑未就绪",
computerNotStarted: "尚未启动",
computerOff: "未启动",
thinking: "思考中",
needsReply: "需要回复",
stepComputer: "操作电脑",
stepSearch: "搜索中",
stepFetch: "读取网页",
stepBrowser: "操作浏览器",
stepBox: "在电脑上执行",
stepShell: "执行命令",
stepWrite: "改文件",
stepRead: "读文件",
stepWorking: "处理中",
};
const en: Messages = {
language: "Language",
settings: "Settings",
close: "Close",
addAgent: "New agent",
deleteAgent: "Delete agent",
deleteAgentConfirm: "Delete {name}? Its chats and memory will be removed. This cannot be undone.",
search: "Search",
agentNamePlaceholder: "Agent name",
agents: "Agents",
noAgents: "No agents yet",
noMatchingAgents: "No matching agents",
queuedWork: "Queued…",
activityUnknown: "Work status is unavailable. Reconnecting.",
agentWorking: "Working",
newChat: "New chat",
localWorkspace: "Local workspace",
pickAgent: "Choose an agent",
workTools: "Work tools",
stop: "Stop",
computer: "Computer",
startWorkWith: "Start working with {name}",
startWorkHint: "Send a message, or open the computer on the right to watch it work.",
workingWith: "{name} is working…",
moreActions: "More actions",
messageTo: "Message {name}",
pickAgentFirst: "Choose an agent first",
send: "Send",
collapse: "Collapse",
computerOf: "{name}'s computer",
enlarge: "Expand",
loginHint: "Sign in inside this agent's own browser. The session stays on its computer — don't type passwords in chat.",
chat: "Chat",
mainMenu: "Main menu",
restart: "Restart",
update: "Update",
restartTitle: "Restart this computer",
updateTitle: "Rebuild the image and reopen if it changed",
restartConfirm: "Restart will briefly interrupt the desktop and any running work. Browser logins are kept. Restart now?",
updateConfirm:
"Update rebuilds this computer's image from the current Dockerfile. Logins and /workspace stay on the volume. If the image changed, the desktop will reopen. This can take several minutes. Update now?",
computerStarting: "Starting computer…",
computerRestarting: "Restarting computer…",
computerUpdating: "Updating computer image…",
computerRunning: "Running",
computerUpToDate: "Image is up to date",
computerNotReady: "Computer is not ready",
computerNotStarted: "Not started yet",
computerOff: "Off",
thinking: "Thinking",
needsReply: "Needs a reply",
stepComputer: "Using the computer",
stepSearch: "Searching",
stepFetch: "Reading a page",
stepBrowser: "Using the browser",
stepBox: "Running on the computer",
stepShell: "Running a command",
stepWrite: "Editing a file",
stepRead: "Reading a file",
stepWorking: "Working",
};
const ja: Messages = {
language: "言語",
settings: "設定",
close: "閉じる",
addAgent: "Agent を追加",
deleteAgent: "Agent を削除",
deleteAgentConfirm: "{name} を削除しますか?会話と記憶も消えます。元に戻せません。",
search: "検索",
agentNamePlaceholder: "Agent 名",
agents: "Agent",
noAgents: "まだ Agent がありません",
noMatchingAgents: "一致する Agent がありません",
queuedWork: "開始待ち…",
activityUnknown: "作業状態を確認できません。再接続しています。",
agentWorking: "作業中",
newChat: "新しい会話",
localWorkspace: "ローカルワークスペース",
pickAgent: "Agent を選ぶ",
workTools: "作業ツール",
stop: "停止",
computer: "コンピュータ",
startWorkWith: "{name} と作業を始める",
startWorkHint: "メッセージを送るか、右のコンピュータを開いて作業を見てください。",
workingWith: "{name} が作業中…",
moreActions: "その他",
messageTo: "{name} にメッセージ",
pickAgentFirst: "先に Agent を選んでください",
send: "送信",
collapse: "折りたたむ",
computerOf: "{name} のコンピュータ",
enlarge: "拡大",
loginHint: "このエージェント自身のブラウザでログインしてください。セッションはそのコンピュータに残ります。パスワードをチャットに入力しないでください。",
chat: "チャット",
mainMenu: "メインメニュー",
restart: "再起動",
update: "更新",
restartTitle: "このコンピュータを再起動",
updateTitle: "イメージを再構築し、変更があれば開き直す",
restartConfirm: "再起動するとデスクトップと実行中の操作が一時的に中断されます。ブラウザのログインは残ります。再起動しますか?",
updateConfirm:
"更新すると現在の Dockerfile からこのコンピュータのイメージを再構築します。ログインと /workspace は volume に残ります。イメージが変わった場合はデスクトップを開き直します。数分かかることがあります。更新しますか?",
computerStarting: "コンピュータを起動中…",
computerRestarting: "コンピュータを再起動中…",
computerUpdating: "コンピュータのイメージを更新中…",
computerRunning: "実行中",
computerUpToDate: "イメージは最新です",
computerNotReady: "コンピュータの準備ができていません",
computerNotStarted: "まだ起動していません",
computerOff: "停止中",
thinking: "考え中",
needsReply: "返信が必要です",
stepComputer: "コンピュータを操作中",
stepSearch: "検索中",
stepFetch: "ページを読み取り中",
stepBrowser: "ブラウザを操作中",
stepBox: "コンピュータで実行中",
stepShell: "コマンドを実行中",
stepWrite: "ファイルを編集中",
stepRead: "ファイルを読み取り中",
stepWorking: "処理中",
};
export const MESSAGES: Record<Locale, Messages> = {
"zh-Hant": zhHant,
"zh-Hans": zhHans,
en,
ja,
};
export function isLocale(value: string | null | undefined): value is Locale {
return LOCALES.includes(value as Locale);
}
export function detectLocale(): Locale {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (isLocale(saved)) return saved;
} catch {
/* ignore */
}
const nav = typeof navigator === "undefined" ? "" : navigator.language || "";
const lower = nav.toLowerCase();
if (lower.startsWith("ja")) return "ja";
if (lower.startsWith("en")) return "en";
if (lower.startsWith("zh")) {
if (lower.includes("cn") || lower.includes("sg") || lower.includes("hans")) return "zh-Hans";
return "zh-Hant";
}
return "zh-Hant";
}
export function formatMessage(template: string, vars?: Record<string, string>): string {
if (!vars) return template;
return template.replace(/\{(\w+)\}/g, (_, key: string) => vars[key] ?? `{${key}}`);
}
type I18nValue = {
locale: Locale;
setLocale: (locale: Locale) => void;
t: Messages;
format: (key: MessageKey, vars?: Record<string, string>) => string;
};
const I18nContext = createContext<I18nValue | null>(null);
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(detectLocale);
useEffect(() => {
document.documentElement.lang = locale;
try {
localStorage.setItem(STORAGE_KEY, locale);
} catch {
/* ignore */
}
}, [locale]);
const value = useMemo<I18nValue>(() => {
const t = MESSAGES[locale];
return {
locale,
setLocale: setLocaleState,
t,
format: (key, vars) => formatMessage(t[key], vars),
};
}, [locale]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nValue {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useI18n must be used within I18nProvider");
return ctx;
}