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..60e268ec4 --- /dev/null +++ b/ui/src/i18n/locales/en-US.ts @@ -0,0 +1,747 @@ +/** + * 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", + }, + + // General + accounts: "Accounts", + + // WhatsApp + whatsapp: { + title: "WhatsApp", + desc: "Link WhatsApp Web and monitor connection health.", + start: "Start", + relink: "Relink", + logout: "Logout", + scanQr: "Scan QR with WhatsApp on your phone.", + linking: "Linking…", + waitingForQr: "Waiting for QR code…", + notConfigured: "Not configured.", + linked: "Linked", + lastConnect: "Last connect", + authAge: "Auth age", + showQr: "Show QR", + waitForScan: "Wait for scan", + working: "Working…", + }, + + // Telegram + telegram: { + title: "Telegram", + desc: "Bot status and channel configuration.", + mode: "Mode", + lastStart: "Last start", + lastProbe: "Last probe", + probe: "Probe", + probeOk: "ok", + probeFailed: "failed", + }, + + // Discord + discord: { + title: "Discord", + desc: "Bot status and channel configuration.", + lastStart: "Last start", + lastProbe: "Last probe", + probe: "Probe", + probeOk: "ok", + probeFailed: "failed", + }, + + // Slack + slack: { + title: "Slack", + desc: "Socket mode status and channel configuration.", + lastStart: "Last start", + lastProbe: "Last probe", + probe: "Probe", + probeOk: "ok", + probeFailed: "failed", + }, + + // Signal + signal: { + title: "Signal", + desc: "signal-cli status and channel configuration.", + baseUrl: "Base URL", + lastStart: "Last start", + lastProbe: "Last probe", + probe: "Probe", + probeOk: "ok", + probeFailed: "failed", + }, + + // iMessage + imessage: { + title: "iMessage", + desc: "macOS bridge status and channel configuration.", + lastStart: "Last start", + lastProbe: "Last probe", + probe: "Probe", + probeOk: "ok", + probeFailed: "failed", + }, + + // Google Chat + googlechat: { + title: "Google Chat", + desc: "Service account status and channel configuration.", + credential: "Credential", + audience: "Audience", + lastStart: "Last start", + lastProbe: "Last probe", + probe: "Probe", + probeOk: "ok", + probeFailed: "failed", + }, + + // Nostr + nostr: { + title: "Nostr", + desc: "Decentralized DMs via Nostr relays (NIP-04).", + publicKey: "Public Key", + lastStart: "Last start", + profile: "Profile", + editProfile: "Edit Profile", + profilePicture: "Profile picture", + displayName: "Display Name", + noProfile: "No profile set. Click \"Edit Profile\" to add your name, bio, and avatar.", + profileForm: { + title: "Edit Profile", + account: "Account", + name: "Username", + nameHelp: "Short username (e.g., satoshi)", + namePlaceholder: "satoshi", + displayName: "Display Name", + displayNameHelp: "Your full display name", + displayNamePlaceholder: "Satoshi Nakamoto", + about: "Bio", + aboutHelp: "A brief bio or description", + aboutPlaceholder: "Tell people about yourself...", + picture: "Avatar URL", + pictureHelp: "HTTPS URL to your profile picture", + picturePlaceholder: "https://example.com/avatar.jpg", + picturePreview: "Profile picture preview", + advanced: "Advanced", + banner: "Banner URL", + bannerHelp: "HTTPS URL to a banner image", + bannerPlaceholder: "https://example.com/banner.jpg", + website: "Website", + websiteHelp: "Your personal website", + websitePlaceholder: "https://example.com", + nip05: "NIP-05 Identifier", + nip05Help: "Verifiable identifier (e.g., you@domain.com)", + nip05Placeholder: "you@example.com", + lud16: "Lightning Address", + lud16Help: "Lightning address for tips (LUD-16)", + lud16Placeholder: "you@getalby.com", + showAdvanced: "Show Advanced", + hideAdvanced: "Hide Advanced", + importFromRelays: "Import from Relays", + importing: "Importing…", + savePublish: "Save & Publish", + unsavedChanges: "You have unsaved changes", + }, + }, + + // Config section + config: { + title: "Channel Configuration", + saveChanges: "Save Changes", + reloadConfig: "Reload Config", + unsavedChanges: "Unsaved changes", + loadingSchema: "Loading config schema…", + schemaUnavailable: "Schema unavailable. Use Raw.", + channelSchemaUnavailable: "Channel config schema unavailable.", + reload: "Reload", + }, + }, + + // 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 jobs yet.", + addJob: "Add job", + runNow: "Run", + remove: "Remove", + enable: "Enable", + disable: "Disable", + runs: "Runs", + lastRun: "Last run", + nextRun: "Next run", + + // Scheduler card + scheduler: "Scheduler", + schedulerDesc: "Gateway-owned cron scheduler status.", + jobs: "Jobs", + + // New job form + newJob: "New Job", + newJobDesc: "Create a scheduled wakeup or agent run.", + name: "Name", + description: "Description", + agentId: "Agent ID", + agentIdPlaceholder: "default", + scheduleKind: "Schedule", + everyLabel: "Every", + atLabel: "At", + cronLabel: "Cron", + runAt: "Run at", + every: "Every", + unit: "Unit", + minutes: "Minutes", + hours: "Hours", + days: "Days", + expression: "Expression", + timezone: "Timezone (optional)", + session: "Session", + main: "Main", + isolated: "Isolated", + wakeMode: "Wake mode", + nextHeartbeat: "Next heartbeat", + now: "Now", + payload: "Payload", + systemEvent: "System event", + agentTurn: "Agent turn", + systemText: "System text", + agentMessage: "Agent message", + deliver: "Deliver", + to: "To", + toPlaceholder: "+1555… or chat id", + timeoutSeconds: "Timeout (seconds)", + postToMainPrefix: "Post to main prefix", + + // Jobs list + jobsList: "Jobs", + jobsListDesc: "All scheduled jobs stored in the gateway.", + + // Run history + runHistory: "Run history", + runHistoryDesc: "Latest runs for", + selectJob: "Select a job to inspect run history.", + noRuns: "No runs yet.", + + 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", + shown: "shown", + 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", + eligible: "eligible", + blocked: "blocked", + missing: "Missing", + reason: "Reason", + blockedByAllowlist: "blocked by allowlist", + }, + + // Nodes page + nodes: { + title: "Nodes", + desc: "Paired devices and live links.", + noNodes: "No nodes found.", + devices: "Devices", + devicesDesc: "Pairing requests + role tokens.", + noDevices: "No paired devices.", + approve: "Approve", + reject: "Reject", + revoke: "Revoke", + rotate: "Rotate", + pending: "Pending", + paired: "Paired", + approved: "Approved", + offline: "offline", + tokens: "Tokens", + tokensNone: "Tokens: none", + role: "role", + requested: "requested", + repair: "repair", + active: "active", + revoked: "revoked", + scopes: "scopes", + roles: "roles", + + bindings: { + title: "Exec node binding", + desc: "Pin agents to a specific node when using", + default: "Default binding", + defaultDesc: "Used when agents do not override a node binding.", + agent: "Agent", + node: "Node", + anyNode: "Any node", + useDefault: "Use default", + save: "Save", + loadConfig: "Load config", + loadConfigNote: "Load config to edit bindings.", + noNodesAvailable: "No nodes with system.run available.", + defaultAgent: "default agent", + usesDefault: "uses default", + override: "override", + switchToForm: "Switch the Config tab to Form mode to edit bindings here.", + }, + + approvals: { + title: "Exec approvals", + desc: "Allowlist and approval policy for", + target: "Target", + targetDesc: "Gateway edits local approvals; node edits the selected node.", + host: "Host", + gateway: "Gateway", + selectNode: "Select node", + noNodesYet: "No nodes advertise exec approvals yet.", + scope: "Scope", + defaults: "Defaults", + security: "Security", + securityDesc: "Default security mode.", + mode: "Mode", + deny: "Deny", + allowlist: "Allowlist", + full: "Full", + ask: "Ask", + askDesc: "Default prompt policy.", + off: "Off", + onMiss: "On miss", + always: "Always", + askFallback: "Ask fallback", + askFallbackDesc: "Applied when the UI prompt is unavailable.", + fallback: "Fallback", + autoAllowSkills: "Auto-allow skill CLIs", + autoAllowSkillsDesc: "Allow skill executables listed by the Gateway.", + usingDefault: "Using default", + addPattern: "Add pattern", + allowlistTitle: "Allowlist", + allowlistDesc: "Case-insensitive glob patterns.", + noAllowlist: "No allowlist entries yet.", + pattern: "Pattern", + newPattern: "New pattern", + lastUsed: "Last used", + never: "never", + remove: "Remove", + loadApprovals: "Load approvals", + loadApprovalsNote: "Load exec approvals to edit allowlists.", + }, + }, + + // 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", + modelsDesc: "Catalog from models.list.", + heartbeat: "Heartbeat", + lastHeartbeat: "Last heartbeat", + events: "Events", + eventLog: "Event Log", + eventLogDesc: "Latest gateway events.", + noEvents: "No events yet.", + rpcCall: "RPC Call", + manualRpc: "Manual RPC", + manualRpcDesc: "Send a raw gateway method with JSON params.", + method: "Method", + params: "Params", + paramsJson: "Params (JSON)", + call: "Call", + result: "Result", + noResult: "No result yet.", + snapshots: "Snapshots", + snapshotsDesc: "Status, health, and heartbeat data.", + securityAudit: "Security audit", + critical: "critical", + warnings: "warnings", + noCritical: "No critical issues", + runAuditCmd: "Run for details.", + }, + + // 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", + ariaLabel: "Theme", + systemAriaLabel: "System theme", + lightAriaLabel: "Light theme", + darkAriaLabel: "Dark theme", + }, + + // Chat controls + chatControls: { + disabledDuringOnboarding: "Disabled during onboarding", + toggleThinking: "Toggle assistant thinking/working output", + toggleFocusMode: "Toggle focus mode (hide sidebar + page header)", + selectLanguage: "Select language", + }, + + // Gateway connection + gateway: { + disconnected: "Disconnected from gateway.", + }, + + // Nostr profile messages + nostrProfile: { + publishFailed: "Profile publish failed on all relays.", + publishSuccess: "Profile published to relays.", + updateFailed: "Profile update failed", + importFailed: "Profile import failed", + importedFromRelays: "Profile imported from relays. Review and publish.", + importedReviewPublish: "Profile imported. Review and publish.", + }, + + // 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..ba43dfeff --- /dev/null +++ b/ui/src/i18n/locales/zh-TW.ts @@ -0,0 +1,754 @@ +/** + * 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: "Skills", + nodes: "節點", + chat: "對話", + config: "組態", + debug: "除錯", + logs: "日誌", + }, + subtitles: { + overview: "閘道器狀態、進入點與快速健康檢查。", + channels: "管理頻道與相關設定。", + instances: "來自已連線用戶端與節點的存在訊號。", + sessions: "檢視進行中的工作階段並調整個別設定。", + cron: "排程喚醒與週期性代理執行。", + skills: "管理 Skills 的啟用狀態與 API 金鑰。", + nodes: "已配對的裝置、功能與指令權限。", + chat: "直接與閘道器對話,進行快速操作。", + config: "安全地編輯 ~/.clawdbot/moltbot.json 設定檔。", + debug: "閘道器快照、事件記錄與手動 RPC 呼叫。", + logs: "即時檢視閘道器的檔案日誌。", + }, + docs: "文件", + docsTooltip: "文件(在新分頁開啟)", + }, + + // 總覽頁面 + overview: { + gatewayAccess: "閘道器連線", + gatewayAccessDesc: "控制台連線位置與認證方式。", + websocketUrl: "WebSocket 網址", + gatewayToken: "Gateway 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: "已連線", + }, + + // 一般 + accounts: "帳號", + + // WhatsApp + whatsapp: { + title: "WhatsApp", + desc: "連結 WhatsApp Web 並監控連線狀態。", + start: "開始", + relink: "重新連結", + logout: "登出", + scanQr: "請用手機上的 WhatsApp 掃描 QR Code。", + linking: "連結中…", + waitingForQr: "等待 QR Code…", + notConfigured: "尚未設定。", + linked: "已連結", + lastConnect: "上次連線", + authAge: "認證時長", + showQr: "顯示 QR", + waitForScan: "等待掃描", + working: "處理中…", + }, + + // Telegram + telegram: { + title: "Telegram", + desc: "機器人狀態與頻道設定。", + mode: "模式", + lastStart: "上次啟動", + lastProbe: "上次探測", + probe: "探測", + probeOk: "正常", + probeFailed: "失敗", + }, + + // Discord + discord: { + title: "Discord", + desc: "機器人狀態與頻道設定。", + lastStart: "上次啟動", + lastProbe: "上次探測", + probe: "探測", + probeOk: "正常", + probeFailed: "失敗", + }, + + // Slack + slack: { + title: "Slack", + desc: "Socket 模式狀態與頻道設定。", + lastStart: "上次啟動", + lastProbe: "上次探測", + probe: "探測", + probeOk: "正常", + probeFailed: "失敗", + }, + + // Signal + signal: { + title: "Signal", + desc: "signal-cli 狀態與頻道設定。", + baseUrl: "Base URL", + lastStart: "上次啟動", + lastProbe: "上次探測", + probe: "探測", + probeOk: "正常", + probeFailed: "失敗", + }, + + // iMessage + imessage: { + title: "iMessage", + desc: "macOS 橋接器狀態與頻道設定。", + lastStart: "上次啟動", + lastProbe: "上次探測", + probe: "探測", + probeOk: "正常", + probeFailed: "失敗", + }, + + // Google Chat + googlechat: { + title: "Google Chat", + desc: "服務帳戶狀態與頻道設定。", + credential: "憑證", + audience: "對象", + lastStart: "上次啟動", + lastProbe: "上次探測", + probe: "探測", + probeOk: "正常", + probeFailed: "失敗", + }, + + // Nostr + nostr: { + title: "Nostr", + desc: "透過 Nostr 中繼站進行去中心化私訊(NIP-04)。", + publicKey: "公鑰", + lastStart: "上次啟動", + profile: "個人檔案", + editProfile: "編輯個人檔案", + profilePicture: "個人頭像", + displayName: "顯示名稱", + noProfile: "尚未設定個人檔案。點擊「編輯個人檔案」來新增您的名稱、簡介和頭像。", + profileForm: { + title: "編輯個人檔案", + account: "帳號", + name: "使用者名稱", + nameHelp: "簡短的使用者名稱(如:satoshi)", + namePlaceholder: "satoshi", + displayName: "顯示名稱", + displayNameHelp: "您的完整顯示名稱", + displayNamePlaceholder: "Satoshi Nakamoto", + about: "個人簡介", + aboutHelp: "簡短的自我介紹", + aboutPlaceholder: "介紹一下您自己...", + picture: "頭像網址", + pictureHelp: "您的頭像的 HTTPS 網址", + picturePlaceholder: "https://example.com/avatar.jpg", + picturePreview: "頭像預覽", + advanced: "進階設定", + banner: "橫幅圖片網址", + bannerHelp: "橫幅圖片的 HTTPS 網址", + bannerPlaceholder: "https://example.com/banner.jpg", + website: "網站", + websiteHelp: "您的個人網站", + websitePlaceholder: "https://example.com", + nip05: "NIP-05 識別碼", + nip05Help: "可驗證的識別碼(如:you@domain.com)", + nip05Placeholder: "you@example.com", + lud16: "閃電網路地址", + lud16Help: "用於接收打賞的閃電網路地址(LUD-16)", + lud16Placeholder: "you@getalby.com", + showAdvanced: "顯示進階設定", + hideAdvanced: "隱藏進階設定", + importFromRelays: "從中繼站匯入", + importing: "匯入中…", + savePublish: "儲存並發布", + unsavedChanges: "有未儲存的變更", + }, + }, + + // 設定區塊 + config: { + title: "頻道組態", + saveChanges: "儲存變更", + reloadConfig: "重新載入組態", + unsavedChanges: "有未儲存的變更", + loadingSchema: "載入組態結構中…", + schemaUnavailable: "結構不可用。請使用原始模式。", + channelSchemaUnavailable: "頻道組態結構不可用。", + reload: "重新載入", + }, + }, + + // 工作階段頁面 + 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: "下次執行", + + // 排程器卡片 + scheduler: "排程器", + schedulerDesc: "閘道器所管理的排程器狀態。", + jobs: "任務數", + + // 新任務表單 + newJob: "新增任務", + newJobDesc: "建立排程喚醒或代理執行。", + name: "名稱", + description: "描述", + agentId: "代理 ID", + agentIdPlaceholder: "default", + scheduleKind: "排程類型", + everyLabel: "間隔", + atLabel: "指定時間", + cronLabel: "Cron", + runAt: "執行時間", + every: "每隔", + unit: "單位", + minutes: "分鐘", + hours: "小時", + days: "天", + expression: "運算式", + timezone: "時區(選填)", + session: "工作階段", + main: "主要", + isolated: "獨立", + wakeMode: "喚醒模式", + nextHeartbeat: "下次心跳", + now: "立即", + payload: "內容類型", + systemEvent: "系統事件", + agentTurn: "代理回合", + systemText: "系統文字", + agentMessage: "代理訊息", + deliver: "傳送", + to: "收件者", + toPlaceholder: "+1555… 或聊天 ID", + timeoutSeconds: "逾時(秒)", + postToMainPrefix: "發送至主工作階段前綴", + + // 任務列表 + jobsList: "任務", + jobsListDesc: "所有儲存在閘道器的排程任務。", + + // 執行歷史 + runHistory: "執行歷史", + runHistoryDesc: "最近的執行記錄:", + selectJob: "選擇任務以檢視執行歷史。", + noRuns: "尚無執行記錄。", + + form: { + schedule: "排程(cron 格式)", + message: "訊息", + sessionKey: "工作階段金鑰", + channel: "頻道", + channelPlaceholder: "選擇頻道", + enabled: "啟用", + }, + + status: { + enabled: "排程已啟用", + disabled: "排程已停用", + nextWake: "下次喚醒", + }, + }, + + // Skills 頁面 + skills: { + title: "Skills", + desc: "管理內建與已安裝的 Skills。", + noSkills: "找不到 Skills。", + filter: "篩選 Skills", + shown: "個顯示中", + apiKey: "API 金鑰", + saveKey: "儲存金鑰", + install: "安裝", + installing: "安裝中…", + enabled: "已啟用", + disabled: "已停用", + toggle: "切換", + keySaved: "API 金鑰已儲存", + keyError: "儲存 API 金鑰失敗", + eligible: "可用", + blocked: "已封鎖", + missing: "缺少", + reason: "原因", + blockedByAllowlist: "被許可清單封鎖", + }, + + // 節點頁面 + nodes: { + title: "節點", + desc: "已配對的裝置與即時連結。", + noNodes: "找不到節點。", + devices: "裝置", + devicesDesc: "配對請求與角色 Token。", + noDevices: "沒有已配對的裝置。", + approve: "核准", + reject: "拒絕", + revoke: "撤銷", + rotate: "輪換", + pending: "待處理", + paired: "已配對", + approved: "已核准", + offline: "離線", + tokens: "Token", + tokensNone: "Token:無", + role: "角色", + requested: "請求於", + repair: "修復", + active: "使用中", + revoked: "已撤銷", + scopes: "範圍", + roles: "角色", + + bindings: { + title: "執行節點綁定", + desc: "使用時將代理固定到特定節點", + default: "預設綁定", + defaultDesc: "當代理未覆寫節點綁定時使用。", + agent: "代理", + node: "節點", + anyNode: "任意節點", + useDefault: "使用預設", + save: "儲存", + loadConfig: "載入組態", + loadConfigNote: "載入組態以編輯綁定。", + noNodesAvailable: "沒有支援 system.run 的節點。", + defaultAgent: "預設代理", + usesDefault: "使用預設", + override: "覆寫", + switchToForm: "請將「組態」分頁切換為「表單」模式以在此編輯綁定。", + }, + + approvals: { + title: "執行核准", + desc: "許可清單與核准政策,適用於", + target: "目標", + targetDesc: "閘道器編輯本地核准;節點編輯選定的節點。", + host: "主機", + gateway: "閘道器", + selectNode: "選擇節點", + noNodesYet: "尚無節點公告執行核准。", + scope: "範圍", + defaults: "預設值", + security: "安全性", + securityDesc: "預設安全模式。", + mode: "模式", + deny: "拒絕", + allowlist: "許可清單", + full: "完整", + ask: "詢問", + askDesc: "預設提示政策。", + off: "關閉", + onMiss: "未命中時", + always: "總是", + askFallback: "詢問備援", + askFallbackDesc: "當 UI 提示不可用時套用。", + fallback: "備援", + autoAllowSkills: "自動允許技能 CLI", + autoAllowSkillsDesc: "允許閘道器列出的技能可執行檔。", + usingDefault: "使用預設", + addPattern: "新增模式", + allowlistTitle: "許可清單", + allowlistDesc: "不區分大小寫的 glob 模式。", + noAllowlist: "尚無許可清單項目。", + pattern: "模式", + newPattern: "新模式", + lastUsed: "上次使用", + never: "從未", + remove: "移除", + loadApprovals: "載入核准", + loadApprovalsNote: "載入執行核准以編輯許可清單。", + }, + }, + + // 組態頁面 + 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: "Skills", + tools: "工具", + gateway: "閘道器", + wizard: "設定精靈", + }, + }, + + // 除錯頁面 + debug: { + title: "除錯", + desc: "閘道器內部狀態與手動 RPC 測試。", + status: "狀態", + health: "健康狀態", + models: "模型", + modelsDesc: "來自 models.list 的目錄。", + heartbeat: "心跳", + lastHeartbeat: "上次心跳", + events: "事件", + eventLog: "事件日誌", + eventLogDesc: "最新的閘道器事件。", + noEvents: "尚無事件。", + rpcCall: "RPC 呼叫", + manualRpc: "手動 RPC", + manualRpcDesc: "使用 JSON 參數發送原始閘道器方法。", + method: "方法", + params: "參數", + paramsJson: "參數(JSON)", + call: "呼叫", + result: "結果", + noResult: "尚無結果。", + snapshots: "快照", + snapshotsDesc: "狀態、健康狀態與心跳資料。", + securityAudit: "安全稽核", + critical: "嚴重", + warnings: "警告", + noCritical: "無嚴重問題", + runAuditCmd: "執行以檢視詳情。", + }, + + // 日誌頁面 + 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: "跟隨系統", + ariaLabel: "主題", + systemAriaLabel: "跟隨系統主題", + lightAriaLabel: "淺色主題", + darkAriaLabel: "深色主題", + }, + + // 對話控制 + chatControls: { + disabledDuringOnboarding: "引導期間已停用", + toggleThinking: "切換助手思考/工作輸出", + toggleFocusMode: "切換專注模式(隱藏側邊欄和頁首)", + selectLanguage: "選擇語言", + }, + + // 閘道器連線 + gateway: { + disconnected: "已與閘道器斷線。", + }, + + // Nostr 個人檔案訊息 + nostrProfile: { + publishFailed: "個人檔案發布至所有中繼站失敗。", + publishSuccess: "個人檔案已發布至中繼站。", + updateFailed: "個人檔案更新失敗", + importFailed: "個人檔案匯入失敗", + importedFromRelays: "已從中繼站匯入個人檔案。請檢視並發布。", + importedReviewPublish: "已匯入個人檔案。請檢視並發布。", + }, + + // 時間/日期格式 + 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-channels.ts b/ui/src/ui/app-channels.ts index 91ff734ed..193a8ad9c 100644 --- a/ui/src/ui/app-channels.ts +++ b/ui/src/ui/app-channels.ts @@ -8,6 +8,7 @@ import { loadConfig, saveConfig } from "./controllers/config"; import type { MoltbotApp } from "./app"; import type { NostrProfile } from "./types"; import { createNostrProfileFormState } from "./views/channels.nostr-profile-form"; +import { t } from "../i18n"; export async function handleWhatsAppStart(host: MoltbotApp, force: boolean) { await startWhatsAppLogin(host, force); @@ -142,7 +143,7 @@ export async function handleNostrProfileSave(host: MoltbotApp) { host.nostrProfileFormState = { ...state, saving: false, - error: "Profile publish failed on all relays.", + error: t("nostrProfile.publishFailed"), success: null, }; return; @@ -152,7 +153,7 @@ export async function handleNostrProfileSave(host: MoltbotApp) { ...state, saving: false, error: null, - success: "Profile published to relays.", + success: t("nostrProfile.publishSuccess"), fieldErrors: {}, original: { ...state.values }, }; @@ -161,7 +162,7 @@ export async function handleNostrProfileSave(host: MoltbotApp) { host.nostrProfileFormState = { ...state, saving: false, - error: `Profile update failed: ${String(err)}`, + error: `${t("nostrProfile.updateFailed")}: ${String(err)}`, success: null, }; } @@ -214,8 +215,8 @@ export async function handleNostrProfileImport(host: MoltbotApp) { values: nextValues, error: null, success: data.saved - ? "Profile imported from relays. Review and publish." - : "Profile imported. Review and publish.", + ? t("nostrProfile.importedFromRelays") + : t("nostrProfile.importedReviewPublish"), showAdvanced, }; @@ -226,7 +227,7 @@ export async function handleNostrProfileImport(host: MoltbotApp) { host.nostrProfileFormState = { ...state, importing: false, - error: `Profile import failed: ${String(err)}`, + error: `${t("nostrProfile.importFailed")}: ${String(err)}`, success: null, }; } diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index c2190e1c9..8b3ab7632 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -10,6 +10,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); @@ -95,7 +96,7 @@ export function renderChatControls(state: AppViewState) { state.resetToolStream(); void refreshChat(state as unknown as Parameters[0]); }} - title="Refresh chat data" + title="${t("common.refresh")}" > ${refreshIcon} @@ -112,8 +113,8 @@ export function renderChatControls(state: AppViewState) { }} aria-pressed=${showThinking} title=${disableThinkingToggle - ? "Disabled during onboarding" - : "Toggle assistant thinking/working output"} + ? t("chatControls.disabledDuringOnboarding") + : t("chatControls.toggleThinking")} > ${icons.brain} @@ -129,8 +130,8 @@ export function renderChatControls(state: AppViewState) { }} aria-pressed=${focusActive} title=${disableFocusToggle - ? "Disabled during onboarding" - : "Toggle focus mode (hide sidebar + page header)"} + ? t("chatControls.disabledDuringOnboarding") + : t("chatControls.toggleFocusMode")} > ${focusIcon} @@ -209,14 +210,14 @@ export function renderThemeToggle(state: AppViewState) { return html`
-
+
@@ -224,8 +225,8 @@ export function renderThemeToggle(state: AppViewState) { class="theme-toggle__button ${state.theme === "light" ? "active" : ""}" @click=${applyTheme("light")} aria-pressed=${state.theme === "light"} - aria-label="Light theme" - title="Light" + aria-label="${t("theme.lightAriaLabel")}" + title="${t("theme.light")}" > ${renderSunIcon()} @@ -233,8 +234,8 @@ export function renderThemeToggle(state: AppViewState) { class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}" @click=${applyTheme("dark")} aria-pressed=${state.theme === "dark"} - aria-label="Dark theme" - title="Dark" + aria-label="${t("theme.darkAriaLabel")}" + title="${t("theme.dark")}" > ${renderMoonIcon()} @@ -278,3 +279,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 422af6863..2d02190f5 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"; @@ -51,7 +53,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"; @@ -105,7 +107,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("gateway.disconnected"); const isChat = state.tab === "chat"; const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding); const showThinking = state.onboarding ? false : state.settings.chatShowThinking; @@ -123,8 +125,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} @@ -133,18 +135,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()}