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
This commit is contained in:
Claude 2026-01-28 23:00:19 +00:00
parent 01e0d3a320
commit a2497ecaab
No known key found for this signature in database
12 changed files with 1414 additions and 174 deletions

155
ui/src/i18n/index.ts Normal file
View File

@ -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<string, string | TranslationDict>;
const locales: Record<Locale, TranslationDict> = {
'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, string | number>): 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, string | number>): 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<Locale, string> = {
'en-US': 'English',
'zh-TW': '繁體中文',
};
return names[locale] || locale;
}
// Initialize locale on module load
initLocale();

View File

@ -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",
},
};

View File

@ -0,0 +1,527 @@
/**
* Traditional Chinese (Taiwan) translations
*
*
*
* - 使
* -
* - 使
* - WhatsAppTelegram
*/
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瀏覽器會阻擋裝置身分驗證。請使用 HTTPSTailscale 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: "找不到資源",
},
};

View File

@ -1,2 +1,7 @@
import "./styles.css";
import { initLocale } from "./i18n";
// Initialize i18n before loading the app
initLocale();
import "./ui/app.ts";

View File

@ -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
=========================================== */

View File

@ -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}
</button>
@ -240,3 +241,38 @@ function renderMonitorIcon() {
</svg>
`;
}
/**
* 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`
<div class="language-switcher">
<select
class="language-switcher__select"
.value=${currentLocale}
@change=${handleChange}
aria-label="Select language"
>
${locales.map(
(locale) => html`
<option value=${locale} ?selected=${locale === currentLocale}>
${getLocaleDisplayName(locale)}
</option>
`,
)}
</select>
</div>
`;
}

View File

@ -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")}"
>
<span class="nav-collapse-toggle__icon">${icons.menu}</span>
</button>
@ -132,18 +134,19 @@ export function renderApp(state: AppViewState) {
<img src="https://mintcdn.com/clawdhub/4rYvG-uuZrMK_URE/assets/pixel-lobster.svg?fit=max&auto=format&n=4rYvG-uuZrMK_URE&q=85&s=da2032e9eac3b5d9bfe7eb96ca6a8a26" alt="Moltbot" />
</div>
<div class="brand-text">
<div class="brand-title">MOLTBOT</div>
<div class="brand-sub">Gateway Dashboard</div>
<div class="brand-title">${t("app.title")}</div>
<div class="brand-sub">${t("app.subtitle")}</div>
</div>
</div>
</div>
<div class="topbar-status">
<div class="pill">
<span class="statusDot ${state.connected ? "ok" : ""}"></span>
<span>Health</span>
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
<span>${t("app.health")}</span>
<span class="mono">${state.connected ? t("common.ok") : t("app.offline")}</span>
</div>
${renderThemeToggle(state)}
${renderLanguageSwitcher()}
</div>
</header>
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
@ -164,7 +167,7 @@ export function renderApp(state: AppViewState) {
}}
aria-expanded=${!isGroupCollapsed}
>
<span class="nav-label__text">${group.label}</span>
<span class="nav-label__text">${getGroupLabel(group.label)}</span>
<span class="nav-label__chevron">${isGroupCollapsed ? "+" : ""}</span>
</button>
<div class="nav-group__items">
@ -175,7 +178,7 @@ export function renderApp(state: AppViewState) {
})}
<div class="nav-group nav-group--links">
<div class="nav-label nav-label--static">
<span class="nav-label__text">Resources</span>
<span class="nav-label__text">${t("nav.groups.resources")}</span>
</div>
<div class="nav-group__items">
<a
@ -183,10 +186,10 @@ export function renderApp(state: AppViewState) {
href="https://docs.molt.bot"
target="_blank"
rel="noreferrer"
title="Docs (opens in new tab)"
title="${t("nav.docsTooltip")}"
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
<span class="nav-item__text">Docs</span>
<span class="nav-item__text">${t("nav.docs")}</span>
</a>
</div>
</div>

View File

@ -1,15 +1,23 @@
import type { IconName } from "./icons.js";
import { t } from "../i18n";
export const TAB_GROUPS = [
{ label: "Chat", tabs: ["chat"] },
{ label: "nav.groups.chat", tabs: ["chat"] },
{
label: "Control",
label: "nav.groups.control",
tabs: ["overview", "channels", "instances", "sessions", "cron"],
},
{ label: "Agent", tabs: ["skills", "nodes"] },
{ label: "Settings", tabs: ["config", "debug", "logs"] },
{ label: "nav.groups.agent", tabs: ["skills", "nodes"] },
{ label: "nav.groups.settings", tabs: ["config", "debug", "logs"] },
] as const;
/**
* Get translated group label
*/
export function getGroupLabel(labelKey: string): string {
return t(labelKey);
}
export type Tab =
| "overview"
| "channels"
@ -130,59 +138,9 @@ 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";
}
return t(`nav.tabs.${tab}`);
}
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 "";
}
return t(`nav.subtitles.${tab}`);
}

View File

@ -5,6 +5,7 @@ import type { SessionsListResult } from "../types";
import type { ChatAttachment, ChatQueueItem } from "../ui-types";
import type { ChatItem, MessageGroup } from "../types/chat-types";
import { icons } from "../icons";
import { t } from "../../i18n";
import {
normalizeMessage,
normalizeRoleForGrouping,
@ -84,7 +85,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
if (status.active) {
return html`
<div class="callout info compaction-indicator compaction-indicator--active">
${icons.loader} Compacting context...
${icons.loader} ${t("chat.compacting")}
</div>
`;
}
@ -95,7 +96,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
return html`
<div class="callout success compaction-indicator compaction-indicator--complete">
${icons.check} Context compacted
${icons.check} ${t("chat.compacted")}
</div>
`;
}
@ -157,13 +158,13 @@ function renderAttachmentPreview(props: ChatProps) {
<div class="chat-attachment">
<img
src=${att.dataUrl}
alt="Attachment preview"
alt="${t("chat.attachmentPreview")}"
class="chat-attachment__img"
/>
<button
class="chat-attachment__remove"
type="button"
aria-label="Remove attachment"
aria-label="${t("chat.removeAttachment")}"
@click=${() => {
const next = (props.attachments ?? []).filter(
(a) => a.id !== att.id,
@ -197,9 +198,9 @@ export function renderChat(props: ChatProps) {
const hasAttachments = (props.attachments?.length ?? 0) > 0;
const composePlaceholder = props.connected
? hasAttachments
? "Add a message or paste more images..."
: "Message (↩ to send, Shift+↩ for line breaks, paste images)"
: "Connect to the gateway to start chatting…";
? t("chat.messagePlaceholderWithImages")
: t("chat.messagePlaceholder")
: t("chat.connectToChat");
const splitRatio = props.splitRatio ?? 0.6;
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
@ -210,7 +211,7 @@ export function renderChat(props: ChatProps) {
aria-live="polite"
@scroll=${props.onChatScroll}
>
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
${props.loading ? html`<div class="muted">${t("chat.loadingChat")}</div>` : nothing}
${repeat(buildChatItems(props), (item) => item.key, (item) => {
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity);
@ -257,8 +258,8 @@ export function renderChat(props: ChatProps) {
class="chat-focus-exit"
type="button"
@click=${props.onToggleFocusMode}
aria-label="Exit focus mode"
title="Exit focus mode"
aria-label="${t("chat.exitFocusMode")}"
title="${t("chat.exitFocusMode")}"
>
${icons.x}
</button>
@ -300,7 +301,7 @@ export function renderChat(props: ChatProps) {
${props.queue.length
? html`
<div class="chat-queue" role="status" aria-live="polite">
<div class="chat-queue__title">Queued (${props.queue.length})</div>
<div class="chat-queue__title">${t("chat.queued")} (${props.queue.length})</div>
<div class="chat-queue__list">
${props.queue.map(
(item) => html`
@ -308,13 +309,13 @@ export function renderChat(props: ChatProps) {
<div class="chat-queue__text">
${item.text ||
(item.attachments?.length
? `Image (${item.attachments.length})`
? `${t("chat.image")} (${item.attachments.length})`
: "")}
</div>
<button
class="btn chat-queue__remove"
type="button"
aria-label="Remove queued message"
aria-label="${t("chat.removeQueued")}"
@click=${() => props.onQueueRemove(item.id)}
>
${icons.x}
@ -331,7 +332,7 @@ export function renderChat(props: ChatProps) {
${renderAttachmentPreview(props)}
<div class="chat-compose__row">
<label class="field chat-compose__field">
<span>Message</span>
<span>${t("chat.message")}</span>
<textarea
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
.value=${props.draft}
@ -359,14 +360,14 @@ export function renderChat(props: ChatProps) {
?disabled=${!props.connected || (!canAbort && props.sending)}
@click=${canAbort ? props.onAbort : props.onNewSession}
>
${canAbort ? "Stop" : "New session"}
${canAbort ? t("chat.stop") : t("chat.newSession")}
</button>
<button
class="btn primary"
?disabled=${!props.connected}
@click=${props.onSend}
>
${isBusy ? "Queue" : "Send"}<kbd class="btn-kbd"></kbd>
${isBusy ? t("chat.queue") : t("chat.send")}<kbd class="btn-kbd"></kbd>
</button>
</div>
</div>

View File

@ -1,6 +1,7 @@
import { html, nothing } from "lit";
import type { LogEntry, LogLevel } from "../types";
import { t } from "../../i18n";
const LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
@ -44,41 +45,41 @@ export function renderLogs(props: LogsProps) {
if (entry.level && !props.levelFilters[entry.level]) return false;
return matchesFilter(entry, needle);
});
const exportLabel = needle || levelFiltered ? "filtered" : "visible";
const exportLabel = needle || levelFiltered ? t("logs.exportFiltered") : t("logs.exportVisible");
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Logs</div>
<div class="card-sub">Gateway file logs (JSONL).</div>
<div class="card-title">${t("logs.title")}</div>
<div class="card-sub">${t("logs.desc")}</div>
</div>
<div class="row" style="gap: 8px;">
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
${props.loading ? t("common.loading") : t("common.refresh")}
</button>
<button
class="btn"
?disabled=${filtered.length === 0}
@click=${() => props.onExport(filtered.map((entry) => entry.raw), exportLabel)}
>
Export ${exportLabel}
${exportLabel}
</button>
</div>
</div>
<div class="filters" style="margin-top: 14px;">
<label class="field" style="min-width: 220px;">
<span>Filter</span>
<span>${t("logs.filter")}</span>
<input
.value=${props.filterText}
@input=${(e: Event) =>
props.onFilterTextChange((e.target as HTMLInputElement).value)}
placeholder="Search logs"
placeholder="${t("logs.searchLogs")}"
/>
</label>
<label class="field checkbox">
<span>Auto-follow</span>
<span>${t("logs.autoFollow")}</span>
<input
type="checkbox"
.checked=${props.autoFollow}
@ -98,18 +99,18 @@ export function renderLogs(props: LogsProps) {
@change=${(e: Event) =>
props.onLevelToggle(level, (e.target as HTMLInputElement).checked)}
/>
<span>${level}</span>
<span>${t(`logs.levels.${level}`)}</span>
</label>
`,
)}
</div>
${props.file
? html`<div class="muted" style="margin-top: 10px;">File: ${props.file}</div>`
? html`<div class="muted" style="margin-top: 10px;">${t("logs.file")}: ${props.file}</div>`
: nothing}
${props.truncated
? html`<div class="callout" style="margin-top: 10px;">
Log output truncated; showing latest chunk.
${t("logs.truncated")}
</div>`
: nothing}
${props.error
@ -118,7 +119,7 @@ export function renderLogs(props: LogsProps) {
<div class="log-stream" style="margin-top: 12px;" @scroll=${props.onScroll}>
${filtered.length === 0
? html`<div class="muted" style="padding: 12px;">No log entries.</div>`
? html`<div class="muted" style="padding: 12px;">${t("logs.noEntries")}</div>`
: filtered.map(
(entry) => html`
<div class="log-row">

View File

@ -4,6 +4,7 @@ import type { GatewayHelloOk } from "../gateway";
import { formatAgo, formatDurationMs } from "../format";
import { formatNextRun } from "../presenter";
import type { UiSettings } from "../storage";
import { t } from "../../i18n";
export type OverviewProps = {
connected: boolean;
@ -41,10 +42,10 @@ export function renderOverview(props: OverviewProps) {
if (!hasToken && !hasPassword) {
return html`
<div class="muted" style="margin-top: 8px;">
This gateway requires auth. Add a token or password, then click Connect.
${t("overview.authRequired")}
<div style="margin-top: 6px;">
<span class="mono">moltbot dashboard --no-open</span> tokenized URL<br />
<span class="mono">moltbot doctor --generate-gateway-token</span> set token
<span class="mono">${t("overview.tokenizedUrlCmd")}</span> tokenized URL<br />
<span class="mono">${t("overview.generateTokenCmd")}</span> set token
</div>
<div style="margin-top: 6px;">
<a
@ -52,8 +53,8 @@ export function renderOverview(props: OverviewProps) {
href="https://docs.molt.bot/web/dashboard"
target="_blank"
rel="noreferrer"
title="Control UI auth docs (opens in new tab)"
>Docs: Control UI auth</a
title="${t("overview.authDocsLink")}"
>${t("overview.authDocsLink")}</a
>
</div>
</div>
@ -61,17 +62,16 @@ export function renderOverview(props: OverviewProps) {
}
return html`
<div class="muted" style="margin-top: 8px;">
Auth failed. Re-copy a tokenized URL with
<span class="mono">moltbot dashboard --no-open</span>, or update the token,
then click Connect.
${t("overview.authFailed")}
<span class="mono">${t("overview.tokenizedUrlCmd")}</span>${t("overview.thenClickConnect")}
<div style="margin-top: 6px;">
<a
class="session-link"
href="https://docs.molt.bot/web/dashboard"
target="_blank"
rel="noreferrer"
title="Control UI auth docs (opens in new tab)"
>Docs: Control UI auth</a
title="${t("overview.authDocsLink")}"
>${t("overview.authDocsLink")}</a
>
</div>
</div>
@ -87,11 +87,11 @@ export function renderOverview(props: OverviewProps) {
}
return html`
<div class="muted" style="margin-top: 8px;">
This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or
open <span class="mono">http://127.0.0.1:18789</span> on the gateway host.
${t("overview.insecureContext")}
<span class="mono">http://127.0.0.1:18789</span> ${t("overview.insecureContextLocal")}
<div style="margin-top: 6px;">
If you must stay on HTTP, set
<span class="mono">gateway.controlUi.allowInsecureAuth: true</span> (token-only).
${t("overview.insecureContextConfig")}
<span class="mono">${t("overview.insecureContextConfigValue")}</span> ${t("overview.insecureContextNote")}
</div>
<div style="margin-top: 6px;">
<a
@ -99,8 +99,8 @@ export function renderOverview(props: OverviewProps) {
href="https://docs.molt.bot/gateway/tailscale"
target="_blank"
rel="noreferrer"
title="Tailscale Serve docs (opens in new tab)"
>Docs: Tailscale Serve</a
title="${t("overview.tailscaleDocsLink")}"
>${t("overview.tailscaleDocsLink")}</a
>
<span class="muted"> · </span>
<a
@ -108,8 +108,8 @@ export function renderOverview(props: OverviewProps) {
href="https://docs.molt.bot/web/control-ui#insecure-http"
target="_blank"
rel="noreferrer"
title="Insecure HTTP docs (opens in new tab)"
>Docs: Insecure HTTP</a
title="${t("overview.insecureHttpDocsLink")}"
>${t("overview.insecureHttpDocsLink")}</a
>
</div>
</div>
@ -119,11 +119,11 @@ export function renderOverview(props: OverviewProps) {
return html`
<section class="grid grid-cols-2">
<div class="card">
<div class="card-title">Gateway Access</div>
<div class="card-sub">Where the dashboard connects and how it authenticates.</div>
<div class="card-title">${t("overview.gatewayAccess")}</div>
<div class="card-sub">${t("overview.gatewayAccessDesc")}</div>
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>WebSocket URL</span>
<span>${t("overview.websocketUrl")}</span>
<input
.value=${props.settings.gatewayUrl}
@input=${(e: Event) => {
@ -134,7 +134,7 @@ export function renderOverview(props: OverviewProps) {
/>
</label>
<label class="field">
<span>Gateway Token</span>
<span>${t("overview.gatewayToken")}</span>
<input
.value=${props.settings.token}
@input=${(e: Event) => {
@ -145,7 +145,7 @@ export function renderOverview(props: OverviewProps) {
/>
</label>
<label class="field">
<span>Password (not stored)</span>
<span>${t("overview.password")}</span>
<input
type="password"
.value=${props.password}
@ -153,11 +153,11 @@ export function renderOverview(props: OverviewProps) {
const v = (e.target as HTMLInputElement).value;
props.onPasswordChange(v);
}}
placeholder="system or shared password"
placeholder="${t("overview.passwordPlaceholder")}"
/>
</label>
<label class="field">
<span>Default Session Key</span>
<span>${t("overview.defaultSessionKey")}</span>
<input
.value=${props.settings.sessionKey}
@input=${(e: Event) => {
@ -168,36 +168,36 @@ export function renderOverview(props: OverviewProps) {
</label>
</div>
<div class="row" style="margin-top: 14px;">
<button class="btn" @click=${() => props.onConnect()}>Connect</button>
<button class="btn" @click=${() => props.onRefresh()}>Refresh</button>
<span class="muted">Click Connect to apply connection changes.</span>
<button class="btn" @click=${() => props.onConnect()}>${t("overview.connect")}</button>
<button class="btn" @click=${() => props.onRefresh()}>${t("common.refresh")}</button>
<span class="muted">${t("overview.connectNote")}</span>
</div>
</div>
<div class="card">
<div class="card-title">Snapshot</div>
<div class="card-sub">Latest gateway handshake information.</div>
<div class="card-title">${t("overview.snapshot")}</div>
<div class="card-sub">${t("overview.snapshotDesc")}</div>
<div class="stat-grid" style="margin-top: 16px;">
<div class="stat">
<div class="stat-label">Status</div>
<div class="stat-label">${t("overview.status")}</div>
<div class="stat-value ${props.connected ? "ok" : "warn"}">
${props.connected ? "Connected" : "Disconnected"}
${props.connected ? t("common.connected") : t("common.disconnected")}
</div>
</div>
<div class="stat">
<div class="stat-label">Uptime</div>
<div class="stat-label">${t("overview.uptime")}</div>
<div class="stat-value">${uptime}</div>
</div>
<div class="stat">
<div class="stat-label">Tick Interval</div>
<div class="stat-label">${t("overview.tickInterval")}</div>
<div class="stat-value">${tick}</div>
</div>
<div class="stat">
<div class="stat-label">Last Channels Refresh</div>
<div class="stat-label">${t("overview.lastChannelsRefresh")}</div>
<div class="stat-value">
${props.lastChannelsRefresh
? formatAgo(props.lastChannelsRefresh)
: "n/a"}
: t("common.na")}
</div>
</div>
</div>
@ -208,52 +208,52 @@ export function renderOverview(props: OverviewProps) {
${insecureContextHint ?? ""}
</div>`
: html`<div class="callout" style="margin-top: 14px;">
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
${t("overview.channelsHint")}
</div>`}
</div>
</section>
<section class="grid grid-cols-3" style="margin-top: 18px;">
<div class="card stat-card">
<div class="stat-label">Instances</div>
<div class="stat-label">${t("overview.instancesCard")}</div>
<div class="stat-value">${props.presenceCount}</div>
<div class="muted">Presence beacons in the last 5 minutes.</div>
<div class="muted">${t("overview.instancesDesc")}</div>
</div>
<div class="card stat-card">
<div class="stat-label">Sessions</div>
<div class="stat-value">${props.sessionsCount ?? "n/a"}</div>
<div class="muted">Recent session keys tracked by the gateway.</div>
<div class="stat-label">${t("overview.sessionsCard")}</div>
<div class="stat-value">${props.sessionsCount ?? t("common.na")}</div>
<div class="muted">${t("overview.sessionsDesc")}</div>
</div>
<div class="card stat-card">
<div class="stat-label">Cron</div>
<div class="stat-label">${t("overview.cronCard")}</div>
<div class="stat-value">
${props.cronEnabled == null
? "n/a"
? t("common.na")
: props.cronEnabled
? "Enabled"
: "Disabled"}
? t("common.enabled")
: t("common.disabled")}
</div>
<div class="muted">Next wake ${formatNextRun(props.cronNext)}</div>
<div class="muted">${t("overview.nextWake")} ${formatNextRun(props.cronNext)}</div>
</div>
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Notes</div>
<div class="card-sub">Quick reminders for remote control setups.</div>
<div class="card-title">${t("overview.notes")}</div>
<div class="card-sub">${t("overview.notesDesc")}</div>
<div class="note-grid" style="margin-top: 14px;">
<div>
<div class="note-title">Tailscale serve</div>
<div class="note-title">${t("overview.tailscaleServe")}</div>
<div class="muted">
Prefer serve mode to keep the gateway on loopback with tailnet auth.
${t("overview.tailscaleServeDesc")}
</div>
</div>
<div>
<div class="note-title">Session hygiene</div>
<div class="muted">Use /new or sessions.patch to reset context.</div>
<div class="note-title">${t("overview.sessionHygiene")}</div>
<div class="muted">${t("overview.sessionHygieneDesc")}</div>
</div>
<div>
<div class="note-title">Cron reminders</div>
<div class="muted">Use isolated sessions for recurring runs.</div>
<div class="note-title">${t("overview.cronReminders")}</div>
<div class="muted">${t("overview.cronRemindersDesc")}</div>
</div>
</div>
</section>

View File

@ -4,6 +4,7 @@ import { formatAgo } from "../format";
import { formatSessionTokens } from "../presenter";
import { pathForTab } from "../navigation";
import type { GatewaySessionRow, SessionsListResult } from "../types";
import { t } from "../../i18n";
export type SessionsProps = {
loading: boolean;
@ -76,17 +77,17 @@ export function renderSessions(props: SessionsProps) {
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Sessions</div>
<div class="card-sub">Active session keys and per-session overrides.</div>
<div class="card-title">${t("sessions.title")}</div>
<div class="card-sub">${t("sessions.desc")}</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
${props.loading ? t("common.loading") : t("common.refresh")}
</button>
</div>
<div class="filters" style="margin-top: 14px;">
<label class="field">
<span>Active within (minutes)</span>
<span>${t("sessions.activeWithin")}</span>
<input
.value=${props.activeMinutes}
@input=${(e: Event) =>
@ -99,7 +100,7 @@ export function renderSessions(props: SessionsProps) {
/>
</label>
<label class="field">
<span>Limit</span>
<span>${t("sessions.limit")}</span>
<input
.value=${props.limit}
@input=${(e: Event) =>
@ -112,7 +113,7 @@ export function renderSessions(props: SessionsProps) {
/>
</label>
<label class="field checkbox">
<span>Include global</span>
<span>${t("sessions.includeGlobal")}</span>
<input
type="checkbox"
.checked=${props.includeGlobal}
@ -126,7 +127,7 @@ export function renderSessions(props: SessionsProps) {
/>
</label>
<label class="field checkbox">
<span>Include unknown</span>
<span>${t("sessions.includeUnknown")}</span>
<input
type="checkbox"
.checked=${props.includeUnknown}
@ -146,23 +147,23 @@ export function renderSessions(props: SessionsProps) {
: nothing}
<div class="muted" style="margin-top: 12px;">
${props.result ? `Store: ${props.result.path}` : ""}
${props.result ? `${t("sessions.store")}: ${props.result.path}` : ""}
</div>
<div class="table" style="margin-top: 16px;">
<div class="table-head">
<div>Key</div>
<div>Label</div>
<div>Kind</div>
<div>Updated</div>
<div>Tokens</div>
<div>Thinking</div>
<div>Verbose</div>
<div>Reasoning</div>
<div>Actions</div>
<div>${t("sessions.columns.key")}</div>
<div>${t("sessions.columns.label")}</div>
<div>${t("sessions.columns.kind")}</div>
<div>${t("sessions.columns.updated")}</div>
<div>${t("sessions.columns.tokens")}</div>
<div>${t("sessions.columns.thinking")}</div>
<div>${t("sessions.columns.verbose")}</div>
<div>${t("sessions.columns.reasoning")}</div>
<div>${t("sessions.columns.actions")}</div>
</div>
${rows.length === 0
? html`<div class="muted">No sessions found.</div>`
? html`<div class="muted">${t("sessions.noSessions")}</div>`
: rows.map((row) =>
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
)}
@ -178,7 +179,7 @@ function renderRow(
onDelete: SessionsProps["onDelete"],
disabled: boolean,
) {
const updated = row.updatedAt ? formatAgo(row.updatedAt) : "n/a";
const updated = row.updatedAt ? formatAgo(row.updatedAt) : t("common.na");
const rawThinking = row.thinkingLevel ?? "";
const isBinaryThinking = isBinaryThinkingProvider(row.modelProvider);
const thinking = resolveThinkLevelDisplay(rawThinking, isBinaryThinking);
@ -200,7 +201,7 @@ function renderRow(
<input
.value=${row.label ?? ""}
?disabled=${disabled}
placeholder="(optional)"
placeholder="${t("common.optional")}"
@change=${(e: Event) => {
const value = (e.target as HTMLInputElement).value.trim();
onPatch(row.key, { label: value || null });
@ -222,7 +223,7 @@ function renderRow(
}}
>
${thinkLevels.map((level) =>
html`<option value=${level}>${level || "inherit"}</option>`,
html`<option value=${level}>${level || t("common.inherit")}</option>`,
)}
</select>
</div>
@ -250,13 +251,13 @@ function renderRow(
}}
>
${REASONING_LEVELS.map((level) =>
html`<option value=${level}>${level || "inherit"}</option>`,
html`<option value=${level}>${level || t("common.inherit")}</option>`,
)}
</select>
</div>
<div>
<button class="btn danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}>
Delete
${t("common.delete")}
</button>
</div>
</div>