From 54900d63583c4a1e68b391ad6b5fcbbe14de849a Mon Sep 17 00:00:00 2001 From: Lekler Date: Wed, 28 Jan 2026 04:15:40 -0300 Subject: [PATCH] feat: Implement internationalization (i18n) with English and Portuguese (pt-BR) locales and integrate into UI components. --- ui/src/i18n/index.ts | 5 + ui/src/i18n/lib/lit-controller.ts | 22 + ui/src/i18n/lib/translate.ts | 81 +++ ui/src/i18n/lib/types.ts | 1 + ui/src/i18n/locales/en.ts | 98 ++++ ui/src/i18n/locales/pt-BR.ts | 98 ++++ ui/src/i18n/test/translate.test.ts | 46 ++ ui/src/ui/app-render.ts | 773 +++++++++++++++-------------- ui/src/ui/app.ts | 8 + ui/src/ui/navigation.ts | 55 +- ui/src/ui/storage.ts | 19 +- ui/src/ui/views/overview.ts | 55 +- 12 files changed, 829 insertions(+), 432 deletions(-) create mode 100644 ui/src/i18n/index.ts create mode 100644 ui/src/i18n/lib/lit-controller.ts create mode 100644 ui/src/i18n/lib/translate.ts create mode 100644 ui/src/i18n/lib/types.ts create mode 100644 ui/src/i18n/locales/en.ts create mode 100644 ui/src/i18n/locales/pt-BR.ts create mode 100644 ui/src/i18n/test/translate.test.ts diff --git a/ui/src/i18n/index.ts b/ui/src/i18n/index.ts new file mode 100644 index 000000000..a1d7ccc08 --- /dev/null +++ b/ui/src/i18n/index.ts @@ -0,0 +1,5 @@ +export * from './lib/types'; +export * from './lib/translate'; +export * from './lib/lit-controller'; +export * from './locales/en'; +export * from './locales/pt-BR'; diff --git a/ui/src/i18n/lib/lit-controller.ts b/ui/src/i18n/lib/lit-controller.ts new file mode 100644 index 000000000..34600795c --- /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 { + 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 000000000..e7e62c9cb --- /dev/null +++ b/ui/src/i18n/lib/translate.ts @@ -0,0 +1,81 @@ +import type { TranslationMap } from './types'; + +class Translator { + private locale: string = 'en'; + private translations: Record = {}; + private listeners: Set<() => void> = new Set(); + + constructor() { + // Try to load saved locale + try { + const saved = localStorage.getItem('moltbot-locale'); + if (saved) { + this.locale = saved; + } else if (navigator.language.startsWith('pt')) { + this.locale = 'pt-BR'; + } + } catch (e) { + // ignore + } + } + + get currentLocale() { + return this.locale; + } + + subscribe(cb: () => void) { + this.listeners.add(cb); + return () => this.listeners.delete(cb); + } + + private notify() { + this.listeners.forEach(cb => cb()); + } + + setResources(locale: string, resources: TranslationMap) { + this.translations[locale] = resources; + this.notify(); + } + + setLocale(locale: string) { + if (this.locale === locale) return; + this.locale = locale; + try { + localStorage.setItem('moltbot-locale', locale); + } catch (e) { + // ignore + } + this.notify(); + } + + t(key: string, params?: Record): string { + const table = this.translations[this.locale]; + + let val = this.resolve(table, key); + + if (val === undefined || val === null) { + // Fallback to EN if key missing in current + if (this.locale !== 'en') { + val = this.resolve(this.translations['en'], key); + } + } + + if (val === undefined || val === null) return key; + if (typeof val !== 'string') return key; + + return this.interpolate(val, params); + } + + private resolve(table: TranslationMap | undefined, key: string): any { + if (!table) return undefined; + return key.split('.').reduce((obj, i) => (obj ? obj[i] : null), table); + } + + private interpolate(text: string, params?: Record) { + if (!params) return text; + return text.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`)); + } +} + +export const i18n = new Translator(); +export const t = i18n.t.bind(i18n); diff --git a/ui/src/i18n/lib/types.ts b/ui/src/i18n/lib/types.ts new file mode 100644 index 000000000..5570eb34e --- /dev/null +++ b/ui/src/i18n/lib/types.ts @@ -0,0 +1 @@ +export type TranslationMap = Record; // Relaxed type for nested objects diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts new file mode 100644 index 000000000..b7965fb43 --- /dev/null +++ b/ui/src/i18n/locales/en.ts @@ -0,0 +1,98 @@ +export const en = { + brand: { + title: "MOLTBOT", + subtitle: "Gateway Dashboard" + }, + sidebar: { + expand: "Expand sidebar", + collapse: "Collapse sidebar" + }, + status: { + health: "Health", + ok: "OK", + offline: "Offline", + connected: "Connected", + disconnected: "Disconnected" + }, + nav: { + resources: "Resources", + docs: "Docs", + group: { + chat: "Chat", + control: "Control", + agent: "Agent", + settings: "Settings" + } + }, + tab: { + title: { + overview: "Overview", + channels: "Channels", + instances: "Instances", + sessions: "Sessions", + cron: "Cron Jobs", + skills: "Skills", + nodes: "Nodes", + chat: "Chat", + config: "Config", + debug: "Debug", + logs: "Logs", + control: "Control" + }, + subtitle: { + 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.", + 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 ~/.clawdbot/moltbot.json safely.", + debug: "Gateway snapshots, events, and manual RPC calls.", + logs: "Live tail of the gateway file logs." + } + }, + overview: { + gateway_access: { + title: "Gateway Access", + sub: "Where the dashboard connects and how it authenticates." + }, + snapshot: { + title: "Snapshot", + sub: "Latest gateway handshake information." + }, + notes: { + title: "Notes", + sub: "Quick reminders for remote control setups.", + tailscale: "Tailscale serve", + tailscale_sub: "Prefer serve mode to keep the gateway on loopback with tailnet auth.", + session: "Session hygiene", + session_sub: "Use /new or sessions.patch to reset context.", + cron: "Cron reminders", + cron_sub: "Use isolated sessions for recurring runs." + }, + field: { + websocket: "WebSocket URL", + token: "Gateway Token", + password: "Password (not stored)", + session: "Default Session Key", + language: "Language" + }, + stats: { + uptime: "Uptime", + tick: "Tick Interval", + last_refresh: "Last Channels Refresh", + }, + action: { + connect: "Connect", + refresh: "Refresh" + }, + hint: { + connect_apply: "Click Connect to apply connection changes.", + use_channels: "Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.", + auth_failed: "Auth failed. Re-copy a tokenized URL with `moltbot dashboard --no-open`, or update the token, then click Connect.", + https_required: "This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open http://127.0.0.1:18789 on the gateway host." + } + } +}; diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts new file mode 100644 index 000000000..283fe9fc1 --- /dev/null +++ b/ui/src/i18n/locales/pt-BR.ts @@ -0,0 +1,98 @@ +export const ptBR = { + brand: { + title: "MOLTBOT", + subtitle: "Painel do Gateway" + }, + sidebar: { + expand: "Expandir barra lateral", + collapse: "Recolher barra lateral" + }, + status: { + health: "Saúde", + ok: "OK", + offline: "Offline", + connected: "Conectado", + disconnected: "Desconectado" + }, + nav: { + resources: "Recursos", + docs: "Documentação", + group: { + chat: "Chat", + control: "Controle", + agent: "Agente", + settings: "Configurações" + } + }, + tab: { + title: { + overview: "Visão Geral", + channels: "Canais", + instances: "Instâncias", + sessions: "Sessões", + cron: "Tarefas Cron", + skills: "Skills", + nodes: "Nós", + chat: "Chat", + config: "Configuração", + debug: "Depuração", + logs: "Logs", + control: "Controle" + }, + subtitle: { + overview: "Status do gateway 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.", + cron: "Agendar despertares e execuções recorrentes de agentes.", + skills: "Gerenciar disponibilidade de skills e 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 ~/.clawdbot/moltbot.json com segurança.", + debug: "Snapshots do gateway, eventos e chamadas RPC manuais.", + logs: "Acompanhamento em tempo real dos logs do gateway." + } + }, + overview: { + gateway_access: { + title: "Acesso ao Gateway", + sub: "Onde o painel se conecta e como se autentica." + }, + snapshot: { + title: "Snapshot", + sub: "Informações mais recentes do handshake do gateway." + }, + notes: { + title: "Notas", + sub: "Lembretes rápidos para configurações de controle remoto.", + tailscale: "Tailscale serve", + tailscale_sub: "Prefira o modo serve para manter o gateway em loopback com autenticação tailnet.", + session: "Higiene de sessão", + session_sub: "Use /new ou sessions.patch para redefinir o contexto.", + cron: "Lembretes do Cron", + cron_sub: "Use sessões isoladas para execuções recorrentes." + }, + field: { + websocket: "URL do WebSocket", + token: "Token do Gateway", + password: "Senha (não armazenada)", + session: "Chave de Sessão Padrão", + language: "Idioma" + }, + stats: { + uptime: "Tempo de Atividade", + tick: "Intervalo de Tick", + last_refresh: "Última Atualização de Canais", + }, + action: { + connect: "Conectar", + refresh: "Atualizar" + }, + hint: { + connect_apply: "Clique em Conectar para aplicar as alterações.", + use_channels: "Use Canais para vincular WhatsApp, Telegram, Discord, Signal ou iMessage.", + auth_failed: "Falha na autenticação. Copie novamente uma URL tokenizada com `moltbot dashboard --no-open`, ou atualize o token, depois clique em Conectar.", + https_required: "Esta página é HTTP, então o navegador bloqueia a identidade do dispositivo. Use HTTPS (Tailscale Serve) ou abra http://127.0.0.1:18789 no host do gateway." + } + } +}; diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts new file mode 100644 index 000000000..cb25d40d3 --- /dev/null +++ b/ui/src/i18n/test/translate.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { i18n } from '../lib/translate'; + +describe('i18n', () => { + beforeEach(() => { + i18n.setResources('en', { + hello: 'Hello World', + greeting: 'Hello {name}', + nested: { + key: 'Nested Value' + } + }); + i18n.setResources('pt-BR', { + hello: 'Olá Mundo', + greeting: 'Olá {name}' + }); + i18n.setLocale('en'); + }); + + it('translates simple keys', () => { + expect(i18n.t('hello')).toBe('Hello World'); + }); + + it('translates with interpolation', () => { + expect(i18n.t('greeting', { name: 'Alice' })).toBe('Hello Alice'); + }); + + it('handles nested keys', () => { + expect(i18n.t('nested.key')).toBe('Nested Value'); + }); + + it('switches locales', () => { + i18n.setLocale('pt-BR'); + expect(i18n.t('hello')).toBe('Olá Mundo'); + }); + + it('fallbacks to en', () => { + i18n.setLocale('pt-BR'); + // 'nested.key' is not in pt-BR + expect(i18n.t('nested.key')).toBe('Nested Value'); + }); + + it('returns key if missing', () => { + expect(i18n.t('missing.key')).toBe('missing.key'); + }); +}); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index a088c33ff..75173e8c5 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -81,6 +81,7 @@ import { import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron"; import { loadDebug, callDebugMethod } from "./controllers/debug"; import { loadLogs } from "./controllers/logs"; +import { t } from "../i18n"; const AVATAR_DATA_RE = /^data:/i; const AVATAR_HTTP_RE = /^https?:\/\//i; @@ -104,7 +105,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("status.disconnected") + " from gateway."; const isChat = state.tab === "chat"; const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding); const showThinking = state.onboarding ? false : state.settings.chatShowThinking; @@ -118,12 +119,19 @@ export function renderApp(state: AppViewState) { @@ -132,36 +140,41 @@ export function renderApp(state: AppViewState) { Moltbot
-
MOLTBOT
-
Gateway Dashboard
+
+
${t("brand.title")}
+
${t("brand.subtitle")}
+
- Health - ${state.connected ? "OK" : "Offline"} +
+ + ${t("status.health")} + ${state.connected ? t("status.ok") : t("status.offline")} +
${renderThemeToggle(state)}