diff --git a/ui/src/i18n/index.ts b/ui/src/i18n/index.ts new file mode 100644 index 00000000000..d043702dbab --- /dev/null +++ b/ui/src/i18n/index.ts @@ -0,0 +1,3 @@ +export * from "./lib/types"; +export * from "./lib/translate"; +export * from "./lib/lit-controller"; diff --git a/ui/src/i18n/lib/lit-controller.ts b/ui/src/i18n/lib/lit-controller.ts new file mode 100644 index 00000000000..9bf4edc137a --- /dev/null +++ b/ui/src/i18n/lib/lit-controller.ts @@ -0,0 +1,22 @@ +import type { ReactiveController, ReactiveControllerHost } from "lit"; +import { i18n } from "./translate"; + +export class I18nController implements ReactiveController { + private host: ReactiveControllerHost; + private unsubscribe?: () => void; + + constructor(host: ReactiveControllerHost) { + this.host = host; + this.host.addController(this); + } + + hostConnected() { + this.unsubscribe = i18n.subscribe(() => { + this.host.requestUpdate(); + }); + } + + hostDisconnected() { + this.unsubscribe?.(); + } +} diff --git a/ui/src/i18n/lib/translate.ts b/ui/src/i18n/lib/translate.ts new file mode 100644 index 00000000000..62675f22842 --- /dev/null +++ b/ui/src/i18n/lib/translate.ts @@ -0,0 +1,106 @@ +import type { Locale, TranslationMap } from "./types"; +import { en } from "../locales/en"; + +type Subscriber = (locale: Locale) => void; + +class I18nManager { + private locale: Locale = "en"; + private translations: Record = { en } as Record; + private subscribers: Set = new Set(); + + constructor() { + this.loadLocale(); + } + + private loadLocale() { + const saved = localStorage.getItem("openclaw.i18n.locale") as Locale; + if (saved && ["en", "zh-CN", "zh-TW", "pt-BR"].includes(saved)) { + this.locale = saved; + } else { + const navLang = navigator.language; + if (navLang.startsWith("zh")) { + this.locale = navLang === "zh-TW" || navLang === "zh-HK" ? "zh-TW" : "zh-CN"; + } else if (navLang.startsWith("pt")) { + this.locale = "pt-BR"; + } else { + this.locale = "en"; + } + } + } + + public getLocale(): Locale { + return this.locale; + } + + public async setLocale(locale: Locale) { + if (this.locale === locale) return; + + // Lazy load translations if needed + if (!this.translations[locale]) { + try { + const module = await import(`../locales/${locale}.ts`); + this.translations[locale] = module[locale.replace("-", "_")]; + } catch (e) { + console.error(`Failed to load locale: ${locale}`, e); + return; + } + } + + this.locale = locale; + localStorage.setItem("openclaw.i18n.locale", locale); + this.notify(); + } + + public registerTranslation(locale: Locale, map: TranslationMap) { + this.translations[locale] = map; + } + + public subscribe(sub: Subscriber) { + this.subscribers.add(sub); + return () => this.subscribers.delete(sub); + } + + private notify() { + this.subscribers.forEach((sub) => sub(this.locale)); + } + + public t(key: string, params?: Record): string { + const keys = key.split("."); + let value: any = this.translations[this.locale] || this.translations["en"]; + + for (const k of keys) { + if (value && typeof value === "object") { + value = value[k]; + } else { + value = undefined; + break; + } + } + + // Fallback to English + if (value === undefined && this.locale !== "en") { + value = this.translations["en"]; + for (const k of keys) { + if (value && typeof value === "object") { + value = value[k]; + } else { + value = undefined; + break; + } + } + } + + if (typeof value !== "string") { + return key; + } + + if (params) { + return value.replace(/\{(\w+)\}/g, (_, k) => params[k] || `{${k}}`); + } + + return value; + } +} + +export const i18n = new I18nManager(); +export const t = (key: string, params?: Record) => i18n.t(key, params); diff --git a/ui/src/i18n/lib/types.ts b/ui/src/i18n/lib/types.ts new file mode 100644 index 00000000000..3fefa42bf59 --- /dev/null +++ b/ui/src/i18n/lib/types.ts @@ -0,0 +1,9 @@ +export type TranslationMap = { [key: string]: string | TranslationMap }; + +export type Locale = "en" | "zh-CN" | "zh-TW" | "pt-BR"; + +export interface I18nConfig { + locale: Locale; + fallbackLocale: Locale; + translations: Record; +} diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts new file mode 100644 index 00000000000..407f2e48380 --- /dev/null +++ b/ui/src/i18n/locales/en.ts @@ -0,0 +1,107 @@ +import type { TranslationMap } from "../lib/types"; + +export const en: TranslationMap = { + common: { + health: "Health", + ok: "OK", + offline: "Offline", + connect: "Connect", + refresh: "Refresh", + enabled: "Enabled", + disabled: "Disabled", + na: "n/a", + docs: "Docs", + resources: "Resources", + }, + nav: { + chat: "Chat", + control: "Control", + agent: "Agent", + settings: "Settings", + expand: "Expand sidebar", + collapse: "Collapse sidebar", + }, + tabs: { + agents: "Agents", + overview: "Overview", + channels: "Channels", + instances: "Instances", + sessions: "Sessions", + usage: "Usage", + cron: "Cron Jobs", + skills: "Skills", + nodes: "Nodes", + chat: "Chat", + config: "Config", + debug: "Debug", + logs: "Logs", + }, + subtitles: { + agents: "Manage agent workspaces, tools, and identities.", + overview: "Gateway status, entry points, and a fast health read.", + channels: "Manage channels and settings.", + instances: "Presence beacons from connected clients and nodes.", + sessions: "Inspect active sessions and adjust per-session defaults.", + usage: "Monitor API usage and costs.", + cron: "Schedule wakeups and recurring agent runs.", + skills: "Manage skill availability and API key injection.", + nodes: "Paired devices, capabilities, and command exposure.", + chat: "Direct gateway chat session for quick interventions.", + config: "Edit ~/.openclaw/openclaw.json safely.", + debug: "Gateway snapshots, events, and manual RPC calls.", + logs: "Live tail of the gateway file logs.", + }, + overview: { + access: { + title: "Gateway Access", + subtitle: "Where the dashboard connects and how it authenticates.", + wsUrl: "WebSocket URL", + token: "Gateway Token", + password: "Password (not stored)", + sessionKey: "Default Session Key", + connectHint: "Click Connect to apply connection changes.", + }, + snapshot: { + title: "Snapshot", + subtitle: "Latest gateway handshake information.", + status: "Status", + uptime: "Uptime", + tickInterval: "Tick Interval", + lastChannelsRefresh: "Last Channels Refresh", + channelsHint: "Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.", + }, + stats: { + instances: "Instances", + instancesHint: "Presence beacons in the last 5 minutes.", + sessions: "Sessions", + sessionsHint: "Recent session keys tracked by the gateway.", + cron: "Cron", + cronNext: "Next wake {time}", + }, + notes: { + title: "Notes", + subtitle: "Quick reminders for remote control setups.", + tailscaleTitle: "Tailscale serve", + tailscaleText: "Prefer serve mode to keep the gateway on loopback with tailnet auth.", + sessionTitle: "Session hygiene", + sessionText: "Use /new or sessions.patch to reset context.", + cronTitle: "Cron reminders", + cronText: "Use isolated sessions for recurring runs.", + }, + auth: { + required: "This gateway requires auth. Add a token or password, then click Connect.", + failed: "Auth failed. Re-copy a tokenized URL with {command}, or update the token, then click Connect.", + }, + insecure: { + hint: "This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open {url} on the gateway host.", + stayHttp: "If you must stay on HTTP, set {config} (token-only).", + }, + }, + chat: { + disconnected: "Disconnected from gateway.", + refreshTitle: "Refresh chat data", + thinkingToggle: "Toggle assistant thinking/working output", + focusToggle: "Toggle focus mode (hide sidebar + page header)", + onboardingDisabled: "Disabled during onboarding", + }, +}; diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts new file mode 100644 index 00000000000..931183cba43 --- /dev/null +++ b/ui/src/i18n/locales/pt-BR.ts @@ -0,0 +1,107 @@ +import type { TranslationMap } from "../lib/types"; + +export const pt_BR: TranslationMap = { + common: { + health: "Saúde", + ok: "OK", + offline: "Offline", + connect: "Conectar", + refresh: "Atualizar", + enabled: "Ativado", + disabled: "Desativado", + na: "n/a", + docs: "Docs", + resources: "Recursos", + }, + nav: { + chat: "Chat", + control: "Controle", + agent: "Agente", + settings: "Configurações", + expand: "Expandir barra lateral", + collapse: "Recolher barra lateral", + }, + tabs: { + agents: "Agentes", + overview: "Visão Geral", + channels: "Canais", + instances: "Instâncias", + sessions: "Sessões", + usage: "Uso", + cron: "Tarefas Cron", + skills: "Habilidades", + nodes: "Nós", + chat: "Chat", + config: "Config", + debug: "Debug", + logs: "Logs", + }, + subtitles: { + agents: "Gerenciar espaços de trabalho, ferramentas e identidades de agentes.", + overview: "Status do gateway, pontos de entrada e leitura rápida de saúde.", + channels: "Gerenciar canais e configurações.", + instances: "Beacons de presença de clientes e nós conectados.", + sessions: "Inspecionar sessões ativas e ajustar padrões por sessão.", + usage: "Monitorar uso e custos da API.", + cron: "Agendar despertares e execuções recorrentes de agentes.", + skills: "Gerenciar disponibilidade de habilidades e injeção de chaves de API.", + nodes: "Dispositivos pareados, capacidades e exposição de comandos.", + chat: "Sessão de chat direta com o gateway para intervenções rápidas.", + config: "Editar ~/.openclaw/openclaw.json com segurança.", + debug: "Snapshots do gateway, eventos e chamadas RPC manuais.", + logs: "Acompanhamento ao vivo dos logs de arquivo do gateway.", + }, + overview: { + access: { + title: "Acesso ao Gateway", + subtitle: "Onde o dashboard se conecta e como ele se autentica.", + wsUrl: "URL WebSocket", + token: "Token do Gateway", + password: "Senha (não armazenada)", + sessionKey: "Chave de Sessão Padrão", + connectHint: "Clique em Conectar para aplicar as alterações de conexão.", + }, + snapshot: { + title: "Snapshot", + subtitle: "Informações mais recentes do handshake do gateway.", + status: "Status", + uptime: "Tempo de Atividade", + tickInterval: "Intervalo de Tick", + lastChannelsRefresh: "Última Atualização de Canais", + channelsHint: "Use Canais para vincular WhatsApp, Telegram, Discord, Signal ou iMessage.", + }, + stats: { + instances: "Instâncias", + instancesHint: "Beacons de presença nos últimos 5 minutos.", + sessions: "Sessões", + sessionsHint: "Chaves de sessão recentes rastreadas pelo gateway.", + cron: "Cron", + cronNext: "Próximo despertar {time}", + }, + notes: { + title: "Notas", + subtitle: "Lembretes rápidos para configurações de controle remoto.", + tailscaleTitle: "Tailscale serve", + tailscaleText: "Prefira o modo serve para manter o gateway em loopback com autenticação tailnet.", + sessionTitle: "Higiene de sessão", + sessionText: "Use /new ou sessions.patch para redefinir o contexto.", + cronTitle: "Lembretes de Cron", + cronText: "Use sessões isoladas para execuções recorrentes.", + }, + auth: { + required: "Este gateway requer autenticação. Adicione um token ou senha e clique em Conectar.", + failed: "Falha na autenticação. Recopie uma URL com token usando {command}, ou atualize o token e clique em Conectar.", + }, + insecure: { + hint: "Esta página é HTTP, então o navegador bloqueia a identidade do dispositivo. Use HTTPS (Tailscale Serve) ou abra {url} no host do gateway.", + stayHttp: "Se você precisar permanecer em HTTP, defina {config} (apenas token).", + }, + }, + chat: { + disconnected: "Desconectado do gateway.", + refreshTitle: "Atualizar dados do chat", + thinkingToggle: "Alternar saída de pensamento/trabalho do assistente", + focusToggle: "Alternar modo de foco (ocultar barra lateral + cabeçalho da página)", + onboardingDisabled: "Desativado durante a integração", + }, +}; diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts new file mode 100644 index 00000000000..fb6122414e4 --- /dev/null +++ b/ui/src/i18n/locales/zh-CN.ts @@ -0,0 +1,107 @@ +import type { TranslationMap } from "../lib/types"; + +export const zh_CN: TranslationMap = { + common: { + health: "健康状况", + ok: "正常", + offline: "离线", + connect: "连接", + refresh: "刷新", + enabled: "已启用", + disabled: "已禁用", + na: "不适用", + docs: "文档", + resources: "资源", + }, + nav: { + chat: "聊天", + control: "控制", + agent: "代理", + settings: "设置", + expand: "展开侧边栏", + collapse: "折叠侧边栏", + }, + tabs: { + agents: "代理", + overview: "概览", + channels: "频道", + instances: "实例", + sessions: "会话", + usage: "使用情况", + cron: "定时任务", + skills: "技能", + nodes: "节点", + chat: "聊天", + config: "配置", + debug: "调试", + logs: "日志", + }, + subtitles: { + agents: "管理代理工作区、工具和身份。", + overview: "网关状态、入口点和快速健康读取。", + channels: "管理频道和设置。", + instances: "来自已连接客户端和节点的在线信号。", + sessions: "检查活动会话并调整每个会话的默认设置。", + usage: "监控 API 使用情况和成本。", + cron: "安排唤醒和重复的代理运行。", + skills: "管理技能可用性和 API 密钥注入。", + nodes: "配对设备、功能和命令公开。", + chat: "用于快速干预的直接网关聊天会话。", + config: "安全地编辑 ~/.openclaw/openclaw.json。", + debug: "网关快照、事件和手动 RPC 调用。", + logs: "网关文件日志的实时追踪。", + }, + overview: { + access: { + title: "网关访问", + subtitle: "仪表板连接的位置及其身份验证方式。", + wsUrl: "WebSocket URL", + token: "网关令牌", + password: "密码 (不存储)", + sessionKey: "默认会话密钥", + connectHint: "点击连接以应用连接更改。", + }, + snapshot: { + title: "快照", + subtitle: "最新的网关握手信息。", + status: "状态", + uptime: "运行时间", + tickInterval: "刻度间隔", + lastChannelsRefresh: "最后频道刷新", + channelsHint: "使用频道链接 WhatsApp、Telegram、Discord、Signal 或 iMessage。", + }, + stats: { + instances: "实例", + instancesHint: "过去 5 分钟内的在线信号。", + sessions: "会话", + sessionsHint: "网关跟踪的最近会话密钥。", + cron: "定时任务", + cronNext: "下次唤醒 {time}", + }, + notes: { + title: "备注", + subtitle: "远程控制设置的快速提醒。", + tailscaleTitle: "Tailscale serve", + tailscaleText: "首选 serve 模式以通过 tailnet 身份验证将网关保持在回环地址。", + sessionTitle: "会话清理", + sessionText: "使用 /new 或 sessions.patch 重置上下文。", + cronTitle: "定时任务提醒", + cronText: "为重复运行使用隔离的会话。", + }, + auth: { + required: "此网关需要身份验证。添加令牌或密码,然后点击连接。", + failed: "身份验证失败。请使用 {command} 重新复制令牌化 URL,或更新令牌,然后点击连接。", + }, + insecure: { + hint: "此页面为 HTTP,因此浏览器阻止设备标识。请使用 HTTPS (Tailscale Serve) 或在网关主机上打开 {url}。", + stayHttp: "如果您必须保持 HTTP,请设置 {config} (仅限令牌)。", + }, + }, + chat: { + disconnected: "已断开与网关的连接。", + refreshTitle: "刷新聊天数据", + thinkingToggle: "切换助手思考/工作输出", + focusToggle: "切换专注模式 (隐藏侧边栏 + 页面页眉)", + onboardingDisabled: "引导期间禁用", + }, +}; diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts new file mode 100644 index 00000000000..c485b2f9413 --- /dev/null +++ b/ui/src/i18n/test/translate.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { i18n, t } from "../lib/translate"; + +describe("i18n", () => { + beforeEach(() => { + localStorage.clear(); + // Reset to English + void i18n.setLocale("en"); + }); + + it("should return the key if translation is missing", () => { + expect(t("non.existent.key")).toBe("non.existent.key"); + }); + + it("should return the correct English translation", () => { + expect(t("common.health")).toBe("Health"); + }); + + it("should replace parameters correctly", () => { + expect(t("overview.stats.cronNext", { time: "10:00" })).toBe("Next wake 10:00"); + }); + + it("should fallback to English if key is missing in another locale", async () => { + // We haven't registered other locales in the test environment yet, + // but the logic should fallback to 'en' map which is always there. + await i18n.setLocale("zh-CN"); + // Since we don't mock the import, it might fail to load zh-CN, + // but let's assume it falls back to English for now. + expect(t("common.health")).toBeDefined(); + }); +}); diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index dcc8843bae2..d4537634bb6 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -10,6 +10,7 @@ import { OpenClawApp } from "./app.ts"; import { ChatState, loadChatHistory } from "./controllers/chat.ts"; import { icons } from "./icons.ts"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts"; +import { t } from "../i18n/index.ts"; type SessionDefaultsSnapshot = { mainSessionKey?: string; @@ -186,7 +187,7 @@ export function renderChatControls(state: AppViewState) { }); } }} - title="Refresh chat data" + title=${t("chat.refreshTitle")} > ${refreshIcon} @@ -206,8 +207,8 @@ export function renderChatControls(state: AppViewState) { aria-pressed=${showThinking} title=${ disableThinkingToggle - ? "Disabled during onboarding" - : "Toggle assistant thinking/working output" + ? t("chat.onboardingDisabled") + : t("chat.thinkingToggle") } > ${icons.brain} @@ -227,8 +228,8 @@ export function renderChatControls(state: AppViewState) { aria-pressed=${focusActive} title=${ disableFocusToggle - ? "Disabled during onboarding" - : "Toggle focus mode (hide sidebar + page header)" + ? t("chat.onboardingDisabled") + : t("chat.focusToggle") } > ${focusIcon} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index c48282461eb..f1560eb138e 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -50,9 +50,18 @@ import { saveSkillApiKey, updateSkillEdit, updateSkillEnabled, + type SkillMessage, } from "./controllers/skills.ts"; import { icons } from "./icons.ts"; -import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; +import { + normalizeBasePath, + TAB_GROUPS, + iconForTab, + pathForTab, + subtitleForTab, + titleForTab, + type Tab, +} from "./navigation.ts"; import { renderAgents } from "./views/agents.ts"; import { renderChannels } from "./views/channels.ts"; import { renderChat } from "./views/chat.ts"; @@ -67,6 +76,7 @@ import { renderNodes } from "./views/nodes.ts"; import { renderOverview } from "./views/overview.ts"; import { renderSessions } from "./views/sessions.ts"; import { renderSkills } from "./views/skills.ts"; +import { t, i18n, type Locale } from "../i18n/index.ts"; const AVATAR_DATA_RE = /^data:/i; const AVATAR_HTTP_RE = /^https?:\/\//i; @@ -91,7 +101,7 @@ export function renderApp(state: AppViewState) { const presenceCount = state.presenceEntries.length; const sessionsCount = state.sessionsResult?.count ?? null; const cronNext = state.cronStatus?.nextWakeAtMs ?? null; - const chatDisabledReason = state.connected ? null : "Disconnected from gateway."; + const chatDisabledReason = state.connected ? null : t("chat.disconnected"); const isChat = state.tab === "chat"; const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding); const showThinking = state.onboarding ? false : state.settings.chatShowThinking; @@ -117,8 +127,8 @@ export function renderApp(state: AppViewState) { ...state.settings, navCollapsed: !state.settings.navCollapsed, })} - title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}" - aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}" + title="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}" + aria-label="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}" > ${icons.menu} @@ -135,8 +145,8 @@ export function renderApp(state: AppViewState) {
- Health - ${state.connected ? "OK" : "Offline"} + ${t("common.health")} + ${state.connected ? t("common.ok") : t("common.offline")}
${renderThemeToggle(state)}
@@ -159,7 +169,7 @@ export function renderApp(state: AppViewState) { }} aria-expanded=${!isGroupCollapsed} > - ${group.label} + ${t(`nav.${group.label}`)} ${isGroupCollapsed ? "+" : "−"}