From a2497ecaab5055d294abfd4d0101b8449f5e41ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 23:00:19 +0000 Subject: [PATCH 1/8] feat(ui): add i18n infrastructure and Traditional Chinese localization - Create i18n module with t(), setLocale(), getLocale(), initLocale() functions - Add English (en-US) and Traditional Chinese (zh-TW) locale files - Translate overview, chat, sessions, logs views - Add language switcher component in header - Use Taiwan-style software terminology --- ui/src/i18n/index.ts | 155 ++++++++++ ui/src/i18n/locales/en-US.ts | 520 +++++++++++++++++++++++++++++++ ui/src/i18n/locales/zh-TW.ts | 527 ++++++++++++++++++++++++++++++++ ui/src/main.ts | 5 + ui/src/styles/components.css | 33 ++ ui/src/ui/app-render.helpers.ts | 38 ++- ui/src/ui/app-render.ts | 25 +- ui/src/ui/navigation.ts | 70 +---- ui/src/ui/views/chat.ts | 33 +- ui/src/ui/views/logs.ts | 25 +- ui/src/ui/views/overview.ts | 110 +++---- ui/src/ui/views/sessions.ts | 47 +-- 12 files changed, 1414 insertions(+), 174 deletions(-) create mode 100644 ui/src/i18n/index.ts create mode 100644 ui/src/i18n/locales/en-US.ts create mode 100644 ui/src/i18n/locales/zh-TW.ts diff --git a/ui/src/i18n/index.ts b/ui/src/i18n/index.ts new file mode 100644 index 000000000..79d62dd17 --- /dev/null +++ b/ui/src/i18n/index.ts @@ -0,0 +1,155 @@ +/** + * Moltbot Control UI - Internationalization (i18n) System + * + * A lightweight i18n implementation for the Lit-based Control UI. + * Supports nested translation keys, interpolation, and pluralization. + */ + +import { zhTW } from './locales/zh-TW'; +import { enUS } from './locales/en-US'; + +export type Locale = 'en-US' | 'zh-TW'; + +export type TranslationDict = Record; + +const locales: Record = { + 'en-US': enUS, + 'zh-TW': zhTW, +}; + +let currentLocale: Locale = 'zh-TW'; // Default to Traditional Chinese + +/** + * Get the current locale + */ +export function getLocale(): Locale { + return currentLocale; +} + +/** + * Set the current locale + */ +export function setLocale(locale: Locale): void { + if (locales[locale]) { + currentLocale = locale; + // Store preference in localStorage + try { + localStorage.setItem('moltbot-locale', locale); + } catch { + // Ignore storage errors + } + // Dispatch event for components to react + window.dispatchEvent(new CustomEvent('locale-changed', { detail: { locale } })); + } +} + +/** + * Initialize locale from stored preference or browser settings + */ +export function initLocale(): void { + try { + const stored = localStorage.getItem('moltbot-locale') as Locale | null; + if (stored && locales[stored]) { + currentLocale = stored; + return; + } + } catch { + // Ignore storage errors + } + + // Detect from browser + const browserLang = navigator.language; + if (browserLang.startsWith('zh')) { + currentLocale = 'zh-TW'; + } else { + currentLocale = 'en-US'; + } +} + +/** + * Get a nested value from an object using a dot-separated path + */ +function getNestedValue(obj: TranslationDict, path: string): string | undefined { + const keys = path.split('.'); + let current: string | TranslationDict | undefined = obj; + + for (const key of keys) { + if (current === undefined || typeof current === 'string') { + return undefined; + } + current = current[key]; + } + + return typeof current === 'string' ? current : undefined; +} + +/** + * Interpolate variables in a string + * Supports {{variable}} syntax + */ +function interpolate(template: string, values?: Record): string { + if (!values) return template; + + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => { + const value = values[key]; + return value !== undefined ? String(value) : `{{${key}}}`; + }); +} + +/** + * Main translation function + * + * @param key - Dot-separated translation key (e.g., 'nav.overview') + * @param values - Optional interpolation values + * @returns Translated string or the key if not found + * + * @example + * t('nav.overview') // "總覽" + * t('chat.messageCount', { count: 5 }) // "5 則訊息" + */ +export function t(key: string, values?: Record): string { + const dict = locales[currentLocale]; + const translation = getNestedValue(dict, key); + + if (translation === undefined) { + // Fallback to English + const fallback = getNestedValue(locales['en-US'], key); + if (fallback !== undefined) { + return interpolate(fallback, values); + } + // Return the key as last resort (helps identify missing translations) + console.warn(`[i18n] Missing translation: ${key}`); + return key; + } + + return interpolate(translation, values); +} + +/** + * Check if a translation key exists + */ +export function hasTranslation(key: string): boolean { + const dict = locales[currentLocale]; + return getNestedValue(dict, key) !== undefined; +} + +/** + * Get all available locales + */ +export function getAvailableLocales(): Locale[] { + return Object.keys(locales) as Locale[]; +} + +/** + * Get locale display name + */ +export function getLocaleDisplayName(locale: Locale): string { + const names: Record = { + 'en-US': 'English', + 'zh-TW': '繁體中文', + }; + return names[locale] || locale; +} + +// Initialize locale on module load +initLocale(); diff --git a/ui/src/i18n/locales/en-US.ts b/ui/src/i18n/locales/en-US.ts new file mode 100644 index 000000000..2ab24b4e4 --- /dev/null +++ b/ui/src/i18n/locales/en-US.ts @@ -0,0 +1,520 @@ +/** + * English (US) translations - Baseline + */ +export const enUS = { + // Common + common: { + loading: "Loading…", + refresh: "Refresh", + save: "Save", + saving: "Saving…", + apply: "Apply", + applying: "Applying…", + cancel: "Cancel", + delete: "Delete", + edit: "Edit", + close: "Close", + yes: "Yes", + no: "No", + ok: "OK", + error: "Error", + success: "Success", + warning: "Warning", + info: "Info", + enabled: "Enabled", + disabled: "Disabled", + configured: "Configured", + connected: "Connected", + disconnected: "Disconnected", + running: "Running", + stopped: "Stopped", + active: "Active", + inactive: "Inactive", + unknown: "Unknown", + none: "None", + all: "All", + search: "Search", + filter: "Filter", + export: "Export", + import: "Import", + copy: "Copy", + copied: "Copied!", + na: "n/a", + optional: "(optional)", + required: "Required", + inherit: "inherit", + actions: "Actions", + }, + + // App header and branding + app: { + title: "MOLTBOT", + subtitle: "Gateway Dashboard", + health: "Health", + offline: "Offline", + expandSidebar: "Expand sidebar", + collapseSidebar: "Collapse sidebar", + }, + + // Navigation + nav: { + groups: { + chat: "Chat", + control: "Control", + agent: "Agent", + settings: "Settings", + resources: "Resources", + }, + tabs: { + overview: "Overview", + channels: "Channels", + instances: "Instances", + sessions: "Sessions", + cron: "Cron Jobs", + skills: "Skills", + nodes: "Nodes", + chat: "Chat", + config: "Config", + debug: "Debug", + logs: "Logs", + }, + subtitles: { + 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.", + }, + docs: "Docs", + docsTooltip: "Docs (opens in new tab)", + }, + + // Overview page + overview: { + gatewayAccess: "Gateway Access", + gatewayAccessDesc: "Where the dashboard connects and how it authenticates.", + websocketUrl: "WebSocket URL", + gatewayToken: "Gateway Token", + password: "Password (not stored)", + passwordPlaceholder: "system or shared password", + defaultSessionKey: "Default Session Key", + connect: "Connect", + connectNote: "Click Connect to apply connection changes.", + + snapshot: "Snapshot", + snapshotDesc: "Latest gateway handshake information.", + status: "Status", + uptime: "Uptime", + tickInterval: "Tick Interval", + lastChannelsRefresh: "Last Channels Refresh", + + authRequired: "This gateway requires auth. Add a token or password, then click Connect.", + authFailed: "Auth failed. Re-copy a tokenized URL with", + authDocsLink: "Docs: Control UI auth", + tokenizedUrlCmd: "moltbot dashboard --no-open", + generateTokenCmd: "moltbot doctor --generate-gateway-token", + thenClickConnect: ", or update the token, then click Connect.", + + insecureContext: "This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open", + insecureContextLocal: "on the gateway host.", + insecureContextConfig: "If you must stay on HTTP, set", + insecureContextConfigValue: "gateway.controlUi.allowInsecureAuth: true", + insecureContextNote: "(token-only).", + tailscaleDocsLink: "Docs: Tailscale Serve", + insecureHttpDocsLink: "Docs: Insecure HTTP", + + channelsHint: "Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.", + + instancesCard: "Instances", + instancesDesc: "Presence beacons in the last 5 minutes.", + sessionsCard: "Sessions", + sessionsDesc: "Recent session keys tracked by the gateway.", + cronCard: "Cron", + nextWake: "Next wake", + + notes: "Notes", + notesDesc: "Quick reminders for remote control setups.", + tailscaleServe: "Tailscale serve", + tailscaleServeDesc: "Prefer serve mode to keep the gateway on loopback with tailnet auth.", + sessionHygiene: "Session hygiene", + sessionHygieneDesc: "Use /new or sessions.patch to reset context.", + cronReminders: "Cron reminders", + cronRemindersDesc: "Use isolated sessions for recurring runs.", + }, + + // Chat page + chat: { + message: "Message", + messagePlaceholder: "Message (↩ to send, Shift+↩ for line breaks, paste images)", + messagePlaceholderWithImages: "Add a message or paste more images...", + connectToChat: "Connect to the gateway to start chatting…", + send: "Send", + queue: "Queue", + stop: "Stop", + newSession: "New session", + loadingChat: "Loading chat…", + compacting: "Compacting context...", + compacted: "Context compacted", + queued: "Queued", + removeQueued: "Remove queued message", + exitFocusMode: "Exit focus mode", + removeAttachment: "Remove attachment", + attachmentPreview: "Attachment preview", + showingLast: "Showing last {{count}} messages ({{hidden}} hidden).", + image: "Image", + }, + + // Channels page + channels: { + title: "Channels", + health: "Channel health", + healthDesc: "Channel status snapshots from the gateway.", + noSnapshot: "No snapshot yet.", + statusAndConfig: "Channel status and configuration.", + lastInbound: "Last inbound", + + // Status labels + labels: { + configured: "Configured", + running: "Running", + connected: "Connected", + }, + + // WhatsApp + whatsapp: { + title: "WhatsApp", + desc: "WhatsApp via Baileys (multi-device).", + start: "Start", + relink: "Relink", + logout: "Logout", + scanQr: "Scan QR with WhatsApp on your phone.", + linking: "Linking…", + waitingForQr: "Waiting for QR code…", + notConfigured: "Not configured.", + }, + + // Telegram + telegram: { + title: "Telegram", + desc: "Telegram bot(s) via Grammy.", + }, + + // Discord + discord: { + title: "Discord", + desc: "Discord bot via discord.js.", + }, + + // Slack + slack: { + title: "Slack", + desc: "Slack app via Bolt framework.", + }, + + // Signal + signal: { + title: "Signal", + desc: "Signal via signal-cli or linked device.", + }, + + // iMessage + imessage: { + title: "iMessage", + desc: "iMessage via BlueBubbles server.", + }, + + // Google Chat + googlechat: { + title: "Google Chat", + desc: "Google Chat via service account.", + }, + + // Nostr + nostr: { + title: "Nostr", + desc: "Nostr protocol via NIP-04 DMs.", + editProfile: "Edit Profile", + profileForm: { + title: "Edit Nostr Profile", + name: "Display Name", + about: "About", + picture: "Picture URL", + nip05: "NIP-05 Identifier", + lud16: "Lightning Address", + banner: "Banner URL", + website: "Website", + showAdvanced: "Show advanced fields", + hideAdvanced: "Hide advanced fields", + importFromRelays: "Import from relays", + importing: "Importing…", + }, + }, + + // Config section + config: { + title: "Channel Configuration", + saveChanges: "Save Changes", + reloadConfig: "Reload Config", + unsavedChanges: "Unsaved changes", + }, + }, + + // Sessions page + sessions: { + title: "Sessions", + desc: "Active session keys and per-session overrides.", + activeWithin: "Active within (minutes)", + limit: "Limit", + includeGlobal: "Include global", + includeUnknown: "Include unknown", + store: "Store", + noSessions: "No sessions found.", + + columns: { + key: "Key", + label: "Label", + kind: "Kind", + updated: "Updated", + tokens: "Tokens", + thinking: "Thinking", + verbose: "Verbose", + reasoning: "Reasoning", + actions: "Actions", + }, + + levels: { + off: "off", + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + on: "on", + stream: "stream", + offExplicit: "off (explicit)", + }, + }, + + // Cron page + cron: { + title: "Cron Jobs", + desc: "Scheduled agent wakeups and recurring tasks.", + noJobs: "No cron jobs configured.", + addJob: "Add Job", + runNow: "Run", + remove: "Remove", + enable: "Enable", + disable: "Disable", + runs: "Runs", + lastRun: "Last run", + nextRun: "Next run", + + form: { + schedule: "Schedule (cron)", + message: "Message", + sessionKey: "Session Key", + channel: "Channel", + channelPlaceholder: "Select channel", + enabled: "Enabled", + }, + + status: { + enabled: "Cron Enabled", + disabled: "Cron Disabled", + nextWake: "Next wake", + }, + }, + + // Skills page + skills: { + title: "Skills", + desc: "Manage bundled and installed skills.", + noSkills: "No skills found.", + filter: "Filter skills", + apiKey: "API Key", + saveKey: "Save Key", + install: "Install", + installing: "Installing…", + enabled: "Enabled", + disabled: "Disabled", + toggle: "Toggle", + keySaved: "API key saved", + keyError: "Failed to save API key", + }, + + // Nodes page + nodes: { + title: "Nodes", + desc: "Connected execution nodes and device pairings.", + noNodes: "No nodes connected.", + devices: "Devices", + noDevices: "No paired devices.", + approve: "Approve", + reject: "Reject", + revoke: "Revoke", + rotate: "Rotate", + pending: "Pending", + approved: "Approved", + + bindings: { + title: "Exec Bindings", + desc: "Bind agents to specific execution nodes.", + default: "Default Node", + agent: "Agent", + node: "Node", + save: "Save Bindings", + }, + + approvals: { + title: "Exec Approvals", + desc: "Pre-approve commands for agent execution.", + target: "Target", + gateway: "Gateway", + selectAgent: "Select agent", + addRule: "Add Rule", + noRules: "No approval rules configured.", + }, + }, + + // Config page + config: { + title: "Settings", + desc: "Configuration editor with schema validation.", + valid: "valid", + invalid: "invalid", + searchSettings: "Search settings...", + allSettings: "All Settings", + form: "Form", + raw: "Raw", + rawJson5: "Raw JSON5", + reload: "Reload", + update: "Update", + updating: "Updating…", + noChanges: "No changes", + unsavedChanges: "Unsaved changes", + unsavedCount: "{{count}} unsaved change", + unsavedCountPlural: "{{count}} unsaved changes", + viewPending: "View {{count}} pending change", + viewPendingPlural: "View {{count}} pending changes", + loadingSchema: "Loading schema…", + formUnsafe: "Form view can't safely edit some fields. Use Raw to avoid losing config entries.", + + sections: { + env: "Environment", + update: "Updates", + agents: "Agents", + auth: "Authentication", + channels: "Channels", + messages: "Messages", + commands: "Commands", + hooks: "Hooks", + skills: "Skills", + tools: "Tools", + gateway: "Gateway", + wizard: "Setup Wizard", + }, + }, + + // Debug page + debug: { + title: "Debug", + desc: "Gateway internals and manual RPC testing.", + status: "Status", + health: "Health", + models: "Models", + heartbeat: "Heartbeat", + events: "Events", + rpcCall: "RPC Call", + method: "Method", + params: "Params", + call: "Call", + result: "Result", + noResult: "No result yet.", + }, + + // Logs page + logs: { + title: "Logs", + desc: "Gateway file logs (JSONL).", + filter: "Filter", + searchLogs: "Search logs", + autoFollow: "Auto-follow", + file: "File", + truncated: "Log output truncated; showing latest chunk.", + noEntries: "No log entries.", + exportFiltered: "Export filtered", + exportVisible: "Export visible", + + levels: { + trace: "trace", + debug: "debug", + info: "info", + warn: "warn", + error: "error", + fatal: "fatal", + }, + }, + + // Instances page + instances: { + title: "Instances", + desc: "Presence beacons from connected gateways and nodes.", + noInstances: "No presence beacons found.", + id: "ID", + type: "Type", + version: "Version", + lastSeen: "Last Seen", + }, + + // Exec approval prompt + execApproval: { + title: "Execution Approval Required", + command: "Command", + agent: "Agent", + allowOnce: "Allow Once", + allowAlways: "Allow Always", + deny: "Deny", + }, + + // Theme + theme: { + toggle: "Toggle theme", + light: "Light", + dark: "Dark", + system: "System", + }, + + // Time/date formatting + time: { + justNow: "just now", + minutesAgo: "{{count}}m ago", + hoursAgo: "{{count}}h ago", + daysAgo: "{{count}}d ago", + never: "never", + }, + + // Markdown sidebar + sidebar: { + close: "Close", + viewRaw: "View raw", + error: "Error loading content", + }, + + // Errors + errors: { + connectionFailed: "Connection failed", + loadFailed: "Failed to load", + saveFailed: "Failed to save", + unknownError: "An unknown error occurred", + networkError: "Network error", + timeout: "Request timed out", + unauthorized: "Unauthorized", + forbidden: "Forbidden", + notFound: "Not found", + }, +}; diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts new file mode 100644 index 000000000..f7800f1fb --- /dev/null +++ b/ui/src/i18n/locales/zh-TW.ts @@ -0,0 +1,527 @@ +/** + * Traditional Chinese (Taiwan) translations + * 繁體中文(台灣)翻譯 + * + * 翻譯原則: + * - 使用台灣常見的軟體用語 + * - 保持專業但親切的語氣 + * - 技術名詞保留英文或使用約定俗成的翻譯 + * - 品牌名稱(WhatsApp、Telegram 等)不翻譯 + */ +export const zhTW = { + // 通用 + common: { + loading: "載入中…", + refresh: "重新整理", + save: "儲存", + saving: "儲存中…", + apply: "套用", + applying: "套用中…", + cancel: "取消", + delete: "刪除", + edit: "編輯", + close: "關閉", + yes: "是", + no: "否", + ok: "確定", + error: "錯誤", + success: "成功", + warning: "警告", + info: "資訊", + enabled: "已啟用", + disabled: "已停用", + configured: "已設定", + connected: "已連線", + disconnected: "已斷線", + running: "執行中", + stopped: "已停止", + active: "使用中", + inactive: "閒置中", + unknown: "未知", + none: "無", + all: "全部", + search: "搜尋", + filter: "篩選", + export: "匯出", + import: "匯入", + copy: "複製", + copied: "已複製!", + na: "無資料", + optional: "(選填)", + required: "必填", + inherit: "繼承", + actions: "操作", + }, + + // 應用程式標題與品牌 + app: { + title: "MOLTBOT", + subtitle: "閘道器控制台", + health: "狀態", + offline: "離線", + expandSidebar: "展開側邊欄", + collapseSidebar: "收合側邊欄", + }, + + // 導覽列 + nav: { + groups: { + chat: "對話", + control: "控制", + agent: "代理", + settings: "設定", + resources: "資源", + }, + tabs: { + overview: "總覽", + channels: "頻道", + instances: "實例", + sessions: "工作階段", + cron: "排程任務", + skills: "技能", + nodes: "節點", + chat: "對話", + config: "組態", + debug: "除錯", + logs: "日誌", + }, + subtitles: { + overview: "閘道器狀態、進入點與快速健康檢查。", + channels: "管理頻道與相關設定。", + instances: "來自已連線用戶端與節點的存在訊號。", + sessions: "檢視進行中的工作階段並調整個別設定。", + cron: "排程喚醒與週期性代理執行。", + skills: "管理技能的啟用狀態與 API 金鑰。", + nodes: "已配對的裝置、功能與指令權限。", + chat: "直接與閘道器對話,進行快速操作。", + config: "安全地編輯 ~/.clawdbot/moltbot.json 設定檔。", + debug: "閘道器快照、事件記錄與手動 RPC 呼叫。", + logs: "即時檢視閘道器的檔案日誌。", + }, + docs: "文件", + docsTooltip: "文件(在新分頁開啟)", + }, + + // 總覽頁面 + overview: { + gatewayAccess: "閘道器連線", + gatewayAccessDesc: "控制台連線位置與認證方式。", + websocketUrl: "WebSocket 網址", + gatewayToken: "閘道器 Token", + password: "密碼(不會儲存)", + passwordPlaceholder: "系統或共用密碼", + defaultSessionKey: "預設工作階段金鑰", + connect: "連線", + connectNote: "點擊「連線」以套用連線設定。", + + snapshot: "快照", + snapshotDesc: "最新的閘道器交握資訊。", + status: "狀態", + uptime: "運行時間", + tickInterval: "更新間隔", + lastChannelsRefresh: "上次頻道更新", + + authRequired: "此閘道器需要認證。請新增 Token 或密碼,然後點擊「連線」。", + authFailed: "認證失敗。請重新複製包含 Token 的網址:", + authDocsLink: "文件:控制台認證", + tokenizedUrlCmd: "moltbot dashboard --no-open", + generateTokenCmd: "moltbot doctor --generate-gateway-token", + thenClickConnect: ",或更新 Token,然後點擊「連線」。", + + insecureContext: "此頁面使用 HTTP,瀏覽器會阻擋裝置身分驗證。請使用 HTTPS(Tailscale Serve)或在閘道器主機上開啟", + insecureContextLocal: "。", + insecureContextConfig: "如果必須使用 HTTP,請設定", + insecureContextConfigValue: "gateway.controlUi.allowInsecureAuth: true", + insecureContextNote: "(僅限 Token 認證)。", + tailscaleDocsLink: "文件:Tailscale Serve", + insecureHttpDocsLink: "文件:不安全的 HTTP", + + channelsHint: "使用「頻道」來連結 WhatsApp、Telegram、Discord、Signal 或 iMessage。", + + instancesCard: "實例", + instancesDesc: "過去 5 分鐘內的存在訊號數量。", + sessionsCard: "工作階段", + sessionsDesc: "閘道器追蹤中的近期工作階段金鑰。", + cronCard: "排程", + nextWake: "下次喚醒", + + notes: "備註", + notesDesc: "遠端控制設定的快速提醒。", + tailscaleServe: "Tailscale serve", + tailscaleServeDesc: "建議使用 serve 模式,讓閘道器只在本地介面監聽並透過 tailnet 認證。", + sessionHygiene: "工作階段管理", + sessionHygieneDesc: "使用 /new 或 sessions.patch 重設上下文。", + cronReminders: "排程提醒", + cronRemindersDesc: "週期性執行建議使用獨立的工作階段。", + }, + + // 對話頁面 + chat: { + message: "訊息", + messagePlaceholder: "輸入訊息(Enter 送出,Shift+Enter 換行,可貼上圖片)", + messagePlaceholderWithImages: "新增訊息或貼上更多圖片...", + connectToChat: "請連線到閘道器以開始對話…", + send: "送出", + queue: "排入佇列", + stop: "停止", + newSession: "新工作階段", + loadingChat: "載入對話中…", + compacting: "壓縮上下文中…", + compacted: "上下文已壓縮", + queued: "佇列中", + removeQueued: "移除佇列中的訊息", + exitFocusMode: "離開專注模式", + removeAttachment: "移除附件", + attachmentPreview: "附件預覽", + showingLast: "顯示最後 {{count}} 則訊息(已隱藏 {{hidden}} 則)。", + image: "圖片", + }, + + // 頻道頁面 + channels: { + title: "頻道", + health: "頻道健康狀態", + healthDesc: "來自閘道器的頻道狀態快照。", + noSnapshot: "尚無快照資料。", + statusAndConfig: "頻道狀態與設定。", + lastInbound: "上次收到訊息", + + // 狀態標籤 + labels: { + configured: "已設定", + running: "執行中", + connected: "已連線", + }, + + // WhatsApp + whatsapp: { + title: "WhatsApp", + desc: "透過 Baileys 連接 WhatsApp(多裝置)。", + start: "開始", + relink: "重新連結", + logout: "登出", + scanQr: "請用手機上的 WhatsApp 掃描 QR Code。", + linking: "連結中…", + waitingForQr: "等待 QR Code…", + notConfigured: "尚未設定。", + }, + + // Telegram + telegram: { + title: "Telegram", + desc: "透過 Grammy 連接 Telegram 機器人。", + }, + + // Discord + discord: { + title: "Discord", + desc: "透過 discord.js 連接 Discord 機器人。", + }, + + // Slack + slack: { + title: "Slack", + desc: "透過 Bolt 框架連接 Slack 應用程式。", + }, + + // Signal + signal: { + title: "Signal", + desc: "透過 signal-cli 或已連結裝置連接 Signal。", + }, + + // iMessage + imessage: { + title: "iMessage", + desc: "透過 BlueBubbles 伺服器連接 iMessage。", + }, + + // Google Chat + googlechat: { + title: "Google Chat", + desc: "透過服務帳戶連接 Google Chat。", + }, + + // Nostr + nostr: { + title: "Nostr", + desc: "透過 NIP-04 私訊連接 Nostr 協定。", + editProfile: "編輯個人檔案", + profileForm: { + title: "編輯 Nostr 個人檔案", + name: "顯示名稱", + about: "關於", + picture: "頭像網址", + nip05: "NIP-05 識別碼", + lud16: "閃電網路地址", + banner: "橫幅圖片網址", + website: "網站", + showAdvanced: "顯示進階欄位", + hideAdvanced: "隱藏進階欄位", + importFromRelays: "從中繼站匯入", + importing: "匯入中…", + }, + }, + + // 設定區塊 + config: { + title: "頻道組態", + saveChanges: "儲存變更", + reloadConfig: "重新載入組態", + unsavedChanges: "有未儲存的變更", + }, + }, + + // 工作階段頁面 + sessions: { + title: "工作階段", + desc: "進行中的工作階段金鑰與個別覆寫設定。", + activeWithin: "活動時間(分鐘)", + limit: "數量限制", + includeGlobal: "包含全域", + includeUnknown: "包含未知", + store: "儲存位置", + noSessions: "找不到工作階段。", + + columns: { + key: "金鑰", + label: "標籤", + kind: "類型", + updated: "更新時間", + tokens: "Token 數", + thinking: "思考模式", + verbose: "詳細模式", + reasoning: "推理模式", + actions: "操作", + }, + + levels: { + off: "關閉", + minimal: "最小", + low: "低", + medium: "中", + high: "高", + on: "開啟", + stream: "串流", + offExplicit: "關閉(明確)", + }, + }, + + // 排程任務頁面 + cron: { + title: "排程任務", + desc: "排程代理喚醒與週期性任務。", + noJobs: "尚未設定排程任務。", + addJob: "新增任務", + runNow: "立即執行", + remove: "移除", + enable: "啟用", + disable: "停用", + runs: "執行記錄", + lastRun: "上次執行", + nextRun: "下次執行", + + form: { + schedule: "排程(cron 格式)", + message: "訊息", + sessionKey: "工作階段金鑰", + channel: "頻道", + channelPlaceholder: "選擇頻道", + enabled: "啟用", + }, + + status: { + enabled: "排程已啟用", + disabled: "排程已停用", + nextWake: "下次喚醒", + }, + }, + + // 技能頁面 + skills: { + title: "技能", + desc: "管理內建與已安裝的技能。", + noSkills: "找不到技能。", + filter: "篩選技能", + apiKey: "API 金鑰", + saveKey: "儲存金鑰", + install: "安裝", + installing: "安裝中…", + enabled: "已啟用", + disabled: "已停用", + toggle: "切換", + keySaved: "API 金鑰已儲存", + keyError: "儲存 API 金鑰失敗", + }, + + // 節點頁面 + nodes: { + title: "節點", + desc: "已連線的執行節點與裝置配對。", + noNodes: "沒有已連線的節點。", + devices: "裝置", + noDevices: "沒有已配對的裝置。", + approve: "核准", + reject: "拒絕", + revoke: "撤銷", + rotate: "輪換", + pending: "待處理", + approved: "已核准", + + bindings: { + title: "執行綁定", + desc: "將代理綁定到特定執行節點。", + default: "預設節點", + agent: "代理", + node: "節點", + save: "儲存綁定", + }, + + approvals: { + title: "執行核准", + desc: "預先核准代理可執行的指令。", + target: "目標", + gateway: "閘道器", + selectAgent: "選擇代理", + addRule: "新增規則", + noRules: "尚未設定核准規則。", + }, + }, + + // 組態頁面 + config: { + title: "設定", + desc: "具有結構驗證的組態編輯器。", + valid: "有效", + invalid: "無效", + searchSettings: "搜尋設定...", + allSettings: "所有設定", + form: "表單", + raw: "原始", + rawJson5: "原始 JSON5", + reload: "重新載入", + update: "更新", + updating: "更新中…", + noChanges: "沒有變更", + unsavedChanges: "有未儲存的變更", + unsavedCount: "{{count}} 個未儲存的變更", + unsavedCountPlural: "{{count}} 個未儲存的變更", + viewPending: "檢視 {{count}} 個待處理變更", + viewPendingPlural: "檢視 {{count}} 個待處理變更", + loadingSchema: "載入結構定義中…", + formUnsafe: "表單模式無法安全編輯某些欄位。請使用原始模式以避免遺失設定。", + + sections: { + env: "環境", + update: "更新", + agents: "代理", + auth: "認證", + channels: "頻道", + messages: "訊息", + commands: "指令", + hooks: "鉤子", + skills: "技能", + tools: "工具", + gateway: "閘道器", + wizard: "設定精靈", + }, + }, + + // 除錯頁面 + debug: { + title: "除錯", + desc: "閘道器內部狀態與手動 RPC 測試。", + status: "狀態", + health: "健康狀態", + models: "模型", + heartbeat: "心跳", + events: "事件", + rpcCall: "RPC 呼叫", + method: "方法", + params: "參數", + call: "呼叫", + result: "結果", + noResult: "尚無結果。", + }, + + // 日誌頁面 + logs: { + title: "日誌", + desc: "閘道器檔案日誌(JSONL 格式)。", + filter: "篩選", + searchLogs: "搜尋日誌", + autoFollow: "自動捲動", + file: "檔案", + truncated: "日誌輸出已截斷;顯示最新的部分。", + noEntries: "沒有日誌記錄。", + exportFiltered: "匯出篩選結果", + exportVisible: "匯出可見內容", + + levels: { + trace: "追蹤", + debug: "除錯", + info: "資訊", + warn: "警告", + error: "錯誤", + fatal: "嚴重", + }, + }, + + // 實例頁面 + instances: { + title: "實例", + desc: "來自已連線閘道器與節點的存在訊號。", + noInstances: "找不到存在訊號。", + id: "識別碼", + type: "類型", + version: "版本", + lastSeen: "上次出現", + }, + + // 執行核准提示 + execApproval: { + title: "需要執行核准", + command: "指令", + agent: "代理", + allowOnce: "允許一次", + allowAlways: "永久允許", + deny: "拒絕", + }, + + // 主題 + theme: { + toggle: "切換主題", + light: "淺色", + dark: "深色", + system: "跟隨系統", + }, + + // 時間/日期格式 + time: { + justNow: "剛剛", + minutesAgo: "{{count}} 分鐘前", + hoursAgo: "{{count}} 小時前", + daysAgo: "{{count}} 天前", + never: "從未", + }, + + // Markdown 側邊欄 + sidebar: { + close: "關閉", + viewRaw: "檢視原始內容", + error: "載入內容時發生錯誤", + }, + + // 錯誤訊息 + errors: { + connectionFailed: "連線失敗", + loadFailed: "載入失敗", + saveFailed: "儲存失敗", + unknownError: "發生未知錯誤", + networkError: "網路錯誤", + timeout: "請求逾時", + unauthorized: "未授權", + forbidden: "禁止存取", + notFound: "找不到資源", + }, +}; diff --git a/ui/src/main.ts b/ui/src/main.ts index 9374bb20e..5ae77ca07 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -1,2 +1,7 @@ import "./styles.css"; +import { initLocale } from "./i18n"; + +// Initialize i18n before loading the app +initLocale(); + import "./ui/app.ts"; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 27dfe62d1..587e50ec3 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -274,6 +274,39 @@ stroke-linejoin: round; } +/* =========================================== + Language Switcher + =========================================== */ + +.language-switcher { + margin-left: 8px; +} + +.language-switcher__select { + padding: 6px 10px; + font-size: 12px; + font-weight: 500; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--secondary); + color: var(--text); + cursor: pointer; + transition: + border-color var(--duration-fast) ease, + background var(--duration-fast) ease; + outline: none; +} + +.language-switcher__select:hover { + border-color: var(--border-strong); + background: var(--card); +} + +.language-switcher__select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-subtle); +} + /* =========================================== Status Dot - With glow for emphasis =========================================== */ diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 22f8d90db..da0d26bfc 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -9,6 +9,7 @@ import { syncUrlWithSessionKey } from "./app-settings"; import type { SessionsListResult } from "./types"; import type { ThemeMode } from "./theme"; import type { ThemeTransitionContext } from "./theme-transition"; +import { t, getLocale, setLocale, getAvailableLocales, getLocaleDisplayName, type Locale } from "../../i18n"; export function renderTab(state: AppViewState, tab: Tab) { const href = pathForTab(tab, state.basePath); @@ -89,7 +90,7 @@ export function renderChatControls(state: AppViewState) { state.resetToolStream(); void loadChatHistory(state); }} - title="Refresh chat history" + title="${t("common.refresh")}" > ${refreshIcon} @@ -240,3 +241,38 @@ function renderMonitorIcon() { `; } + +/** + * Render language switcher + */ +export function renderLanguageSwitcher() { + const currentLocale = getLocale(); + const locales = getAvailableLocales(); + + const handleChange = (e: Event) => { + const select = e.target as HTMLSelectElement; + const newLocale = select.value as Locale; + setLocale(newLocale); + // Reload the page to apply new locale + window.location.reload(); + }; + + return html` +
+ +
+ `; +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index a088c33ff..e7567d3e3 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -5,6 +5,7 @@ import type { AppViewState } from "./app-view-state"; import { parseAgentSessionKey } from "../../../src/routing/session-key.js"; import { TAB_GROUPS, + getGroupLabel, iconForTab, pathForTab, subtitleForTab, @@ -12,6 +13,7 @@ import { type Tab, } from "./navigation"; import { icons } from "./icons"; +import { t } from "../i18n"; import type { UiSettings } from "./storage"; import type { ThemeMode } from "./theme"; import type { ThemeTransitionContext } from "./theme-transition"; @@ -50,7 +52,7 @@ import { rotateDeviceToken, } from "./controllers/devices"; import { renderSkills } from "./views/skills"; -import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers"; +import { renderChatControls, renderTab, renderThemeToggle, renderLanguageSwitcher } from "./app-render.helpers"; import { loadChannels } from "./controllers/channels"; import { loadPresence } from "./controllers/presence"; import { deleteSession, loadSessions, patchSession } from "./controllers/sessions"; @@ -122,8 +124,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("app.expandSidebar") : t("app.collapseSidebar")}" + aria-label="${state.settings.navCollapsed ? t("app.expandSidebar") : t("app.collapseSidebar")}" > ${icons.menu} @@ -132,18 +134,19 @@ export function renderApp(state: AppViewState) { Moltbot
-
MOLTBOT
-
Gateway Dashboard
+
${t("app.title")}
+
${t("app.subtitle")}
- Health - ${state.connected ? "OK" : "Offline"} + ${t("app.health")} + ${state.connected ? t("common.ok") : t("app.offline")}
${renderThemeToggle(state)} + ${renderLanguageSwitcher()}