From e4523991e3eda273dffa3e155601a933f20e29fa Mon Sep 17 00:00:00 2001 From: Mike Date: Thu, 29 Jan 2026 19:53:08 +0800 Subject: [PATCH] feat(ui): add i18n support with Simplified Chinese (zh-CN) - Add lightweight i18n module with auto-detection - Add English (en) and Simplified Chinese (zh-CN) locales - Update navigation.ts to use i18n for tab titles and descriptions - Support browser language detection and localStorage persistence Closes #3902 --- ui/src/i18n/index.ts | 149 +++++++++++++++++++++++++++++++++ ui/src/i18n/locales/en.ts | 151 +++++++++++++++++++++++++++++++++ ui/src/i18n/locales/zh-CN.ts | 151 +++++++++++++++++++++++++++++++++ ui/src/i18n/types.ts | 157 +++++++++++++++++++++++++++++++++++ ui/src/ui/navigation.ts | 71 +++------------- 5 files changed, 621 insertions(+), 58 deletions(-) create mode 100644 ui/src/i18n/index.ts create mode 100644 ui/src/i18n/locales/en.ts create mode 100644 ui/src/i18n/locales/zh-CN.ts create mode 100644 ui/src/i18n/types.ts diff --git a/ui/src/i18n/index.ts b/ui/src/i18n/index.ts new file mode 100644 index 000000000..edc3916a2 --- /dev/null +++ b/ui/src/i18n/index.ts @@ -0,0 +1,149 @@ +/** + * Lightweight i18n module for Control UI + * + * Usage: + * import { t, setLocale, getLocale } from './i18n'; + * + * // Get translated string + * t('nav.chat') // => "Chat" or "聊天" + * + * // Change locale + * setLocale('zh-CN'); + * + * // Get current locale + * getLocale() // => 'zh-CN' + */ + +import type { Locale, TranslationKey, TranslationKeys } from './types'; +import { en } from './locales/en'; +import { zhCN } from './locales/zh-CN'; + +const STORAGE_KEY = 'moltbot-locale'; + +const locales: Record = { + 'en': en, + 'zh-CN': zhCN, +}; + +let currentLocale: Locale = 'en'; + +/** + * Detect the best locale based on browser settings + */ +function detectLocale(): Locale { + // Check localStorage first + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && isValidLocale(stored)) { + return stored as Locale; + } + } catch { + // localStorage not available + } + + // Check browser language + const browserLang = navigator.language || (navigator as any).userLanguage || 'en'; + + // Check for exact match + if (isValidLocale(browserLang)) { + return browserLang as Locale; + } + + // Check for language code match (e.g., 'zh' matches 'zh-CN') + const langCode = browserLang.split('-')[0].toLowerCase(); + if (langCode === 'zh') { + return 'zh-CN'; + } + + return 'en'; +} + +/** + * Check if a locale is valid + */ +function isValidLocale(locale: string): locale is Locale { + return locale in locales; +} + +/** + * Get the current locale + */ +export function getLocale(): Locale { + return currentLocale; +} + +/** + * Get all available locales + */ +export function getAvailableLocales(): Locale[] { + return Object.keys(locales) as Locale[]; +} + +/** + * Get locale display name + */ +export function getLocaleDisplayName(locale: Locale): string { + switch (locale) { + case 'en': + return 'English'; + case 'zh-CN': + return '简体中文'; + default: + return locale; + } +} + +/** + * Set the current locale + */ +export function setLocale(locale: Locale): void { + if (!isValidLocale(locale)) { + console.warn(`Invalid locale: ${locale}, falling back to 'en'`); + locale = 'en'; + } + + currentLocale = locale; + + // Persist to localStorage + try { + localStorage.setItem(STORAGE_KEY, locale); + } catch { + // localStorage not available + } + + // Dispatch event for components to react + window.dispatchEvent(new CustomEvent('locale-changed', { detail: { locale } })); +} + +/** + * Get a translated string + */ +export function t(key: TranslationKey): string { + const translations = locales[currentLocale] ?? locales['en']; + return translations[key] ?? key; +} + +/** + * Get a translated string with interpolation + * + * Usage: + * tf('greeting', { name: 'World' }) // "Hello, {name}!" => "Hello, World!" + */ +export function tf(key: TranslationKey, params: Record): string { + let result = t(key); + for (const [param, value] of Object.entries(params)) { + result = result.replace(new RegExp(`\\{${param}\\}`, 'g'), String(value)); + } + return result; +} + +/** + * Initialize i18n with auto-detection + */ +export function initI18n(): Locale { + currentLocale = detectLocale(); + return currentLocale; +} + +// Auto-initialize on module load +initI18n(); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts new file mode 100644 index 000000000..db4af7da1 --- /dev/null +++ b/ui/src/i18n/locales/en.ts @@ -0,0 +1,151 @@ +import type { TranslationKeys } from '../types'; + +export const en: TranslationKeys = { + // Navigation groups + 'nav.chat': 'Chat', + 'nav.control': 'Control', + 'nav.agent': 'Agent', + 'nav.settings': 'Settings', + + // Tab titles + 'tab.overview': 'Overview', + 'tab.channels': 'Channels', + 'tab.instances': 'Instances', + 'tab.sessions': 'Sessions', + 'tab.cron': 'Cron Jobs', + 'tab.skills': 'Skills', + 'tab.nodes': 'Nodes', + 'tab.chat': 'Chat', + 'tab.config': 'Config', + 'tab.debug': 'Debug', + 'tab.logs': 'Logs', + + // Tab subtitles/descriptions + 'tab.overview.desc': 'Gateway status, entry points, and a fast health read.', + 'tab.channels.desc': 'Manage channels and settings.', + 'tab.instances.desc': 'Presence beacons from connected clients and nodes.', + 'tab.sessions.desc': 'Inspect active sessions and adjust per-session defaults.', + 'tab.cron.desc': 'Schedule wakeups and recurring agent runs.', + 'tab.skills.desc': 'Manage skill availability and API key injection.', + 'tab.nodes.desc': 'Paired devices, capabilities, and command exposure.', + 'tab.chat.desc': 'Direct gateway chat session for quick interventions.', + 'tab.config.desc': 'Edit ~/.clawdbot/clawdbot.json safely.', + 'tab.debug.desc': 'Gateway snapshots, events, and manual RPC calls.', + 'tab.logs.desc': 'Live tail of the gateway file logs.', + + // Common actions + 'action.save': 'Save', + 'action.cancel': 'Cancel', + 'action.apply': 'Apply', + 'action.reset': 'Reset', + 'action.delete': 'Delete', + 'action.edit': 'Edit', + 'action.add': 'Add', + 'action.remove': 'Remove', + 'action.refresh': 'Refresh', + 'action.copy': 'Copy', + 'action.close': 'Close', + 'action.confirm': 'Confirm', + 'action.send': 'Send', + 'action.stop': 'Stop', + 'action.retry': 'Retry', + + // Status + 'status.online': 'Online', + 'status.offline': 'Offline', + 'status.connected': 'Connected', + 'status.disconnected': 'Disconnected', + 'status.loading': 'Loading...', + 'status.error': 'Error', + 'status.success': 'Success', + 'status.pending': 'Pending', + 'status.idle': 'Idle', + 'status.running': 'Running', + 'status.ok': 'OK', + + // Header + 'header.health': 'Health', + 'header.brand.title': 'MOLTBOT', + 'header.brand.sub': 'Gateway Dashboard', + 'header.expandSidebar': 'Expand sidebar', + 'header.collapseSidebar': 'Collapse sidebar', + + // Theme + 'theme.light': 'Light', + 'theme.dark': 'Dark', + 'theme.system': 'System', + + // Chat + 'chat.placeholder': 'Type a message...', + 'chat.send': 'Send', + 'chat.thinking': 'Thinking...', + 'chat.attachFile': 'Attach file', + 'chat.clearHistory': 'Clear history', + + // Channels + 'channels.whatsapp': 'WhatsApp', + 'channels.telegram': 'Telegram', + 'channels.discord': 'Discord', + 'channels.slack': 'Slack', + 'channels.signal': 'Signal', + 'channels.imessage': 'iMessage', + 'channels.nostr': 'Nostr', + 'channels.googlechat': 'Google Chat', + + // Sessions + 'sessions.title': 'Sessions', + 'sessions.active': 'Active', + 'sessions.tokens': 'Tokens', + 'sessions.model': 'Model', + 'sessions.lastActivity': 'Last Activity', + + // Cron + 'cron.title': 'Cron Jobs', + 'cron.schedule': 'Schedule', + 'cron.nextRun': 'Next Run', + 'cron.lastRun': 'Last Run', + 'cron.enabled': 'Enabled', + 'cron.disabled': 'Disabled', + 'cron.addJob': 'Add Job', + + // Config + 'config.title': 'Configuration', + 'config.saved': 'Saved', + 'config.unsaved': 'Unsaved changes', + 'config.saveChanges': 'Save Changes', + 'config.discardChanges': 'Discard Changes', + + // Logs + 'logs.title': 'Logs', + 'logs.level': 'Level', + 'logs.filter': 'Filter', + 'logs.export': 'Export', + 'logs.clear': 'Clear', + + // Skills + 'skills.title': 'Skills', + 'skills.installed': 'Installed', + 'skills.available': 'Available', + 'skills.install': 'Install', + 'skills.uninstall': 'Uninstall', + + // Nodes + 'nodes.title': 'Nodes', + 'nodes.paired': 'Paired', + 'nodes.pending': 'Pending', + 'nodes.approve': 'Approve', + 'nodes.reject': 'Reject', + + // Errors + 'error.connection': 'Connection error', + 'error.timeout': 'Request timed out', + 'error.unknown': 'An unknown error occurred', + 'error.invalidInput': 'Invalid input', + + // Misc + 'misc.noData': 'No data', + 'misc.loading': 'Loading...', + 'misc.never': 'Never', + 'misc.justNow': 'Just now', + 'misc.ago': 'ago', +}; diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts new file mode 100644 index 000000000..a6e4f9082 --- /dev/null +++ b/ui/src/i18n/locales/zh-CN.ts @@ -0,0 +1,151 @@ +import type { TranslationKeys } from '../types'; + +export const zhCN: TranslationKeys = { + // Navigation groups + 'nav.chat': '聊天', + 'nav.control': '控制', + 'nav.agent': '代理', + 'nav.settings': '设置', + + // Tab titles + 'tab.overview': '概览', + 'tab.channels': '通道', + 'tab.instances': '实例', + 'tab.sessions': '会话', + 'tab.cron': '定时任务', + 'tab.skills': '技能', + 'tab.nodes': '节点', + 'tab.chat': '聊天', + 'tab.config': '配置', + 'tab.debug': '调试', + 'tab.logs': '日志', + + // Tab subtitles/descriptions + 'tab.overview.desc': '网关状态、入口点和快速健康检查。', + 'tab.channels.desc': '管理通道和设置。', + 'tab.instances.desc': '来自已连接客户端和节点的状态信标。', + 'tab.sessions.desc': '检查活跃会话并调整每会话默认设置。', + 'tab.cron.desc': '安排唤醒和定期代理运行。', + 'tab.skills.desc': '管理技能可用性和 API 密钥注入。', + 'tab.nodes.desc': '已配对设备、功能和命令暴露。', + 'tab.chat.desc': '直接网关聊天会话,用于快速干预。', + 'tab.config.desc': '安全编辑 ~/.clawdbot/clawdbot.json。', + 'tab.debug.desc': '网关快照、事件和手动 RPC 调用。', + 'tab.logs.desc': '实时查看网关文件日志。', + + // Common actions + 'action.save': '保存', + 'action.cancel': '取消', + 'action.apply': '应用', + 'action.reset': '重置', + 'action.delete': '删除', + 'action.edit': '编辑', + 'action.add': '添加', + 'action.remove': '移除', + 'action.refresh': '刷新', + 'action.copy': '复制', + 'action.close': '关闭', + 'action.confirm': '确认', + 'action.send': '发送', + 'action.stop': '停止', + 'action.retry': '重试', + + // Status + 'status.online': '在线', + 'status.offline': '离线', + 'status.connected': '已连接', + 'status.disconnected': '已断开', + 'status.loading': '加载中...', + 'status.error': '错误', + 'status.success': '成功', + 'status.pending': '等待中', + 'status.idle': '空闲', + 'status.running': '运行中', + 'status.ok': '正常', + + // Header + 'header.health': '健康状态', + 'header.brand.title': 'MOLTBOT', + 'header.brand.sub': '网关控制台', + 'header.expandSidebar': '展开侧边栏', + 'header.collapseSidebar': '收起侧边栏', + + // Theme + 'theme.light': '浅色', + 'theme.dark': '深色', + 'theme.system': '跟随系统', + + // Chat + 'chat.placeholder': '输入消息...', + 'chat.send': '发送', + 'chat.thinking': '思考中...', + 'chat.attachFile': '添加附件', + 'chat.clearHistory': '清除历史', + + // Channels + 'channels.whatsapp': 'WhatsApp', + 'channels.telegram': 'Telegram', + 'channels.discord': 'Discord', + 'channels.slack': 'Slack', + 'channels.signal': 'Signal', + 'channels.imessage': 'iMessage', + 'channels.nostr': 'Nostr', + 'channels.googlechat': 'Google Chat', + + // Sessions + 'sessions.title': '会话', + 'sessions.active': '活跃', + 'sessions.tokens': 'Token 数', + 'sessions.model': '模型', + 'sessions.lastActivity': '最后活动', + + // Cron + 'cron.title': '定时任务', + 'cron.schedule': '调度', + 'cron.nextRun': '下次运行', + 'cron.lastRun': '上次运行', + 'cron.enabled': '已启用', + 'cron.disabled': '已禁用', + 'cron.addJob': '添加任务', + + // Config + 'config.title': '配置', + 'config.saved': '已保存', + 'config.unsaved': '有未保存的更改', + 'config.saveChanges': '保存更改', + 'config.discardChanges': '放弃更改', + + // Logs + 'logs.title': '日志', + 'logs.level': '级别', + 'logs.filter': '筛选', + 'logs.export': '导出', + 'logs.clear': '清空', + + // Skills + 'skills.title': '技能', + 'skills.installed': '已安装', + 'skills.available': '可用', + 'skills.install': '安装', + 'skills.uninstall': '卸载', + + // Nodes + 'nodes.title': '节点', + 'nodes.paired': '已配对', + 'nodes.pending': '等待中', + 'nodes.approve': '批准', + 'nodes.reject': '拒绝', + + // Errors + 'error.connection': '连接错误', + 'error.timeout': '请求超时', + 'error.unknown': '发生未知错误', + 'error.invalidInput': '输入无效', + + // Misc + 'misc.noData': '暂无数据', + 'misc.loading': '加载中...', + 'misc.never': '从未', + 'misc.justNow': '刚刚', + 'misc.ago': '前', +}; diff --git a/ui/src/i18n/types.ts b/ui/src/i18n/types.ts new file mode 100644 index 000000000..4a3f31344 --- /dev/null +++ b/ui/src/i18n/types.ts @@ -0,0 +1,157 @@ +/** + * i18n type definitions for Control UI + */ + +export type Locale = 'en' | 'zh-CN'; + +export interface TranslationKeys { + // Navigation groups + 'nav.chat': string; + 'nav.control': string; + 'nav.agent': string; + 'nav.settings': string; + + // Tab titles + 'tab.overview': string; + 'tab.channels': string; + 'tab.instances': string; + 'tab.sessions': string; + 'tab.cron': string; + 'tab.skills': string; + 'tab.nodes': string; + 'tab.chat': string; + 'tab.config': string; + 'tab.debug': string; + 'tab.logs': string; + + // Tab subtitles/descriptions + 'tab.overview.desc': string; + 'tab.channels.desc': string; + 'tab.instances.desc': string; + 'tab.sessions.desc': string; + 'tab.cron.desc': string; + 'tab.skills.desc': string; + 'tab.nodes.desc': string; + 'tab.chat.desc': string; + 'tab.config.desc': string; + 'tab.debug.desc': string; + 'tab.logs.desc': string; + + // Common actions + 'action.save': string; + 'action.cancel': string; + 'action.apply': string; + 'action.reset': string; + 'action.delete': string; + 'action.edit': string; + 'action.add': string; + 'action.remove': string; + 'action.refresh': string; + 'action.copy': string; + 'action.close': string; + 'action.confirm': string; + 'action.send': string; + 'action.stop': string; + 'action.retry': string; + + // Status + 'status.online': string; + 'status.offline': string; + 'status.connected': string; + 'status.disconnected': string; + 'status.loading': string; + 'status.error': string; + 'status.success': string; + 'status.pending': string; + 'status.idle': string; + 'status.running': string; + 'status.ok': string; + + // Header + 'header.health': string; + 'header.brand.title': string; + 'header.brand.sub': string; + 'header.expandSidebar': string; + 'header.collapseSidebar': string; + + // Theme + 'theme.light': string; + 'theme.dark': string; + 'theme.system': string; + + // Chat + 'chat.placeholder': string; + 'chat.send': string; + 'chat.thinking': string; + 'chat.attachFile': string; + 'chat.clearHistory': string; + + // Channels + 'channels.whatsapp': string; + 'channels.telegram': string; + 'channels.discord': string; + 'channels.slack': string; + 'channels.signal': string; + 'channels.imessage': string; + 'channels.nostr': string; + 'channels.googlechat': string; + + // Sessions + 'sessions.title': string; + 'sessions.active': string; + 'sessions.tokens': string; + 'sessions.model': string; + 'sessions.lastActivity': string; + + // Cron + 'cron.title': string; + 'cron.schedule': string; + 'cron.nextRun': string; + 'cron.lastRun': string; + 'cron.enabled': string; + 'cron.disabled': string; + 'cron.addJob': string; + + // Config + 'config.title': string; + 'config.saved': string; + 'config.unsaved': string; + 'config.saveChanges': string; + 'config.discardChanges': string; + + // Logs + 'logs.title': string; + 'logs.level': string; + 'logs.filter': string; + 'logs.export': string; + 'logs.clear': string; + + // Skills + 'skills.title': string; + 'skills.installed': string; + 'skills.available': string; + 'skills.install': string; + 'skills.uninstall': string; + + // Nodes + 'nodes.title': string; + 'nodes.paired': string; + 'nodes.pending': string; + 'nodes.approve': string; + 'nodes.reject': string; + + // Errors + 'error.connection': string; + 'error.timeout': string; + 'error.unknown': string; + 'error.invalidInput': string; + + // Misc + 'misc.noData': string; + 'misc.loading': string; + 'misc.never': string; + 'misc.justNow': string; + 'misc.ago': string; +} + +export type TranslationKey = keyof TranslationKeys; diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts index 966abec96..25730014f 100644 --- a/ui/src/ui/navigation.ts +++ b/ui/src/ui/navigation.ts @@ -1,15 +1,20 @@ import type { IconName } from "./icons.js"; +import { t } from "../i18n"; export const TAB_GROUPS = [ - { label: "Chat", tabs: ["chat"] }, + { labelKey: "nav.chat" as const, tabs: ["chat"] }, { - label: "Control", + labelKey: "nav.control" as const, tabs: ["overview", "channels", "instances", "sessions", "cron"], }, - { label: "Agent", tabs: ["skills", "nodes"] }, - { label: "Settings", tabs: ["config", "debug", "logs"] }, + { labelKey: "nav.agent" as const, tabs: ["skills", "nodes"] }, + { labelKey: "nav.settings" as const, tabs: ["config", "debug", "logs"] }, ] as const; +export function getTabGroupLabel(labelKey: string): string { + return t(labelKey as any); +} + export type Tab = | "overview" | "channels" @@ -129,60 +134,10 @@ export function iconForTab(tab: Tab): IconName { } } -export function titleForTab(tab: Tab) { - switch (tab) { - case "overview": - return "Overview"; - case "channels": - return "Channels"; - case "instances": - return "Instances"; - case "sessions": - return "Sessions"; - case "cron": - return "Cron Jobs"; - case "skills": - return "Skills"; - case "nodes": - return "Nodes"; - case "chat": - return "Chat"; - case "config": - return "Config"; - case "debug": - return "Debug"; - case "logs": - return "Logs"; - default: - return "Control"; - } +export function titleForTab(tab: Tab): string { + return t(`tab.${tab}` as any); } -export function subtitleForTab(tab: Tab) { - switch (tab) { - case "overview": - return "Gateway status, entry points, and a fast health read."; - case "channels": - return "Manage channels and settings."; - case "instances": - return "Presence beacons from connected clients and nodes."; - case "sessions": - return "Inspect active sessions and adjust per-session defaults."; - case "cron": - return "Schedule wakeups and recurring agent runs."; - case "skills": - return "Manage skill availability and API key injection."; - case "nodes": - return "Paired devices, capabilities, and command exposure."; - case "chat": - return "Direct gateway chat session for quick interventions."; - case "config": - return "Edit ~/.clawdbot/moltbot.json safely."; - case "debug": - return "Gateway snapshots, events, and manual RPC calls."; - case "logs": - return "Live tail of the gateway file logs."; - default: - return ""; - } +export function subtitleForTab(tab: Tab): string { + return t(`tab.${tab}.desc` as any); }