Merge pull request #1 from ckijficqstrvy/claude/project-analysis-c2lri

Claude/project analysis c2lri
This commit is contained in:
ckijficqstrvy 2026-01-29 23:33:42 +08:00 committed by GitHub
commit 0c5759b9d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 2250 additions and 543 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,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",
},
};

View File

@ -0,0 +1,754 @@
/**
* 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: "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瀏覽器會阻擋裝置身分驗證。請使用 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: "已連線",
},
// 一般
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: "找不到資源",
},
};

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

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

View File

@ -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<typeof refreshChat>[0]);
}}
title="Refresh chat data"
title="${t("common.refresh")}"
>
${refreshIcon}
</button>
@ -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}
</button>
@ -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}
</button>
@ -209,14 +210,14 @@ export function renderThemeToggle(state: AppViewState) {
return html`
<div class="theme-toggle" style="--theme-index: ${index};">
<div class="theme-toggle__track" role="group" aria-label="Theme">
<div class="theme-toggle__track" role="group" aria-label="${t("theme.ariaLabel")}">
<span class="theme-toggle__indicator"></span>
<button
class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
@click=${applyTheme("system")}
aria-pressed=${state.theme === "system"}
aria-label="System theme"
title="System"
aria-label="${t("theme.systemAriaLabel")}"
title="${t("theme.system")}"
>
${renderMonitorIcon()}
</button>
@ -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()}
</button>
@ -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()}
</button>
@ -278,3 +279,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="${t("chatControls.selectLanguage")}"
>
${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";
@ -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")}"
>
<span class="nav-collapse-toggle__icon">${icons.menu}</span>
</button>
@ -133,18 +135,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" : ""}">
@ -165,7 +168,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">
@ -176,7 +179,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
@ -184,10 +187,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

@ -1,5 +1,6 @@
import { html } from "lit";
import { t } from "../../i18n";
import type { ConfigUiHints } from "../types";
import type { ChannelsProps } from "./channels.types";
import {
@ -71,11 +72,11 @@ export function renderChannelConfigForm(props: ChannelConfigFormProps) {
const analysis = analyzeConfigSchema(props.schema);
const normalized = analysis.schema;
if (!normalized) {
return html`<div class="callout danger">Schema unavailable. Use Raw.</div>`;
return html`<div class="callout danger">${t("channels.config.schemaUnavailable")}</div>`;
}
const node = resolveSchemaNode(normalized, ["channels", props.channelId]);
if (!node) {
return html`<div class="callout danger">Channel config schema unavailable.</div>`;
return html`<div class="callout danger">${t("channels.config.channelSchemaUnavailable")}</div>`;
}
const configValue = props.configValue ?? {};
const value = resolveChannelValue(configValue, props.channelId);
@ -104,7 +105,7 @@ export function renderChannelConfigSection(params: {
return html`
<div style="margin-top: 16px;">
${props.configSchemaLoading
? html`<div class="muted">Loading config schema…</div>`
? html`<div class="muted">${t("channels.config.loadingSchema")}</div>`
: renderChannelConfigForm({
channelId,
configValue: props.configForm,
@ -119,14 +120,14 @@ export function renderChannelConfigSection(params: {
?disabled=${disabled || !props.configFormDirty}
@click=${() => props.onConfigSave()}
>
${props.configSaving ? "Saving…" : "Save"}
${props.configSaving ? t("common.saving") : t("common.save")}
</button>
<button
class="btn"
?disabled=${disabled}
@click=${() => props.onConfigReload()}
>
Reload
${t("channels.config.reload")}
</button>
</div>
</div>

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import { formatAgo } from "../format";
import type { DiscordStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
@ -15,25 +16,25 @@ export function renderDiscordCard(params: {
return html`
<div class="card">
<div class="card-title">Discord</div>
<div class="card-sub">Bot status and channel configuration.</div>
<div class="card-sub">${t("channels.discord.desc")}</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${discord?.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.configured")}</span>
<span>${discord?.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${discord?.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.running")}</span>
<span>${discord?.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
<span class="label">${t("channels.discord.lastStart")}</span>
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : t("common.na")}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
<span class="label">${t("channels.discord.lastProbe")}</span>
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : t("common.na")}</span>
</div>
</div>
@ -45,7 +46,7 @@ export function renderDiscordCard(params: {
${discord?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} ·
${t("channels.discord.probe")} ${discord.probe.ok ? t("channels.discord.probeOk") : t("channels.discord.probeFailed")} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
</div>`
: nothing}
@ -54,7 +55,7 @@ export function renderDiscordCard(params: {
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
${t("channels.discord.probe")}
</button>
</div>
</div>

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import { formatAgo } from "../format";
import type { GoogleChatStatus } from "../types";
import { renderChannelConfigSection } from "./channels.config";
@ -15,37 +16,37 @@ export function renderGoogleChatCard(params: {
return html`
<div class="card">
<div class="card-title">Google Chat</div>
<div class="card-sub">Chat API webhook status and channel configuration.</div>
<div class="card-sub">${t("channels.googlechat.desc")}</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"}</span>
<span class="label">${t("channels.labels.configured")}</span>
<span>${googleChat ? (googleChat.configured ? t("common.yes") : t("common.no")) : t("common.na")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"}</span>
<span class="label">${t("channels.labels.running")}</span>
<span>${googleChat ? (googleChat.running ? t("common.yes") : t("common.no")) : t("common.na")}</span>
</div>
<div>
<span class="label">Credential</span>
<span>${googleChat?.credentialSource ?? "n/a"}</span>
<span class="label">${t("channels.googlechat.credential")}</span>
<span>${googleChat?.credentialSource ?? t("common.na")}</span>
</div>
<div>
<span class="label">Audience</span>
<span class="label">${t("channels.googlechat.audience")}</span>
<span>
${googleChat?.audienceType
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
: "n/a"}
: t("common.na")}
</span>
</div>
<div>
<span class="label">Last start</span>
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}</span>
<span class="label">${t("channels.googlechat.lastStart")}</span>
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : t("common.na")}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}</span>
<span class="label">${t("channels.googlechat.lastProbe")}</span>
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : t("common.na")}</span>
</div>
</div>
@ -57,7 +58,7 @@ export function renderGoogleChatCard(params: {
${googleChat?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
${t("channels.googlechat.probe")} ${googleChat.probe.ok ? t("channels.googlechat.probeOk") : t("channels.googlechat.probeFailed")} ·
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
</div>`
: nothing}
@ -66,7 +67,7 @@ export function renderGoogleChatCard(params: {
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
${t("channels.googlechat.probe")}
</button>
</div>
</div>

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import { formatAgo } from "../format";
import type { IMessageStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
@ -15,25 +16,25 @@ export function renderIMessageCard(params: {
return html`
<div class="card">
<div class="card-title">iMessage</div>
<div class="card-sub">macOS bridge status and channel configuration.</div>
<div class="card-sub">${t("channels.imessage.desc")}</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${imessage?.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.configured")}</span>
<span>${imessage?.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${imessage?.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.running")}</span>
<span>${imessage?.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span>
<span class="label">${t("channels.imessage.lastStart")}</span>
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : t("common.na")}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span>
<span class="label">${t("channels.imessage.lastProbe")}</span>
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : t("common.na")}</span>
</div>
</div>
@ -45,7 +46,7 @@ export function renderIMessageCard(params: {
${imessage?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
${t("channels.imessage.probe")} ${imessage.probe.ok ? t("channels.imessage.probeOk") : t("channels.imessage.probeFailed")} ·
${imessage.probe.error ?? ""}
</div>`
: nothing}
@ -54,7 +55,7 @@ export function renderIMessageCard(params: {
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
${t("channels.imessage.probe")}
</button>
</div>
</div>

View File

@ -6,6 +6,7 @@
import { html, nothing, type TemplateResult } from "lit";
import { t } from "../../i18n";
import type { NostrProfile as NostrProfileType } from "../types";
// ============================================================================
@ -147,7 +148,7 @@ export function renderNostrProfileForm(params: {
<div style="margin-bottom: 12px;">
<img
src=${picture}
alt="Profile picture preview"
alt="${t("channels.nostr.profileForm.picturePreview")}"
style="max-width: 80px; max-height: 80px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color);"
@error=${(e: Event) => {
const img = e.target as HTMLImageElement;
@ -165,8 +166,8 @@ export function renderNostrProfileForm(params: {
return html`
<div class="nostr-profile-form" style="padding: 16px; background: var(--bg-secondary); border-radius: 8px; margin-top: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<div style="font-weight: 600; font-size: 16px;">Edit Profile</div>
<div style="font-size: 12px; color: var(--text-muted);">Account: ${accountId}</div>
<div style="font-weight: 600; font-size: 16px;">${t("channels.nostr.profileForm.title")}</div>
<div style="font-size: 12px; color: var(--text-muted);">${t("channels.nostr.profileForm.account")}: ${accountId}</div>
</div>
${state.error
@ -179,56 +180,56 @@ export function renderNostrProfileForm(params: {
${renderPicturePreview()}
${renderField("name", "Username", {
placeholder: "satoshi",
${renderField("name", t("channels.nostr.profileForm.name"), {
placeholder: t("channels.nostr.profileForm.namePlaceholder"),
maxLength: 256,
help: "Short username (e.g., satoshi)",
help: t("channels.nostr.profileForm.nameHelp"),
})}
${renderField("displayName", "Display Name", {
placeholder: "Satoshi Nakamoto",
${renderField("displayName", t("channels.nostr.profileForm.displayName"), {
placeholder: t("channels.nostr.profileForm.displayNamePlaceholder"),
maxLength: 256,
help: "Your full display name",
help: t("channels.nostr.profileForm.displayNameHelp"),
})}
${renderField("about", "Bio", {
${renderField("about", t("channels.nostr.profileForm.about"), {
type: "textarea",
placeholder: "Tell people about yourself...",
placeholder: t("channels.nostr.profileForm.aboutPlaceholder"),
maxLength: 2000,
help: "A brief bio or description",
help: t("channels.nostr.profileForm.aboutHelp"),
})}
${renderField("picture", "Avatar URL", {
${renderField("picture", t("channels.nostr.profileForm.picture"), {
type: "url",
placeholder: "https://example.com/avatar.jpg",
help: "HTTPS URL to your profile picture",
placeholder: t("channels.nostr.profileForm.picturePlaceholder"),
help: t("channels.nostr.profileForm.pictureHelp"),
})}
${state.showAdvanced
? html`
<div style="border-top: 1px solid var(--border-color); padding-top: 12px; margin-top: 12px;">
<div style="font-weight: 500; margin-bottom: 12px; color: var(--text-muted);">Advanced</div>
<div style="font-weight: 500; margin-bottom: 12px; color: var(--text-muted);">${t("channels.nostr.profileForm.advanced")}</div>
${renderField("banner", "Banner URL", {
${renderField("banner", t("channels.nostr.profileForm.banner"), {
type: "url",
placeholder: "https://example.com/banner.jpg",
help: "HTTPS URL to a banner image",
placeholder: t("channels.nostr.profileForm.bannerPlaceholder"),
help: t("channels.nostr.profileForm.bannerHelp"),
})}
${renderField("website", "Website", {
${renderField("website", t("channels.nostr.profileForm.website"), {
type: "url",
placeholder: "https://example.com",
help: "Your personal website",
placeholder: t("channels.nostr.profileForm.websitePlaceholder"),
help: t("channels.nostr.profileForm.websiteHelp"),
})}
${renderField("nip05", "NIP-05 Identifier", {
placeholder: "you@example.com",
help: "Verifiable identifier (e.g., you@domain.com)",
${renderField("nip05", t("channels.nostr.profileForm.nip05"), {
placeholder: t("channels.nostr.profileForm.nip05Placeholder"),
help: t("channels.nostr.profileForm.nip05Help"),
})}
${renderField("lud16", "Lightning Address", {
placeholder: "you@getalby.com",
help: "Lightning address for tips (LUD-16)",
${renderField("lud16", t("channels.nostr.profileForm.lud16"), {
placeholder: t("channels.nostr.profileForm.lud16Placeholder"),
help: t("channels.nostr.profileForm.lud16Help"),
})}
</div>
`
@ -240,7 +241,7 @@ export function renderNostrProfileForm(params: {
@click=${callbacks.onSave}
?disabled=${state.saving || !isDirty}
>
${state.saving ? "Saving..." : "Save & Publish"}
${state.saving ? t("common.saving") : t("channels.nostr.profileForm.savePublish")}
</button>
<button
@ -248,14 +249,14 @@ export function renderNostrProfileForm(params: {
@click=${callbacks.onImport}
?disabled=${state.importing || state.saving}
>
${state.importing ? "Importing..." : "Import from Relays"}
${state.importing ? t("channels.nostr.profileForm.importing") : t("channels.nostr.profileForm.importFromRelays")}
</button>
<button
class="btn"
@click=${callbacks.onToggleAdvanced}
>
${state.showAdvanced ? "Hide Advanced" : "Show Advanced"}
${state.showAdvanced ? t("channels.nostr.profileForm.hideAdvanced") : t("channels.nostr.profileForm.showAdvanced")}
</button>
<button
@ -263,13 +264,13 @@ export function renderNostrProfileForm(params: {
@click=${callbacks.onCancel}
?disabled=${state.saving}
>
Cancel
${t("common.cancel")}
</button>
</div>
${isDirty
? html`<div style="font-size: 12px; color: var(--warning-color); margin-top: 8px;">
You have unsaved changes
${t("channels.nostr.profileForm.unsavedChanges")}
</div>`
: nothing}
</div>

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import { formatAgo } from "../format";
import type { ChannelAccountSnapshot, NostrStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
@ -14,7 +15,7 @@ import {
* Truncate a pubkey for display (shows first and last 8 chars)
*/
function truncatePubkey(pubkey: string | null | undefined): string {
if (!pubkey) return "n/a";
if (!pubkey) return t("common.na");
if (pubkey.length <= 20) return pubkey;
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
}
@ -64,20 +65,20 @@ export function renderNostrCard(params: {
</div>
<div class="status-list account-card-status">
<div>
<span class="label">Running</span>
<span>${account.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.running")}</span>
<span>${account.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Configured</span>
<span>${account.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.configured")}</span>
<span>${account.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Public Key</span>
<span class="label">${t("channels.nostr.publicKey")}</span>
<span class="monospace" title="${publicKey ?? ""}">${truncatePubkey(publicKey)}</span>
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
<span class="label">${t("channels.lastInbound")}</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : t("common.na")}</span>
</div>
${account.lastError
? html`
@ -117,7 +118,7 @@ export function renderNostrCard(params: {
return html`
<div style="margin-top: 16px; padding: 12px; background: var(--bg-secondary); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div style="font-weight: 500;">Profile</div>
<div style="font-weight: 500;">${t("channels.nostr.profile")}</div>
${summaryConfigured
? html`
<button
@ -125,7 +126,7 @@ export function renderNostrCard(params: {
@click=${onEditProfile}
style="font-size: 12px; padding: 4px 8px;"
>
Edit Profile
${t("channels.nostr.editProfile")}
</button>
`
: nothing}
@ -138,7 +139,7 @@ export function renderNostrCard(params: {
<div style="margin-bottom: 8px;">
<img
src=${picture}
alt="Profile picture"
alt="${t("channels.nostr.profilePicture")}"
style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color);"
@error=${(e: Event) => {
(e.target as HTMLImageElement).style.display = "none";
@ -147,19 +148,19 @@ export function renderNostrCard(params: {
</div>
`
: nothing}
${name ? html`<div><span class="label">Name</span><span>${name}</span></div>` : nothing}
${name ? html`<div><span class="label">${t("channels.nostr.profileForm.name")}</span><span>${name}</span></div>` : nothing}
${displayName
? html`<div><span class="label">Display Name</span><span>${displayName}</span></div>`
? html`<div><span class="label">${t("channels.nostr.displayName")}</span><span>${displayName}</span></div>`
: nothing}
${about
? html`<div><span class="label">About</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>`
? html`<div><span class="label">${t("channels.nostr.profileForm.about")}</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>`
: nothing}
${nip05 ? html`<div><span class="label">NIP-05</span><span>${nip05}</span></div>` : nothing}
${nip05 ? html`<div><span class="label">${t("channels.nostr.profileForm.nip05")}</span><span>${nip05}</span></div>` : nothing}
</div>
`
: html`
<div style="color: var(--text-muted); font-size: 13px;">
No profile set. Click "Edit Profile" to add your name, bio, and avatar.
${t("channels.nostr.noProfile")}
</div>
`}
</div>
@ -169,7 +170,7 @@ export function renderNostrCard(params: {
return html`
<div class="card">
<div class="card-title">Nostr</div>
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div>
<div class="card-sub">${t("channels.nostr.desc")}</div>
${accountCountLabel}
${hasMultipleAccounts
@ -181,22 +182,22 @@ export function renderNostrCard(params: {
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${summaryConfigured ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.configured")}</span>
<span>${summaryConfigured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${summaryRunning ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.running")}</span>
<span>${summaryRunning ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Public Key</span>
<span class="label">${t("channels.nostr.publicKey")}</span>
<span class="monospace" title="${summaryPublicKey ?? ""}"
>${truncatePubkey(summaryPublicKey)}</span
>
</div>
<div>
<span class="label">Last start</span>
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
<span class="label">${t("channels.nostr.lastStart")}</span>
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : t("common.na")}</span>
</div>
</div>
`}
@ -210,7 +211,7 @@ export function renderNostrCard(params: {
${renderChannelConfigSection({ channelId: "nostr", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(false)}>Refresh</button>
<button class="btn" @click=${() => props.onRefresh(false)}>${t("common.refresh")}</button>
</div>
</div>
`;

View File

@ -1,10 +1,11 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import type { ChannelAccountSnapshot } from "../types";
import type { ChannelKey, ChannelsProps } from "./channels.types";
export function formatDuration(ms?: number | null) {
if (!ms && ms !== 0) return "n/a";
if (!ms && ms !== 0) return t("common.na");
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.round(sec / 60);
@ -41,5 +42,5 @@ export function renderChannelAccountCount(
) {
const count = getChannelAccountCount(key, channelAccounts);
if (count < 2) return nothing;
return html`<div class="account-count">Accounts (${count})</div>`;
return html`<div class="account-count">${t("channels.accounts")} (${count})</div>`;
}

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import { formatAgo } from "../format";
import type { SignalStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
@ -15,29 +16,29 @@ export function renderSignalCard(params: {
return html`
<div class="card">
<div class="card-title">Signal</div>
<div class="card-sub">signal-cli status and channel configuration.</div>
<div class="card-sub">${t("channels.signal.desc")}</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${signal?.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.configured")}</span>
<span>${signal?.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${signal?.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.running")}</span>
<span>${signal?.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Base URL</span>
<span>${signal?.baseUrl ?? "n/a"}</span>
<span class="label">${t("channels.signal.baseUrl")}</span>
<span>${signal?.baseUrl ?? t("common.na")}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
<span class="label">${t("channels.signal.lastStart")}</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : t("common.na")}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
<span class="label">${t("channels.signal.lastProbe")}</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : t("common.na")}</span>
</div>
</div>
@ -49,7 +50,7 @@ export function renderSignalCard(params: {
${signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} ·
${t("channels.signal.probe")} ${signal.probe.ok ? t("channels.signal.probeOk") : t("channels.signal.probeFailed")} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>`
: nothing}
@ -58,7 +59,7 @@ export function renderSignalCard(params: {
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
${t("channels.signal.probe")}
</button>
</div>
</div>

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import { formatAgo } from "../format";
import type { SlackStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
@ -15,25 +16,25 @@ export function renderSlackCard(params: {
return html`
<div class="card">
<div class="card-title">Slack</div>
<div class="card-sub">Socket mode status and channel configuration.</div>
<div class="card-sub">${t("channels.slack.desc")}</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${slack?.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.configured")}</span>
<span>${slack?.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${slack?.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.running")}</span>
<span>${slack?.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
<span class="label">${t("channels.slack.lastStart")}</span>
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : t("common.na")}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
<span class="label">${t("channels.slack.lastProbe")}</span>
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : t("common.na")}</span>
</div>
</div>
@ -45,7 +46,7 @@ export function renderSlackCard(params: {
${slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} ·
${t("channels.slack.probe")} ${slack.probe.ok ? t("channels.slack.probeOk") : t("channels.slack.probeFailed")} ·
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
</div>`
: nothing}
@ -54,7 +55,7 @@ export function renderSlackCard(params: {
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
${t("channels.slack.probe")}
</button>
</div>
</div>

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import { formatAgo } from "../format";
import type { ChannelAccountSnapshot, TelegramStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
@ -28,16 +29,16 @@ export function renderTelegramCard(params: {
</div>
<div class="status-list account-card-status">
<div>
<span class="label">Running</span>
<span>${account.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.running")}</span>
<span>${account.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Configured</span>
<span>${account.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.configured")}</span>
<span>${account.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
<span class="label">${t("channels.lastInbound")}</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : t("common.na")}</span>
</div>
${account.lastError
? html`
@ -54,7 +55,7 @@ export function renderTelegramCard(params: {
return html`
<div class="card">
<div class="card-title">Telegram</div>
<div class="card-sub">Bot status and channel configuration.</div>
<div class="card-sub">${t("channels.telegram.desc")}</div>
${accountCountLabel}
${hasMultipleAccounts
@ -66,24 +67,24 @@ export function renderTelegramCard(params: {
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${telegram?.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.configured")}</span>
<span>${telegram?.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${telegram?.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.running")}</span>
<span>${telegram?.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Mode</span>
<span>${telegram?.mode ?? "n/a"}</span>
<span class="label">${t("channels.telegram.mode")}</span>
<span>${telegram?.mode ?? t("common.na")}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
<span class="label">${t("channels.telegram.lastStart")}</span>
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : t("common.na")}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
<span class="label">${t("channels.telegram.lastProbe")}</span>
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : t("common.na")}</span>
</div>
</div>
`}
@ -96,7 +97,7 @@ export function renderTelegramCard(params: {
${telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
${t("channels.telegram.probe")} ${telegram.probe.ok ? t("channels.telegram.probeOk") : t("channels.telegram.probeFailed")} ·
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
</div>`
: nothing}
@ -105,7 +106,7 @@ export function renderTelegramCard(params: {
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
${t("channels.telegram.probe")}
</button>
</div>
</div>

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import { formatAgo } from "../format";
import type {
ChannelAccountSnapshot,
@ -77,10 +78,10 @@ export function renderChannels(props: ChannelsProps) {
<section class="card" style="margin-top: 18px;">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Channel health</div>
<div class="card-sub">Channel status snapshots from the gateway.</div>
<div class="card-title">${t("channels.health")}</div>
<div class="card-sub">${t("channels.healthDesc")}</div>
</div>
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div>
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : t("common.na")}</div>
</div>
${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
@ -88,7 +89,7 @@ export function renderChannels(props: ChannelsProps) {
</div>`
: nothing}
<pre class="code-block" style="margin-top: 12px;">
${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : t("channels.noSnapshot")}
</pre>
</section>
`;

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import { formatAgo } from "../format";
import type { WhatsAppStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
@ -16,46 +17,46 @@ export function renderWhatsAppCard(params: {
return html`
<div class="card">
<div class="card-title">WhatsApp</div>
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div>
<div class="card-sub">${t("channels.whatsapp.desc")}</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${whatsapp?.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.configured")}</span>
<span>${whatsapp?.configured ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Linked</span>
<span>${whatsapp?.linked ? "Yes" : "No"}</span>
<span class="label">${t("channels.whatsapp.linked")}</span>
<span>${whatsapp?.linked ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${whatsapp?.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.running")}</span>
<span>${whatsapp?.running ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Connected</span>
<span>${whatsapp?.connected ? "Yes" : "No"}</span>
<span class="label">${t("channels.labels.connected")}</span>
<span>${whatsapp?.connected ? t("common.yes") : t("common.no")}</span>
</div>
<div>
<span class="label">Last connect</span>
<span class="label">${t("channels.whatsapp.lastConnect")}</span>
<span>
${whatsapp?.lastConnectedAt
? formatAgo(whatsapp.lastConnectedAt)
: "n/a"}
: t("common.na")}
</span>
</div>
<div>
<span class="label">Last message</span>
<span class="label">${t("channels.lastInbound")}</span>
<span>
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : t("common.na")}
</span>
</div>
<div>
<span class="label">Auth age</span>
<span class="label">${t("channels.whatsapp.authAge")}</span>
<span>
${whatsapp?.authAgeMs != null
? formatDuration(whatsapp.authAgeMs)
: "n/a"}
: t("common.na")}
</span>
</div>
</div>
@ -84,31 +85,31 @@ export function renderWhatsAppCard(params: {
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppStart(false)}
>
${props.whatsappBusy ? "Working…" : "Show QR"}
${props.whatsappBusy ? t("channels.whatsapp.working") : t("channels.whatsapp.showQr")}
</button>
<button
class="btn"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppStart(true)}
>
Relink
${t("channels.whatsapp.relink")}
</button>
<button
class="btn"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppWait()}
>
Wait for scan
${t("channels.whatsapp.waitForScan")}
</button>
<button
class="btn danger"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppLogout()}
>
Logout
${t("channels.whatsapp.logout")}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>
Refresh
${t("common.refresh")}
</button>
</div>

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,4 +1,5 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import type { ConfigUiHints } from "../types";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form";
import {
@ -73,21 +74,15 @@ const sidebarIcons = {
default: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
};
// Section definitions
const SECTIONS: Array<{ key: string; label: string }> = [
{ key: "env", label: "Environment" },
{ key: "update", label: "Updates" },
{ key: "agents", label: "Agents" },
{ key: "auth", label: "Authentication" },
{ key: "channels", label: "Channels" },
{ key: "messages", label: "Messages" },
{ key: "commands", label: "Commands" },
{ key: "hooks", label: "Hooks" },
{ key: "skills", label: "Skills" },
{ key: "tools", label: "Tools" },
{ key: "gateway", label: "Gateway" },
{ key: "wizard", label: "Setup Wizard" },
];
// Section definitions - labels are resolved via t() at render time
const SECTION_KEYS = [
"env", "update", "agents", "auth", "channels", "messages",
"commands", "hooks", "skills", "tools", "gateway", "wizard",
] as const;
function getSectionLabel(key: string): string {
return t(`config.sections.${key}`) || humanize(key);
}
type SubsectionEntry = {
key: string;
@ -191,13 +186,15 @@ export function renderConfig(props: ConfigProps) {
// Get available sections from schema
const schemaProps = analysis.schema?.properties ?? {};
const availableSections = SECTIONS.filter(s => s.key in schemaProps);
const knownKeys = new Set(SECTION_KEYS);
const availableSections = SECTION_KEYS
.filter(k => k in schemaProps)
.map(k => ({ key: k, label: getSectionLabel(k) }));
// Add any sections in schema but not in our list
const knownKeys = new Set(SECTIONS.map(s => s.key));
const extraSections = Object.keys(schemaProps)
.filter(k => !knownKeys.has(k))
.map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
.map(k => ({ key: k, label: getSectionLabel(k) }));
const allSections = [...availableSections, ...extraSections];
@ -255,8 +252,8 @@ export function renderConfig(props: ConfigProps) {
<!-- Sidebar -->
<aside class="config-sidebar">
<div class="config-sidebar__header">
<div class="config-sidebar__title">Settings</div>
<span class="pill pill--sm ${validity === "valid" ? "pill--ok" : validity === "invalid" ? "pill--danger" : ""}">${validity}</span>
<div class="config-sidebar__title">${t("config.title")}</div>
<span class="pill pill--sm ${validity === "valid" ? "pill--ok" : validity === "invalid" ? "pill--danger" : ""}">${t(`config.${validity}`)}</span>
</div>
<!-- Search -->
@ -268,7 +265,7 @@ export function renderConfig(props: ConfigProps) {
<input
type="text"
class="config-search__input"
placeholder="Search settings..."
placeholder=${t("config.searchSettings")}
.value=${props.searchQuery}
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
/>
@ -287,7 +284,7 @@ export function renderConfig(props: ConfigProps) {
@click=${() => props.onSectionChange(null)}
>
<span class="config-nav__icon">${sidebarIcons.all}</span>
<span class="config-nav__label">All Settings</span>
<span class="config-nav__label">${t("config.allSettings")}</span>
</button>
${allSections.map(section => html`
<button
@ -308,13 +305,13 @@ export function renderConfig(props: ConfigProps) {
?disabled=${props.schemaLoading || !props.schema}
@click=${() => props.onFormModeChange("form")}
>
Form
${t("config.form")}
</button>
<button
class="config-mode-toggle__btn ${props.formMode === "raw" ? "active" : ""}"
@click=${() => props.onFormModeChange("raw")}
>
Raw
${t("config.raw")}
</button>
</div>
</div>
@ -326,35 +323,35 @@ export function renderConfig(props: ConfigProps) {
<div class="config-actions">
<div class="config-actions__left">
${hasChanges ? html`
<span class="config-changes-badge">${props.formMode === "raw" ? "Unsaved changes" : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`}</span>
<span class="config-changes-badge">${props.formMode === "raw" ? t("config.unsavedChanges") : t("config.unsavedCount", { count: diff.length })}</span>
` : html`
<span class="config-status muted">No changes</span>
<span class="config-status muted">${t("config.noChanges")}</span>
`}
</div>
<div class="config-actions__right">
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
${props.loading ? "Loading…" : "Reload"}
${props.loading ? t("common.loading") : t("config.reload")}
</button>
<button
class="btn btn--sm primary"
?disabled=${!canSave}
@click=${props.onSave}
>
${props.saving ? "Saving…" : "Save"}
${props.saving ? t("common.saving") : t("common.save")}
</button>
<button
class="btn btn--sm"
?disabled=${!canApply}
@click=${props.onApply}
>
${props.applying ? "Applying…" : "Apply"}
${props.applying ? t("common.applying") : t("common.apply")}
</button>
<button
class="btn btn--sm"
?disabled=${!canUpdate}
@click=${props.onUpdate}
>
${props.updating ? "Updating…" : "Update"}
${props.updating ? t("config.updating") : t("config.update")}
</button>
</div>
</div>
@ -363,7 +360,7 @@ export function renderConfig(props: ConfigProps) {
${hasChanges && props.formMode === "form" ? html`
<details class="config-diff">
<summary class="config-diff__summary">
<span>View ${diff.length} pending change${diff.length !== 1 ? "s" : ""}</span>
<span>${t("config.viewPending", { count: diff.length })}</span>
<svg class="config-diff__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
@ -404,7 +401,7 @@ export function renderConfig(props: ConfigProps) {
class="config-subnav__item ${effectiveSubsection === null ? "active" : ""}"
@click=${() => props.onSubsectionChange(ALL_SUBSECTION)}
>
All
${t("common.all")}
</button>
${subsections.map(
(entry) => html`
@ -430,7 +427,7 @@ export function renderConfig(props: ConfigProps) {
${props.schemaLoading
? html`<div class="config-loading">
<div class="config-loading__spinner"></div>
<span>Loading schema</span>
<span>${t("config.loadingSchema")}</span>
</div>`
: renderConfigForm({
schema: analysis.schema,
@ -445,14 +442,13 @@ export function renderConfig(props: ConfigProps) {
})}
${formUnsafe
? html`<div class="callout danger" style="margin-top: 12px;">
Form view can't safely edit some fields.
Use Raw to avoid losing config entries.
${t("config.formUnsafe")}
</div>`
: nothing}
`
: html`
<label class="field config-raw-field">
<span>Raw JSON5</span>
<span>${t("config.rawJson5")}</span>
<textarea
.value=${props.raw}
@input=${(e: Event) =>

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import { formatMs } from "../format";
import {
formatCronPayload,
@ -57,42 +58,42 @@ export function renderCron(props: CronProps) {
return html`
<section class="grid grid-cols-2">
<div class="card">
<div class="card-title">Scheduler</div>
<div class="card-sub">Gateway-owned cron scheduler status.</div>
<div class="card-title">${t("cron.scheduler")}</div>
<div class="card-sub">${t("cron.schedulerDesc")}</div>
<div class="stat-grid" style="margin-top: 16px;">
<div class="stat">
<div class="stat-label">Enabled</div>
<div class="stat-label">${t("common.enabled")}</div>
<div class="stat-value">
${props.status
? props.status.enabled
? "Yes"
: "No"
: "n/a"}
? t("common.yes")
: t("common.no")
: t("common.na")}
</div>
</div>
<div class="stat">
<div class="stat-label">Jobs</div>
<div class="stat-value">${props.status?.jobs ?? "n/a"}</div>
<div class="stat-label">${t("cron.jobs")}</div>
<div class="stat-value">${props.status?.jobs ?? t("common.na")}</div>
</div>
<div class="stat">
<div class="stat-label">Next wake</div>
<div class="stat-label">${t("cron.status.nextWake")}</div>
<div class="stat-value">${formatNextRun(props.status?.nextWakeAtMs ?? null)}</div>
</div>
</div>
<div class="row" style="margin-top: 12px;">
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Refreshing…" : "Refresh"}
${props.loading ? t("common.loading") : t("common.refresh")}
</button>
${props.error ? html`<span class="muted">${props.error}</span>` : nothing}
</div>
</div>
<div class="card">
<div class="card-title">New Job</div>
<div class="card-sub">Create a scheduled wakeup or agent run.</div>
<div class="card-title">${t("cron.newJob")}</div>
<div class="card-sub">${t("cron.newJobDesc")}</div>
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Name</span>
<span>${t("cron.name")}</span>
<input
.value=${props.form.name}
@input=${(e: Event) =>
@ -100,7 +101,7 @@ export function renderCron(props: CronProps) {
/>
</label>
<label class="field">
<span>Description</span>
<span>${t("cron.description")}</span>
<input
.value=${props.form.description}
@input=${(e: Event) =>
@ -108,16 +109,16 @@ export function renderCron(props: CronProps) {
/>
</label>
<label class="field">
<span>Agent ID</span>
<span>${t("cron.agentId")}</span>
<input
.value=${props.form.agentId}
@input=${(e: Event) =>
props.onFormChange({ agentId: (e.target as HTMLInputElement).value })}
placeholder="default"
placeholder=${t("cron.agentIdPlaceholder")}
/>
</label>
<label class="field checkbox">
<span>Enabled</span>
<span>${t("common.enabled")}</span>
<input
type="checkbox"
.checked=${props.form.enabled}
@ -126,7 +127,7 @@ export function renderCron(props: CronProps) {
/>
</label>
<label class="field">
<span>Schedule</span>
<span>${t("cron.scheduleKind")}</span>
<select
.value=${props.form.scheduleKind}
@change=${(e: Event) =>
@ -134,16 +135,16 @@ export function renderCron(props: CronProps) {
scheduleKind: (e.target as HTMLSelectElement).value as CronFormState["scheduleKind"],
})}
>
<option value="every">Every</option>
<option value="at">At</option>
<option value="cron">Cron</option>
<option value="every">${t("cron.everyLabel")}</option>
<option value="at">${t("cron.atLabel")}</option>
<option value="cron">${t("cron.cronLabel")}</option>
</select>
</label>
</div>
${renderScheduleFields(props)}
<div class="form-grid" style="margin-top: 12px;">
<label class="field">
<span>Session</span>
<span>${t("cron.session")}</span>
<select
.value=${props.form.sessionTarget}
@change=${(e: Event) =>
@ -151,12 +152,12 @@ export function renderCron(props: CronProps) {
sessionTarget: (e.target as HTMLSelectElement).value as CronFormState["sessionTarget"],
})}
>
<option value="main">Main</option>
<option value="isolated">Isolated</option>
<option value="main">${t("cron.main")}</option>
<option value="isolated">${t("cron.isolated")}</option>
</select>
</label>
<label class="field">
<span>Wake mode</span>
<span>${t("cron.wakeMode")}</span>
<select
.value=${props.form.wakeMode}
@change=${(e: Event) =>
@ -164,12 +165,12 @@ export function renderCron(props: CronProps) {
wakeMode: (e.target as HTMLSelectElement).value as CronFormState["wakeMode"],
})}
>
<option value="next-heartbeat">Next heartbeat</option>
<option value="now">Now</option>
<option value="next-heartbeat">${t("cron.nextHeartbeat")}</option>
<option value="now">${t("cron.now")}</option>
</select>
</label>
<label class="field">
<span>Payload</span>
<span>${t("cron.payload")}</span>
<select
.value=${props.form.payloadKind}
@change=${(e: Event) =>
@ -177,13 +178,13 @@ export function renderCron(props: CronProps) {
payloadKind: (e.target as HTMLSelectElement).value as CronFormState["payloadKind"],
})}
>
<option value="systemEvent">System event</option>
<option value="agentTurn">Agent turn</option>
<option value="systemEvent">${t("cron.systemEvent")}</option>
<option value="agentTurn">${t("cron.agentTurn")}</option>
</select>
</label>
</div>
<label class="field" style="margin-top: 12px;">
<span>${props.form.payloadKind === "systemEvent" ? "System text" : "Agent message"}</span>
<span>${props.form.payloadKind === "systemEvent" ? t("cron.systemText") : t("cron.agentMessage")}</span>
<textarea
.value=${props.form.payloadText}
@input=${(e: Event) =>
@ -197,7 +198,7 @@ export function renderCron(props: CronProps) {
? html`
<div class="form-grid" style="margin-top: 12px;">
<label class="field checkbox">
<span>Deliver</span>
<span>${t("cron.deliver")}</span>
<input
type="checkbox"
.checked=${props.form.deliver}
@ -208,7 +209,7 @@ export function renderCron(props: CronProps) {
/>
</label>
<label class="field">
<span>Channel</span>
<span>${t("cron.form.channel")}</span>
<select
.value=${props.form.channel || "last"}
@change=${(e: Event) =>
@ -225,16 +226,16 @@ export function renderCron(props: CronProps) {
</select>
</label>
<label class="field">
<span>To</span>
<span>${t("cron.to")}</span>
<input
.value=${props.form.to}
@input=${(e: Event) =>
props.onFormChange({ to: (e.target as HTMLInputElement).value })}
placeholder="+1555… or chat id"
placeholder=${t("cron.toPlaceholder")}
/>
</label>
<label class="field">
<span>Timeout (seconds)</span>
<span>${t("cron.timeoutSeconds")}</span>
<input
.value=${props.form.timeoutSeconds}
@input=${(e: Event) =>
@ -246,7 +247,7 @@ export function renderCron(props: CronProps) {
${props.form.sessionTarget === "isolated"
? html`
<label class="field">
<span>Post to main prefix</span>
<span>${t("cron.postToMainPrefix")}</span>
<input
.value=${props.form.postToMainPrefix}
@input=${(e: Event) =>
@ -262,17 +263,17 @@ export function renderCron(props: CronProps) {
: nothing}
<div class="row" style="margin-top: 14px;">
<button class="btn primary" ?disabled=${props.busy} @click=${props.onAdd}>
${props.busy ? "Saving…" : "Add job"}
${props.busy ? t("common.saving") : t("cron.addJob")}
</button>
</div>
</div>
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Jobs</div>
<div class="card-sub">All scheduled jobs stored in the gateway.</div>
<div class="card-title">${t("cron.jobsList")}</div>
<div class="card-sub">${t("cron.jobsListDesc")}</div>
${props.jobs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No jobs yet.</div>`
? html`<div class="muted" style="margin-top: 12px;">${t("cron.noJobs")}</div>`
: html`
<div class="list" style="margin-top: 12px;">
${props.jobs.map((job) => renderJob(job, props))}
@ -281,16 +282,16 @@ export function renderCron(props: CronProps) {
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Run history</div>
<div class="card-sub">Latest runs for ${props.runsJobId ?? "(select a job)"}.</div>
<div class="card-title">${t("cron.runHistory")}</div>
<div class="card-sub">${t("cron.runHistoryDesc")} ${props.runsJobId ?? ""}.</div>
${props.runsJobId == null
? html`
<div class="muted" style="margin-top: 12px;">
Select a job to inspect run history.
${t("cron.selectJob")}
</div>
`
: props.runs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No runs yet.</div>`
? html`<div class="muted" style="margin-top: 12px;">${t("cron.noRuns")}</div>`
: html`
<div class="list" style="margin-top: 12px;">
${props.runs.map((entry) => renderRun(entry))}
@ -305,7 +306,7 @@ function renderScheduleFields(props: CronProps) {
if (form.scheduleKind === "at") {
return html`
<label class="field" style="margin-top: 12px;">
<span>Run at</span>
<span>${t("cron.runAt")}</span>
<input
type="datetime-local"
.value=${form.scheduleAt}
@ -321,7 +322,7 @@ function renderScheduleFields(props: CronProps) {
return html`
<div class="form-grid" style="margin-top: 12px;">
<label class="field">
<span>Every</span>
<span>${t("cron.every")}</span>
<input
.value=${form.everyAmount}
@input=${(e: Event) =>
@ -331,7 +332,7 @@ function renderScheduleFields(props: CronProps) {
/>
</label>
<label class="field">
<span>Unit</span>
<span>${t("cron.unit")}</span>
<select
.value=${form.everyUnit}
@change=${(e: Event) =>
@ -339,9 +340,9 @@ function renderScheduleFields(props: CronProps) {
everyUnit: (e.target as HTMLSelectElement).value as CronFormState["everyUnit"],
})}
>
<option value="minutes">Minutes</option>
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="minutes">${t("cron.minutes")}</option>
<option value="hours">${t("cron.hours")}</option>
<option value="days">${t("cron.days")}</option>
</select>
</label>
</div>
@ -350,7 +351,7 @@ function renderScheduleFields(props: CronProps) {
return html`
<div class="form-grid" style="margin-top: 12px;">
<label class="field">
<span>Expression</span>
<span>${t("cron.expression")}</span>
<input
.value=${form.cronExpr}
@input=${(e: Event) =>
@ -358,7 +359,7 @@ function renderScheduleFields(props: CronProps) {
/>
</label>
<label class="field">
<span>Timezone (optional)</span>
<span>${t("cron.timezone")}</span>
<input
.value=${form.cronTz}
@input=${(e: Event) =>
@ -396,7 +397,7 @@ function renderJob(job: CronJob, props: CronProps) {
props.onToggle(job, !job.enabled);
}}
>
${job.enabled ? "Disable" : "Enable"}
${job.enabled ? t("cron.disable") : t("cron.enable")}
</button>
<button
class="btn"
@ -406,7 +407,7 @@ function renderJob(job: CronJob, props: CronProps) {
props.onRun(job);
}}
>
Run
${t("cron.runNow")}
</button>
<button
class="btn"
@ -416,7 +417,7 @@ function renderJob(job: CronJob, props: CronProps) {
props.onLoadRuns(job.id);
}}
>
Runs
${t("cron.runs")}
</button>
<button
class="btn danger"
@ -426,7 +427,7 @@ function renderJob(job: CronJob, props: CronProps) {
props.onRemove(job);
}}
>
Remove
${t("cron.remove")}
</button>
</div>
</div>

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import { formatEventPayload } from "../presenter";
import type { EventLogEntry } from "../app-events";
@ -32,51 +33,51 @@ export function renderDebug(props: DebugProps) {
const securityTone = critical > 0 ? "danger" : warn > 0 ? "warn" : "success";
const securityLabel =
critical > 0
? `${critical} critical`
? `${critical} ${t("debug.critical")}`
: warn > 0
? `${warn} warnings`
: "No critical issues";
? `${warn} ${t("debug.warnings")}`
: t("debug.noCritical");
return html`
<section class="grid grid-cols-2">
<div class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Snapshots</div>
<div class="card-sub">Status, health, and heartbeat data.</div>
<div class="card-title">${t("debug.snapshots")}</div>
<div class="card-sub">${t("debug.snapshotsDesc")}</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Refreshing…" : "Refresh"}
${props.loading ? t("common.loading") : t("common.refresh")}
</button>
</div>
<div class="stack" style="margin-top: 12px;">
<div>
<div class="muted">Status</div>
<div class="muted">${t("debug.status")}</div>
${securitySummary
? html`<div class="callout ${securityTone}" style="margin-top: 8px;">
Security audit: ${securityLabel}${info > 0 ? ` · ${info} info` : ""}. Run
<span class="mono">moltbot security audit --deep</span> for details.
${t("debug.securityAudit")}: ${securityLabel}${info > 0 ? ` · ${info} ${t("common.info").toLowerCase()}` : ""}. ${t("debug.runAuditCmd")}
<span class="mono">moltbot security audit --deep</span>
</div>`
: nothing}
<pre class="code-block">${JSON.stringify(props.status ?? {}, null, 2)}</pre>
</div>
<div>
<div class="muted">Health</div>
<div class="muted">${t("debug.health")}</div>
<pre class="code-block">${JSON.stringify(props.health ?? {}, null, 2)}</pre>
</div>
<div>
<div class="muted">Last heartbeat</div>
<div class="muted">${t("debug.lastHeartbeat")}</div>
<pre class="code-block">${JSON.stringify(props.heartbeat ?? {}, null, 2)}</pre>
</div>
</div>
</div>
<div class="card">
<div class="card-title">Manual RPC</div>
<div class="card-sub">Send a raw gateway method with JSON params.</div>
<div class="card-title">${t("debug.manualRpc")}</div>
<div class="card-sub">${t("debug.manualRpcDesc")}</div>
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Method</span>
<span>${t("debug.method")}</span>
<input
.value=${props.callMethod}
@input=${(e: Event) =>
@ -85,7 +86,7 @@ export function renderDebug(props: DebugProps) {
/>
</label>
<label class="field">
<span>Params (JSON)</span>
<span>${t("debug.paramsJson")}</span>
<textarea
.value=${props.callParams}
@input=${(e: Event) =>
@ -95,7 +96,7 @@ export function renderDebug(props: DebugProps) {
</label>
</div>
<div class="row" style="margin-top: 12px;">
<button class="btn primary" @click=${props.onCall}>Call</button>
<button class="btn primary" @click=${props.onCall}>${t("debug.call")}</button>
</div>
${props.callError
? html`<div class="callout danger" style="margin-top: 12px;">
@ -109,8 +110,8 @@ export function renderDebug(props: DebugProps) {
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Models</div>
<div class="card-sub">Catalog from models.list.</div>
<div class="card-title">${t("debug.models")}</div>
<div class="card-sub">${t("debug.modelsDesc")}</div>
<pre class="code-block" style="margin-top: 12px;">${JSON.stringify(
props.models ?? [],
null,
@ -119,10 +120,10 @@ export function renderDebug(props: DebugProps) {
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Event Log</div>
<div class="card-sub">Latest gateway events.</div>
<div class="card-title">${t("debug.eventLog")}</div>
<div class="card-sub">${t("debug.eventLogDesc")}</div>
${props.eventLog.length === 0
? html`<div class="muted" style="margin-top: 12px;">No events yet.</div>`
? html`<div class="muted" style="margin-top: 12px;">${t("debug.noEvents")}</div>`
: html`
<div class="list" style="margin-top: 12px;">
${props.eventLog.map(

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

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import { clampText, formatAgo, formatList } from "../format";
import type {
ExecApprovalsAllowlistEntry,
@ -60,16 +61,16 @@ export function renderNodes(props: NodesProps) {
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Nodes</div>
<div class="card-sub">Paired devices and live links.</div>
<div class="card-title">${t("nodes.title")}</div>
<div class="card-sub">${t("nodes.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="list" style="margin-top: 16px;">
${props.nodes.length === 0
? html`<div class="muted">No nodes found.</div>`
? html`<div class="muted">${t("nodes.noNodes")}</div>`
: props.nodes.map((n) => renderNode(n))}
</div>
</section>
@ -84,11 +85,11 @@ function renderDevices(props: NodesProps) {
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Devices</div>
<div class="card-sub">Pairing requests + role tokens.</div>
<div class="card-title">${t("nodes.devices")}</div>
<div class="card-sub">${t("nodes.devicesDesc")}</div>
</div>
<button class="btn" ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}>
${props.devicesLoading ? "Loading…" : "Refresh"}
${props.devicesLoading ? t("common.loading") : t("common.refresh")}
</button>
</div>
${props.devicesError
@ -97,18 +98,18 @@ function renderDevices(props: NodesProps) {
<div class="list" style="margin-top: 16px;">
${pending.length > 0
? html`
<div class="muted" style="margin-bottom: 8px;">Pending</div>
<div class="muted" style="margin-bottom: 8px;">${t("nodes.pending")}</div>
${pending.map((req) => renderPendingDevice(req, props))}
`
: nothing}
${paired.length > 0
? html`
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">Paired</div>
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">${t("nodes.paired")}</div>
${paired.map((device) => renderPairedDevice(device, props))}
`
: nothing}
${pending.length === 0 && paired.length === 0
? html`<div class="muted">No paired devices.</div>`
? html`<div class="muted">${t("nodes.noDevices")}</div>`
: nothing}
</div>
</section>
@ -117,9 +118,9 @@ function renderDevices(props: NodesProps) {
function renderPendingDevice(req: PendingDevice, props: NodesProps) {
const name = req.displayName?.trim() || req.deviceId;
const age = typeof req.ts === "number" ? formatAgo(req.ts) : "n/a";
const role = req.role?.trim() ? `role: ${req.role}` : "role: -";
const repair = req.isRepair ? " · repair" : "";
const age = typeof req.ts === "number" ? formatAgo(req.ts) : t("common.na");
const role = req.role?.trim() ? `${t("nodes.role")}: ${req.role}` : `${t("nodes.role")}: -`;
const repair = req.isRepair ? ` · ${t("nodes.repair")}` : "";
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
return html`
<div class="list-item">
@ -127,16 +128,16 @@ function renderPendingDevice(req: PendingDevice, props: NodesProps) {
<div class="list-title">${name}</div>
<div class="list-sub">${req.deviceId}${ip}</div>
<div class="muted" style="margin-top: 6px;">
${role} · requested ${age}${repair}
${role} · ${t("nodes.requested")} ${age}${repair}
</div>
</div>
<div class="list-meta">
<div class="row" style="justify-content: flex-end; gap: 8px; flex-wrap: wrap;">
<button class="btn btn--sm primary" @click=${() => props.onDeviceApprove(req.requestId)}>
Approve
${t("nodes.approve")}
</button>
<button class="btn btn--sm" @click=${() => props.onDeviceReject(req.requestId)}>
Reject
${t("nodes.reject")}
</button>
</div>
</div>
@ -147,8 +148,8 @@ function renderPendingDevice(req: PendingDevice, props: NodesProps) {
function renderPairedDevice(device: PairedDevice, props: NodesProps) {
const name = device.displayName?.trim() || device.deviceId;
const ip = device.remoteIp ? ` · ${device.remoteIp}` : "";
const roles = `roles: ${formatList(device.roles)}`;
const scopes = `scopes: ${formatList(device.scopes)}`;
const roles = `${t("nodes.roles")}: ${formatList(device.roles)}`;
const scopes = `${t("nodes.scopes")}: ${formatList(device.scopes)}`;
const tokens = Array.isArray(device.tokens) ? device.tokens : [];
return html`
<div class="list-item">
@ -157,9 +158,9 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
<div class="list-sub">${device.deviceId}${ip}</div>
<div class="muted" style="margin-top: 6px;">${roles} · ${scopes}</div>
${tokens.length === 0
? html`<div class="muted" style="margin-top: 6px;">Tokens: none</div>`
? html`<div class="muted" style="margin-top: 6px;">${t("nodes.tokensNone")}</div>`
: html`
<div class="muted" style="margin-top: 10px;">Tokens</div>
<div class="muted" style="margin-top: 10px;">${t("nodes.tokens")}</div>
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;">
${tokens.map((token) => renderTokenRow(device.deviceId, token, props))}
</div>
@ -170,8 +171,8 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
}
function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) {
const status = token.revokedAtMs ? "revoked" : "active";
const scopes = `scopes: ${formatList(token.scopes)}`;
const status = token.revokedAtMs ? t("nodes.revoked") : t("nodes.active");
const scopes = `${t("nodes.scopes")}: ${formatList(token.scopes)}`;
const when = formatAgo(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null);
return html`
<div class="row" style="justify-content: space-between; gap: 8px;">
@ -181,7 +182,7 @@ function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: Node
class="btn btn--sm"
@click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)}
>
Rotate
${t("nodes.rotate")}
</button>
${token.revokedAtMs
? nothing
@ -190,7 +191,7 @@ function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: Node
class="btn btn--sm danger"
@click=${() => props.onDeviceRevoke(deviceId, token.role)}
>
Revoke
${t("nodes.revoke")}
</button>
`}
</div>
@ -436,9 +437,9 @@ function renderBindings(state: BindingState) {
<section class="card">
<div class="row" style="justify-content: space-between; align-items: center;">
<div>
<div class="card-title">Exec node binding</div>
<div class="card-title">${t("nodes.bindings.title")}</div>
<div class="card-sub">
Pin agents to a specific node when using <span class="mono">exec host=node</span>.
${t("nodes.bindings.desc")} <span class="mono">exec host=node</span>.
</div>
</div>
<button
@ -446,33 +447,33 @@ function renderBindings(state: BindingState) {
?disabled=${state.disabled || !state.configDirty}
@click=${state.onSave}
>
${state.configSaving ? "Saving…" : "Save"}
${state.configSaving ? t("common.saving") : t("nodes.bindings.save")}
</button>
</div>
${state.formMode === "raw"
? html`<div class="callout warn" style="margin-top: 12px;">
Switch the Config tab to <strong>Form</strong> mode to edit bindings here.
${t("nodes.bindings.switchToForm")}
</div>`
: nothing}
${!state.ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load config to edit bindings.</div>
<div class="muted">${t("nodes.bindings.loadConfigNote")}</div>
<button class="btn" ?disabled=${state.configLoading} @click=${state.onLoadConfig}>
${state.configLoading ? "Loading…" : "Load config"}
${state.configLoading ? t("common.loading") : t("nodes.bindings.loadConfig")}
</button>
</div>`
: html`
<div class="list" style="margin-top: 16px;">
<div class="list-item">
<div class="list-main">
<div class="list-title">Default binding</div>
<div class="list-sub">Used when agents do not override a node binding.</div>
<div class="list-title">${t("nodes.bindings.default")}</div>
<div class="list-sub">${t("nodes.bindings.defaultDesc")}</div>
</div>
<div class="list-meta">
<label class="field">
<span>Node</span>
<span>${t("nodes.bindings.node")}</span>
<select
?disabled=${state.disabled || !supportsBinding}
@change=${(event: Event) => {
@ -481,7 +482,7 @@ function renderBindings(state: BindingState) {
state.onBindDefault(value ? value : null);
}}
>
<option value="" ?selected=${defaultValue === ""}>Any node</option>
<option value="" ?selected=${defaultValue === ""}>${t("nodes.bindings.anyNode")}</option>
${state.nodes.map(
(node) =>
html`<option
@ -494,13 +495,13 @@ function renderBindings(state: BindingState) {
</select>
</label>
${!supportsBinding
? html`<div class="muted">No nodes with system.run available.</div>`
? html`<div class="muted">${t("nodes.bindings.noNodesAvailable")}</div>`
: nothing}
</div>
</div>
${state.agents.length === 0
? html`<div class="muted">No agents found.</div>`
? html`<div class="muted">${t("nodes.noNodes")}</div>`
: state.agents.map((agent) =>
renderAgentBinding(agent, state),
)}
@ -517,9 +518,9 @@ function renderExecApprovals(state: ExecApprovalsState) {
<section class="card">
<div class="row" style="justify-content: space-between; align-items: center;">
<div>
<div class="card-title">Exec approvals</div>
<div class="card-title">${t("nodes.approvals.title")}</div>
<div class="card-sub">
Allowlist and approval policy for <span class="mono">exec host=gateway/node</span>.
${t("nodes.approvals.desc")} <span class="mono">exec host=gateway/node</span>.
</div>
</div>
<button
@ -527,7 +528,7 @@ function renderExecApprovals(state: ExecApprovalsState) {
?disabled=${state.disabled || !state.dirty || !targetReady}
@click=${state.onSave}
>
${state.saving ? "Saving…" : "Save"}
${state.saving ? t("common.saving") : t("common.save")}
</button>
</div>
@ -535,9 +536,9 @@ function renderExecApprovals(state: ExecApprovalsState) {
${!ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load exec approvals to edit allowlists.</div>
<div class="muted">${t("nodes.approvals.loadApprovalsNote")}</div>
<button class="btn" ?disabled=${state.loading || !targetReady} @click=${state.onLoad}>
${state.loading ? "Loading…" : "Load approvals"}
${state.loading ? t("common.loading") : t("nodes.approvals.loadApprovals")}
</button>
</div>`
: html`
@ -558,14 +559,14 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
<div class="list" style="margin-top: 12px;">
<div class="list-item">
<div class="list-main">
<div class="list-title">Target</div>
<div class="list-title">${t("nodes.approvals.target")}</div>
<div class="list-sub">
Gateway edits local approvals; node edits the selected node.
${t("nodes.approvals.targetDesc")}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Host</span>
<span>${t("nodes.approvals.host")}</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
@ -579,14 +580,14 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
}
}}
>
<option value="gateway" ?selected=${state.target === "gateway"}>Gateway</option>
<option value="node" ?selected=${state.target === "node"}>Node</option>
<option value="gateway" ?selected=${state.target === "gateway"}>${t("nodes.approvals.gateway")}</option>
<option value="node" ?selected=${state.target === "node"}>${t("nodes.bindings.node")}</option>
</select>
</label>
${state.target === "node"
? html`
<label class="field">
<span>Node</span>
<span>${t("nodes.bindings.node")}</span>
<select
?disabled=${state.disabled || !hasNodes}
@change=${(event: Event) => {
@ -595,7 +596,7 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
state.onSelectTarget("node", value ? value : null);
}}
>
<option value="" ?selected=${nodeValue === ""}>Select node</option>
<option value="" ?selected=${nodeValue === ""}>${t("nodes.approvals.selectNode")}</option>
${state.targetNodes.map(
(node) =>
html`<option
@ -612,7 +613,7 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
</div>
</div>
${state.target === "node" && !hasNodes
? html`<div class="muted">No nodes advertise exec approvals yet.</div>`
? html`<div class="muted">${t("nodes.approvals.noNodesYet")}</div>`
: nothing}
</div>
`;
@ -621,13 +622,13 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
function renderExecApprovalsTabs(state: ExecApprovalsState) {
return html`
<div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;">
<span class="label">Scope</span>
<span class="label">${t("nodes.approvals.scope")}</span>
<div class="row" style="gap: 8px; flex-wrap: wrap;">
<button
class="btn btn--sm ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "active" : ""}"
@click=${() => state.onSelectScope(EXEC_APPROVALS_DEFAULT_SCOPE)}
>
Defaults
${t("nodes.approvals.defaults")}
</button>
${state.agents.map((agent) => {
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;

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>

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import { clampText } from "../format";
import type { SkillStatusEntry, SkillStatusReport } from "../types";
import type { SkillMessageMap } from "../controllers/skills";
@ -36,25 +37,25 @@ export function renderSkills(props: SkillsProps) {
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Skills</div>
<div class="card-sub">Bundled, managed, and workspace skills.</div>
<div class="card-title">${t("skills.title")}</div>
<div class="card-sub">${t("skills.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" style="flex: 1;">
<span>Filter</span>
<span>${t("common.filter")}</span>
<input
.value=${props.filter}
@input=${(e: Event) =>
props.onFilterChange((e.target as HTMLInputElement).value)}
placeholder="Search skills"
placeholder=${t("skills.filter")}
/>
</label>
<div class="muted">${filtered.length} shown</div>
<div class="muted">${filtered.length} ${t("skills.shown")}</div>
</div>
${props.error
@ -62,7 +63,7 @@ export function renderSkills(props: SkillsProps) {
: nothing}
${filtered.length === 0
? html`<div class="muted" style="margin-top: 16px;">No skills found.</div>`
? html`<div class="muted" style="margin-top: 16px;">${t("skills.noSkills")}</div>`
: html`
<div class="list" style="margin-top: 16px;">
${filtered.map((skill) => renderSkill(skill, props))}
@ -85,8 +86,8 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
...skill.missing.os.map((o) => `os:${o}`),
];
const reasons: string[] = [];
if (skill.disabled) reasons.push("disabled");
if (skill.blockedByAllowlist) reasons.push("blocked by allowlist");
if (skill.disabled) reasons.push(t("skills.disabled").toLowerCase());
if (skill.blockedByAllowlist) reasons.push(t("skills.blockedByAllowlist"));
return html`
<div class="list-item">
<div class="list-main">
@ -97,21 +98,21 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
<div class="chip-row" style="margin-top: 6px;">
<span class="chip">${skill.source}</span>
<span class="chip ${skill.eligible ? "chip-ok" : "chip-warn"}">
${skill.eligible ? "eligible" : "blocked"}
${skill.eligible ? t("skills.eligible") : t("skills.blocked")}
</span>
${skill.disabled ? html`<span class="chip chip-warn">disabled</span>` : nothing}
${skill.disabled ? html`<span class="chip chip-warn">${t("skills.disabled").toLowerCase()}</span>` : nothing}
</div>
${missing.length > 0
? html`
<div class="muted" style="margin-top: 6px;">
Missing: ${missing.join(", ")}
${t("skills.missing")}: ${missing.join(", ")}
</div>
`
: nothing}
${reasons.length > 0
? html`
<div class="muted" style="margin-top: 6px;">
Reason: ${reasons.join(", ")}
${t("skills.reason")}: ${reasons.join(", ")}
</div>
`
: nothing}
@ -123,7 +124,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
?disabled=${busy}
@click=${() => props.onToggle(skill.skillKey, skill.disabled)}
>
${skill.disabled ? "Enable" : "Disable"}
${skill.disabled ? t("cron.enable") : t("cron.disable")}
</button>
${canInstall
? html`<button
@ -132,7 +133,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
@click=${() =>
props.onInstall(skill.skillKey, skill.name, skill.install[0].id)}
>
${busy ? "Installing…" : skill.install[0].label}
${busy ? t("skills.installing") : skill.install[0].label}
</button>`
: nothing}
</div>
@ -151,7 +152,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
${skill.primaryEnv
? html`
<div class="field" style="margin-top: 10px;">
<span>API key</span>
<span>${t("skills.apiKey")}</span>
<input
type="password"
.value=${apiKey}
@ -165,7 +166,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
?disabled=${busy}
@click=${() => props.onSaveKey(skill.skillKey)}
>
Save key
${t("skills.saveKey")}
</button>
`
: nothing}