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