Merge pull request #1 from ckijficqstrvy/claude/project-analysis-c2lri
Claude/project analysis c2lri
This commit is contained in:
commit
0c5759b9d9
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();
|
||||
747
ui/src/i18n/locales/en-US.ts
Normal file
747
ui/src/i18n/locales/en-US.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
754
ui/src/i18n/locales/zh-TW.ts
Normal file
754
ui/src/i18n/locales/zh-TW.ts
Normal file
@ -0,0 +1,754 @@
|
||||
/**
|
||||
* Traditional Chinese (Taiwan) translations
|
||||
* 繁體中文(台灣)翻譯
|
||||
*
|
||||
* 翻譯原則:
|
||||
* - 使用台灣常見的軟體用語
|
||||
* - 保持專業但親切的語氣
|
||||
* - 技術名詞保留英文或使用約定俗成的翻譯
|
||||
* - 品牌名稱(WhatsApp、Telegram 等)不翻譯
|
||||
*/
|
||||
export const zhTW = {
|
||||
// 通用
|
||||
common: {
|
||||
loading: "載入中…",
|
||||
refresh: "重新整理",
|
||||
save: "儲存",
|
||||
saving: "儲存中…",
|
||||
apply: "套用",
|
||||
applying: "套用中…",
|
||||
cancel: "取消",
|
||||
delete: "刪除",
|
||||
edit: "編輯",
|
||||
close: "關閉",
|
||||
yes: "是",
|
||||
no: "否",
|
||||
ok: "確定",
|
||||
error: "錯誤",
|
||||
success: "成功",
|
||||
warning: "警告",
|
||||
info: "資訊",
|
||||
enabled: "已啟用",
|
||||
disabled: "已停用",
|
||||
configured: "已設定",
|
||||
connected: "已連線",
|
||||
disconnected: "已斷線",
|
||||
running: "執行中",
|
||||
stopped: "已停止",
|
||||
active: "使用中",
|
||||
inactive: "閒置中",
|
||||
unknown: "未知",
|
||||
none: "無",
|
||||
all: "全部",
|
||||
search: "搜尋",
|
||||
filter: "篩選",
|
||||
export: "匯出",
|
||||
import: "匯入",
|
||||
copy: "複製",
|
||||
copied: "已複製!",
|
||||
na: "無資料",
|
||||
optional: "(選填)",
|
||||
required: "必填",
|
||||
inherit: "繼承",
|
||||
actions: "操作",
|
||||
},
|
||||
|
||||
// 應用程式標題與品牌
|
||||
app: {
|
||||
title: "MOLTBOT",
|
||||
subtitle: "閘道器控制台",
|
||||
health: "狀態",
|
||||
offline: "離線",
|
||||
expandSidebar: "展開側邊欄",
|
||||
collapseSidebar: "收合側邊欄",
|
||||
},
|
||||
|
||||
// 導覽列
|
||||
nav: {
|
||||
groups: {
|
||||
chat: "對話",
|
||||
control: "控制",
|
||||
agent: "代理",
|
||||
settings: "設定",
|
||||
resources: "資源",
|
||||
},
|
||||
tabs: {
|
||||
overview: "總覽",
|
||||
channels: "頻道",
|
||||
instances: "實例",
|
||||
sessions: "工作階段",
|
||||
cron: "排程任務",
|
||||
skills: "Skills",
|
||||
nodes: "節點",
|
||||
chat: "對話",
|
||||
config: "組態",
|
||||
debug: "除錯",
|
||||
logs: "日誌",
|
||||
},
|
||||
subtitles: {
|
||||
overview: "閘道器狀態、進入點與快速健康檢查。",
|
||||
channels: "管理頻道與相關設定。",
|
||||
instances: "來自已連線用戶端與節點的存在訊號。",
|
||||
sessions: "檢視進行中的工作階段並調整個別設定。",
|
||||
cron: "排程喚醒與週期性代理執行。",
|
||||
skills: "管理 Skills 的啟用狀態與 API 金鑰。",
|
||||
nodes: "已配對的裝置、功能與指令權限。",
|
||||
chat: "直接與閘道器對話,進行快速操作。",
|
||||
config: "安全地編輯 ~/.clawdbot/moltbot.json 設定檔。",
|
||||
debug: "閘道器快照、事件記錄與手動 RPC 呼叫。",
|
||||
logs: "即時檢視閘道器的檔案日誌。",
|
||||
},
|
||||
docs: "文件",
|
||||
docsTooltip: "文件(在新分頁開啟)",
|
||||
},
|
||||
|
||||
// 總覽頁面
|
||||
overview: {
|
||||
gatewayAccess: "閘道器連線",
|
||||
gatewayAccessDesc: "控制台連線位置與認證方式。",
|
||||
websocketUrl: "WebSocket 網址",
|
||||
gatewayToken: "Gateway Token",
|
||||
password: "密碼(不會儲存)",
|
||||
passwordPlaceholder: "系統或共用密碼",
|
||||
defaultSessionKey: "預設工作階段金鑰",
|
||||
connect: "連線",
|
||||
connectNote: "點擊「連線」以套用連線設定。",
|
||||
|
||||
snapshot: "快照",
|
||||
snapshotDesc: "最新的閘道器交握資訊。",
|
||||
status: "狀態",
|
||||
uptime: "運行時間",
|
||||
tickInterval: "更新間隔",
|
||||
lastChannelsRefresh: "上次頻道更新",
|
||||
|
||||
authRequired: "此閘道器需要認證。請新增 Token 或密碼,然後點擊「連線」。",
|
||||
authFailed: "認證失敗。請重新複製包含 Token 的網址:",
|
||||
authDocsLink: "文件:控制台認證",
|
||||
tokenizedUrlCmd: "moltbot dashboard --no-open",
|
||||
generateTokenCmd: "moltbot doctor --generate-gateway-token",
|
||||
thenClickConnect: ",或更新 Token,然後點擊「連線」。",
|
||||
|
||||
insecureContext: "此頁面使用 HTTP,瀏覽器會阻擋裝置身分驗證。請使用 HTTPS(Tailscale Serve)或在閘道器主機上開啟",
|
||||
insecureContextLocal: "。",
|
||||
insecureContextConfig: "如果必須使用 HTTP,請設定",
|
||||
insecureContextConfigValue: "gateway.controlUi.allowInsecureAuth: true",
|
||||
insecureContextNote: "(僅限 Token 認證)。",
|
||||
tailscaleDocsLink: "文件:Tailscale Serve",
|
||||
insecureHttpDocsLink: "文件:不安全的 HTTP",
|
||||
|
||||
channelsHint: "使用「頻道」來連結 WhatsApp、Telegram、Discord、Signal 或 iMessage。",
|
||||
|
||||
instancesCard: "實例",
|
||||
instancesDesc: "過去 5 分鐘內的存在訊號數量。",
|
||||
sessionsCard: "工作階段",
|
||||
sessionsDesc: "閘道器追蹤中的近期工作階段金鑰。",
|
||||
cronCard: "排程",
|
||||
nextWake: "下次喚醒",
|
||||
|
||||
notes: "備註",
|
||||
notesDesc: "遠端控制設定的快速提醒。",
|
||||
tailscaleServe: "Tailscale serve",
|
||||
tailscaleServeDesc: "建議使用 serve 模式,讓閘道器只在本地介面監聽並透過 tailnet 認證。",
|
||||
sessionHygiene: "工作階段管理",
|
||||
sessionHygieneDesc: "使用 /new 或 sessions.patch 重設上下文。",
|
||||
cronReminders: "排程提醒",
|
||||
cronRemindersDesc: "週期性執行建議使用獨立的工作階段。",
|
||||
},
|
||||
|
||||
// 對話頁面
|
||||
chat: {
|
||||
message: "訊息",
|
||||
messagePlaceholder: "輸入訊息(Enter 送出,Shift+Enter 換行,可貼上圖片)",
|
||||
messagePlaceholderWithImages: "新增訊息或貼上更多圖片...",
|
||||
connectToChat: "請連線到閘道器以開始對話…",
|
||||
send: "送出",
|
||||
queue: "排入佇列",
|
||||
stop: "停止",
|
||||
newSession: "新工作階段",
|
||||
loadingChat: "載入對話中…",
|
||||
compacting: "壓縮上下文中…",
|
||||
compacted: "上下文已壓縮",
|
||||
queued: "佇列中",
|
||||
removeQueued: "移除佇列中的訊息",
|
||||
exitFocusMode: "離開專注模式",
|
||||
removeAttachment: "移除附件",
|
||||
attachmentPreview: "附件預覽",
|
||||
showingLast: "顯示最後 {{count}} 則訊息(已隱藏 {{hidden}} 則)。",
|
||||
image: "圖片",
|
||||
},
|
||||
|
||||
// 頻道頁面
|
||||
channels: {
|
||||
title: "頻道",
|
||||
health: "頻道健康狀態",
|
||||
healthDesc: "來自閘道器的頻道狀態快照。",
|
||||
noSnapshot: "尚無快照資料。",
|
||||
statusAndConfig: "頻道狀態與設定。",
|
||||
lastInbound: "上次收到訊息",
|
||||
|
||||
// 狀態標籤
|
||||
labels: {
|
||||
configured: "已設定",
|
||||
running: "執行中",
|
||||
connected: "已連線",
|
||||
},
|
||||
|
||||
// 一般
|
||||
accounts: "帳號",
|
||||
|
||||
// WhatsApp
|
||||
whatsapp: {
|
||||
title: "WhatsApp",
|
||||
desc: "連結 WhatsApp Web 並監控連線狀態。",
|
||||
start: "開始",
|
||||
relink: "重新連結",
|
||||
logout: "登出",
|
||||
scanQr: "請用手機上的 WhatsApp 掃描 QR Code。",
|
||||
linking: "連結中…",
|
||||
waitingForQr: "等待 QR Code…",
|
||||
notConfigured: "尚未設定。",
|
||||
linked: "已連結",
|
||||
lastConnect: "上次連線",
|
||||
authAge: "認證時長",
|
||||
showQr: "顯示 QR",
|
||||
waitForScan: "等待掃描",
|
||||
working: "處理中…",
|
||||
},
|
||||
|
||||
// Telegram
|
||||
telegram: {
|
||||
title: "Telegram",
|
||||
desc: "機器人狀態與頻道設定。",
|
||||
mode: "模式",
|
||||
lastStart: "上次啟動",
|
||||
lastProbe: "上次探測",
|
||||
probe: "探測",
|
||||
probeOk: "正常",
|
||||
probeFailed: "失敗",
|
||||
},
|
||||
|
||||
// Discord
|
||||
discord: {
|
||||
title: "Discord",
|
||||
desc: "機器人狀態與頻道設定。",
|
||||
lastStart: "上次啟動",
|
||||
lastProbe: "上次探測",
|
||||
probe: "探測",
|
||||
probeOk: "正常",
|
||||
probeFailed: "失敗",
|
||||
},
|
||||
|
||||
// Slack
|
||||
slack: {
|
||||
title: "Slack",
|
||||
desc: "Socket 模式狀態與頻道設定。",
|
||||
lastStart: "上次啟動",
|
||||
lastProbe: "上次探測",
|
||||
probe: "探測",
|
||||
probeOk: "正常",
|
||||
probeFailed: "失敗",
|
||||
},
|
||||
|
||||
// Signal
|
||||
signal: {
|
||||
title: "Signal",
|
||||
desc: "signal-cli 狀態與頻道設定。",
|
||||
baseUrl: "Base URL",
|
||||
lastStart: "上次啟動",
|
||||
lastProbe: "上次探測",
|
||||
probe: "探測",
|
||||
probeOk: "正常",
|
||||
probeFailed: "失敗",
|
||||
},
|
||||
|
||||
// iMessage
|
||||
imessage: {
|
||||
title: "iMessage",
|
||||
desc: "macOS 橋接器狀態與頻道設定。",
|
||||
lastStart: "上次啟動",
|
||||
lastProbe: "上次探測",
|
||||
probe: "探測",
|
||||
probeOk: "正常",
|
||||
probeFailed: "失敗",
|
||||
},
|
||||
|
||||
// Google Chat
|
||||
googlechat: {
|
||||
title: "Google Chat",
|
||||
desc: "服務帳戶狀態與頻道設定。",
|
||||
credential: "憑證",
|
||||
audience: "對象",
|
||||
lastStart: "上次啟動",
|
||||
lastProbe: "上次探測",
|
||||
probe: "探測",
|
||||
probeOk: "正常",
|
||||
probeFailed: "失敗",
|
||||
},
|
||||
|
||||
// Nostr
|
||||
nostr: {
|
||||
title: "Nostr",
|
||||
desc: "透過 Nostr 中繼站進行去中心化私訊(NIP-04)。",
|
||||
publicKey: "公鑰",
|
||||
lastStart: "上次啟動",
|
||||
profile: "個人檔案",
|
||||
editProfile: "編輯個人檔案",
|
||||
profilePicture: "個人頭像",
|
||||
displayName: "顯示名稱",
|
||||
noProfile: "尚未設定個人檔案。點擊「編輯個人檔案」來新增您的名稱、簡介和頭像。",
|
||||
profileForm: {
|
||||
title: "編輯個人檔案",
|
||||
account: "帳號",
|
||||
name: "使用者名稱",
|
||||
nameHelp: "簡短的使用者名稱(如:satoshi)",
|
||||
namePlaceholder: "satoshi",
|
||||
displayName: "顯示名稱",
|
||||
displayNameHelp: "您的完整顯示名稱",
|
||||
displayNamePlaceholder: "Satoshi Nakamoto",
|
||||
about: "個人簡介",
|
||||
aboutHelp: "簡短的自我介紹",
|
||||
aboutPlaceholder: "介紹一下您自己...",
|
||||
picture: "頭像網址",
|
||||
pictureHelp: "您的頭像的 HTTPS 網址",
|
||||
picturePlaceholder: "https://example.com/avatar.jpg",
|
||||
picturePreview: "頭像預覽",
|
||||
advanced: "進階設定",
|
||||
banner: "橫幅圖片網址",
|
||||
bannerHelp: "橫幅圖片的 HTTPS 網址",
|
||||
bannerPlaceholder: "https://example.com/banner.jpg",
|
||||
website: "網站",
|
||||
websiteHelp: "您的個人網站",
|
||||
websitePlaceholder: "https://example.com",
|
||||
nip05: "NIP-05 識別碼",
|
||||
nip05Help: "可驗證的識別碼(如:you@domain.com)",
|
||||
nip05Placeholder: "you@example.com",
|
||||
lud16: "閃電網路地址",
|
||||
lud16Help: "用於接收打賞的閃電網路地址(LUD-16)",
|
||||
lud16Placeholder: "you@getalby.com",
|
||||
showAdvanced: "顯示進階設定",
|
||||
hideAdvanced: "隱藏進階設定",
|
||||
importFromRelays: "從中繼站匯入",
|
||||
importing: "匯入中…",
|
||||
savePublish: "儲存並發布",
|
||||
unsavedChanges: "有未儲存的變更",
|
||||
},
|
||||
},
|
||||
|
||||
// 設定區塊
|
||||
config: {
|
||||
title: "頻道組態",
|
||||
saveChanges: "儲存變更",
|
||||
reloadConfig: "重新載入組態",
|
||||
unsavedChanges: "有未儲存的變更",
|
||||
loadingSchema: "載入組態結構中…",
|
||||
schemaUnavailable: "結構不可用。請使用原始模式。",
|
||||
channelSchemaUnavailable: "頻道組態結構不可用。",
|
||||
reload: "重新載入",
|
||||
},
|
||||
},
|
||||
|
||||
// 工作階段頁面
|
||||
sessions: {
|
||||
title: "工作階段",
|
||||
desc: "進行中的工作階段金鑰與個別覆寫設定。",
|
||||
activeWithin: "活動時間(分鐘)",
|
||||
limit: "數量限制",
|
||||
includeGlobal: "包含全域",
|
||||
includeUnknown: "包含未知",
|
||||
store: "儲存位置",
|
||||
noSessions: "找不到工作階段。",
|
||||
|
||||
columns: {
|
||||
key: "金鑰",
|
||||
label: "標籤",
|
||||
kind: "類型",
|
||||
updated: "更新時間",
|
||||
tokens: "Token 數",
|
||||
thinking: "思考模式",
|
||||
verbose: "詳細模式",
|
||||
reasoning: "推理模式",
|
||||
actions: "操作",
|
||||
},
|
||||
|
||||
levels: {
|
||||
off: "關閉",
|
||||
minimal: "最小",
|
||||
low: "低",
|
||||
medium: "中",
|
||||
high: "高",
|
||||
on: "開啟",
|
||||
stream: "串流",
|
||||
offExplicit: "關閉(明確)",
|
||||
},
|
||||
},
|
||||
|
||||
// 排程任務頁面
|
||||
cron: {
|
||||
title: "排程任務",
|
||||
desc: "排程代理喚醒與週期性任務。",
|
||||
noJobs: "尚無任務。",
|
||||
addJob: "新增任務",
|
||||
runNow: "立即執行",
|
||||
remove: "移除",
|
||||
enable: "啟用",
|
||||
disable: "停用",
|
||||
runs: "執行記錄",
|
||||
lastRun: "上次執行",
|
||||
nextRun: "下次執行",
|
||||
|
||||
// 排程器卡片
|
||||
scheduler: "排程器",
|
||||
schedulerDesc: "閘道器所管理的排程器狀態。",
|
||||
jobs: "任務數",
|
||||
|
||||
// 新任務表單
|
||||
newJob: "新增任務",
|
||||
newJobDesc: "建立排程喚醒或代理執行。",
|
||||
name: "名稱",
|
||||
description: "描述",
|
||||
agentId: "代理 ID",
|
||||
agentIdPlaceholder: "default",
|
||||
scheduleKind: "排程類型",
|
||||
everyLabel: "間隔",
|
||||
atLabel: "指定時間",
|
||||
cronLabel: "Cron",
|
||||
runAt: "執行時間",
|
||||
every: "每隔",
|
||||
unit: "單位",
|
||||
minutes: "分鐘",
|
||||
hours: "小時",
|
||||
days: "天",
|
||||
expression: "運算式",
|
||||
timezone: "時區(選填)",
|
||||
session: "工作階段",
|
||||
main: "主要",
|
||||
isolated: "獨立",
|
||||
wakeMode: "喚醒模式",
|
||||
nextHeartbeat: "下次心跳",
|
||||
now: "立即",
|
||||
payload: "內容類型",
|
||||
systemEvent: "系統事件",
|
||||
agentTurn: "代理回合",
|
||||
systemText: "系統文字",
|
||||
agentMessage: "代理訊息",
|
||||
deliver: "傳送",
|
||||
to: "收件者",
|
||||
toPlaceholder: "+1555… 或聊天 ID",
|
||||
timeoutSeconds: "逾時(秒)",
|
||||
postToMainPrefix: "發送至主工作階段前綴",
|
||||
|
||||
// 任務列表
|
||||
jobsList: "任務",
|
||||
jobsListDesc: "所有儲存在閘道器的排程任務。",
|
||||
|
||||
// 執行歷史
|
||||
runHistory: "執行歷史",
|
||||
runHistoryDesc: "最近的執行記錄:",
|
||||
selectJob: "選擇任務以檢視執行歷史。",
|
||||
noRuns: "尚無執行記錄。",
|
||||
|
||||
form: {
|
||||
schedule: "排程(cron 格式)",
|
||||
message: "訊息",
|
||||
sessionKey: "工作階段金鑰",
|
||||
channel: "頻道",
|
||||
channelPlaceholder: "選擇頻道",
|
||||
enabled: "啟用",
|
||||
},
|
||||
|
||||
status: {
|
||||
enabled: "排程已啟用",
|
||||
disabled: "排程已停用",
|
||||
nextWake: "下次喚醒",
|
||||
},
|
||||
},
|
||||
|
||||
// Skills 頁面
|
||||
skills: {
|
||||
title: "Skills",
|
||||
desc: "管理內建與已安裝的 Skills。",
|
||||
noSkills: "找不到 Skills。",
|
||||
filter: "篩選 Skills",
|
||||
shown: "個顯示中",
|
||||
apiKey: "API 金鑰",
|
||||
saveKey: "儲存金鑰",
|
||||
install: "安裝",
|
||||
installing: "安裝中…",
|
||||
enabled: "已啟用",
|
||||
disabled: "已停用",
|
||||
toggle: "切換",
|
||||
keySaved: "API 金鑰已儲存",
|
||||
keyError: "儲存 API 金鑰失敗",
|
||||
eligible: "可用",
|
||||
blocked: "已封鎖",
|
||||
missing: "缺少",
|
||||
reason: "原因",
|
||||
blockedByAllowlist: "被許可清單封鎖",
|
||||
},
|
||||
|
||||
// 節點頁面
|
||||
nodes: {
|
||||
title: "節點",
|
||||
desc: "已配對的裝置與即時連結。",
|
||||
noNodes: "找不到節點。",
|
||||
devices: "裝置",
|
||||
devicesDesc: "配對請求與角色 Token。",
|
||||
noDevices: "沒有已配對的裝置。",
|
||||
approve: "核准",
|
||||
reject: "拒絕",
|
||||
revoke: "撤銷",
|
||||
rotate: "輪換",
|
||||
pending: "待處理",
|
||||
paired: "已配對",
|
||||
approved: "已核准",
|
||||
offline: "離線",
|
||||
tokens: "Token",
|
||||
tokensNone: "Token:無",
|
||||
role: "角色",
|
||||
requested: "請求於",
|
||||
repair: "修復",
|
||||
active: "使用中",
|
||||
revoked: "已撤銷",
|
||||
scopes: "範圍",
|
||||
roles: "角色",
|
||||
|
||||
bindings: {
|
||||
title: "執行節點綁定",
|
||||
desc: "使用時將代理固定到特定節點",
|
||||
default: "預設綁定",
|
||||
defaultDesc: "當代理未覆寫節點綁定時使用。",
|
||||
agent: "代理",
|
||||
node: "節點",
|
||||
anyNode: "任意節點",
|
||||
useDefault: "使用預設",
|
||||
save: "儲存",
|
||||
loadConfig: "載入組態",
|
||||
loadConfigNote: "載入組態以編輯綁定。",
|
||||
noNodesAvailable: "沒有支援 system.run 的節點。",
|
||||
defaultAgent: "預設代理",
|
||||
usesDefault: "使用預設",
|
||||
override: "覆寫",
|
||||
switchToForm: "請將「組態」分頁切換為「表單」模式以在此編輯綁定。",
|
||||
},
|
||||
|
||||
approvals: {
|
||||
title: "執行核准",
|
||||
desc: "許可清單與核准政策,適用於",
|
||||
target: "目標",
|
||||
targetDesc: "閘道器編輯本地核准;節點編輯選定的節點。",
|
||||
host: "主機",
|
||||
gateway: "閘道器",
|
||||
selectNode: "選擇節點",
|
||||
noNodesYet: "尚無節點公告執行核准。",
|
||||
scope: "範圍",
|
||||
defaults: "預設值",
|
||||
security: "安全性",
|
||||
securityDesc: "預設安全模式。",
|
||||
mode: "模式",
|
||||
deny: "拒絕",
|
||||
allowlist: "許可清單",
|
||||
full: "完整",
|
||||
ask: "詢問",
|
||||
askDesc: "預設提示政策。",
|
||||
off: "關閉",
|
||||
onMiss: "未命中時",
|
||||
always: "總是",
|
||||
askFallback: "詢問備援",
|
||||
askFallbackDesc: "當 UI 提示不可用時套用。",
|
||||
fallback: "備援",
|
||||
autoAllowSkills: "自動允許技能 CLI",
|
||||
autoAllowSkillsDesc: "允許閘道器列出的技能可執行檔。",
|
||||
usingDefault: "使用預設",
|
||||
addPattern: "新增模式",
|
||||
allowlistTitle: "許可清單",
|
||||
allowlistDesc: "不區分大小寫的 glob 模式。",
|
||||
noAllowlist: "尚無許可清單項目。",
|
||||
pattern: "模式",
|
||||
newPattern: "新模式",
|
||||
lastUsed: "上次使用",
|
||||
never: "從未",
|
||||
remove: "移除",
|
||||
loadApprovals: "載入核准",
|
||||
loadApprovalsNote: "載入執行核准以編輯許可清單。",
|
||||
},
|
||||
},
|
||||
|
||||
// 組態頁面
|
||||
config: {
|
||||
title: "設定",
|
||||
desc: "具有結構驗證的組態編輯器。",
|
||||
valid: "有效",
|
||||
invalid: "無效",
|
||||
searchSettings: "搜尋設定...",
|
||||
allSettings: "所有設定",
|
||||
form: "表單",
|
||||
raw: "原始",
|
||||
rawJson5: "原始 JSON5",
|
||||
reload: "重新載入",
|
||||
update: "更新",
|
||||
updating: "更新中…",
|
||||
noChanges: "沒有變更",
|
||||
unsavedChanges: "有未儲存的變更",
|
||||
unsavedCount: "{{count}} 個未儲存的變更",
|
||||
unsavedCountPlural: "{{count}} 個未儲存的變更",
|
||||
viewPending: "檢視 {{count}} 個待處理變更",
|
||||
viewPendingPlural: "檢視 {{count}} 個待處理變更",
|
||||
loadingSchema: "載入結構定義中…",
|
||||
formUnsafe: "表單模式無法安全編輯某些欄位。請使用原始模式以避免遺失設定。",
|
||||
|
||||
sections: {
|
||||
env: "環境",
|
||||
update: "更新",
|
||||
agents: "代理",
|
||||
auth: "認證",
|
||||
channels: "頻道",
|
||||
messages: "訊息",
|
||||
commands: "指令",
|
||||
hooks: "鉤子",
|
||||
skills: "Skills",
|
||||
tools: "工具",
|
||||
gateway: "閘道器",
|
||||
wizard: "設定精靈",
|
||||
},
|
||||
},
|
||||
|
||||
// 除錯頁面
|
||||
debug: {
|
||||
title: "除錯",
|
||||
desc: "閘道器內部狀態與手動 RPC 測試。",
|
||||
status: "狀態",
|
||||
health: "健康狀態",
|
||||
models: "模型",
|
||||
modelsDesc: "來自 models.list 的目錄。",
|
||||
heartbeat: "心跳",
|
||||
lastHeartbeat: "上次心跳",
|
||||
events: "事件",
|
||||
eventLog: "事件日誌",
|
||||
eventLogDesc: "最新的閘道器事件。",
|
||||
noEvents: "尚無事件。",
|
||||
rpcCall: "RPC 呼叫",
|
||||
manualRpc: "手動 RPC",
|
||||
manualRpcDesc: "使用 JSON 參數發送原始閘道器方法。",
|
||||
method: "方法",
|
||||
params: "參數",
|
||||
paramsJson: "參數(JSON)",
|
||||
call: "呼叫",
|
||||
result: "結果",
|
||||
noResult: "尚無結果。",
|
||||
snapshots: "快照",
|
||||
snapshotsDesc: "狀態、健康狀態與心跳資料。",
|
||||
securityAudit: "安全稽核",
|
||||
critical: "嚴重",
|
||||
warnings: "警告",
|
||||
noCritical: "無嚴重問題",
|
||||
runAuditCmd: "執行以檢視詳情。",
|
||||
},
|
||||
|
||||
// 日誌頁面
|
||||
logs: {
|
||||
title: "日誌",
|
||||
desc: "閘道器檔案日誌(JSONL 格式)。",
|
||||
filter: "篩選",
|
||||
searchLogs: "搜尋日誌",
|
||||
autoFollow: "自動捲動",
|
||||
file: "檔案",
|
||||
truncated: "日誌輸出已截斷;顯示最新的部分。",
|
||||
noEntries: "沒有日誌記錄。",
|
||||
exportFiltered: "匯出篩選結果",
|
||||
exportVisible: "匯出可見內容",
|
||||
|
||||
levels: {
|
||||
trace: "追蹤",
|
||||
debug: "除錯",
|
||||
info: "資訊",
|
||||
warn: "警告",
|
||||
error: "錯誤",
|
||||
fatal: "嚴重",
|
||||
},
|
||||
},
|
||||
|
||||
// 實例頁面
|
||||
instances: {
|
||||
title: "實例",
|
||||
desc: "來自已連線閘道器與節點的存在訊號。",
|
||||
noInstances: "找不到存在訊號。",
|
||||
id: "識別碼",
|
||||
type: "類型",
|
||||
version: "版本",
|
||||
lastSeen: "上次出現",
|
||||
},
|
||||
|
||||
// 執行核准提示
|
||||
execApproval: {
|
||||
title: "需要執行核准",
|
||||
command: "指令",
|
||||
agent: "代理",
|
||||
allowOnce: "允許一次",
|
||||
allowAlways: "永久允許",
|
||||
deny: "拒絕",
|
||||
},
|
||||
|
||||
// 主題
|
||||
theme: {
|
||||
toggle: "切換主題",
|
||||
light: "淺色",
|
||||
dark: "深色",
|
||||
system: "跟隨系統",
|
||||
ariaLabel: "主題",
|
||||
systemAriaLabel: "跟隨系統主題",
|
||||
lightAriaLabel: "淺色主題",
|
||||
darkAriaLabel: "深色主題",
|
||||
},
|
||||
|
||||
// 對話控制
|
||||
chatControls: {
|
||||
disabledDuringOnboarding: "引導期間已停用",
|
||||
toggleThinking: "切換助手思考/工作輸出",
|
||||
toggleFocusMode: "切換專注模式(隱藏側邊欄和頁首)",
|
||||
selectLanguage: "選擇語言",
|
||||
},
|
||||
|
||||
// 閘道器連線
|
||||
gateway: {
|
||||
disconnected: "已與閘道器斷線。",
|
||||
},
|
||||
|
||||
// Nostr 個人檔案訊息
|
||||
nostrProfile: {
|
||||
publishFailed: "個人檔案發布至所有中繼站失敗。",
|
||||
publishSuccess: "個人檔案已發布至中繼站。",
|
||||
updateFailed: "個人檔案更新失敗",
|
||||
importFailed: "個人檔案匯入失敗",
|
||||
importedFromRelays: "已從中繼站匯入個人檔案。請檢視並發布。",
|
||||
importedReviewPublish: "已匯入個人檔案。請檢視並發布。",
|
||||
},
|
||||
|
||||
// 時間/日期格式
|
||||
time: {
|
||||
justNow: "剛剛",
|
||||
minutesAgo: "{{count}} 分鐘前",
|
||||
hoursAgo: "{{count}} 小時前",
|
||||
daysAgo: "{{count}} 天前",
|
||||
never: "從未",
|
||||
},
|
||||
|
||||
// Markdown 側邊欄
|
||||
sidebar: {
|
||||
close: "關閉",
|
||||
viewRaw: "檢視原始內容",
|
||||
error: "載入內容時發生錯誤",
|
||||
},
|
||||
|
||||
// 錯誤訊息
|
||||
errors: {
|
||||
connectionFailed: "連線失敗",
|
||||
loadFailed: "載入失敗",
|
||||
saveFailed: "儲存失敗",
|
||||
unknownError: "發生未知錯誤",
|
||||
networkError: "網路錯誤",
|
||||
timeout: "請求逾時",
|
||||
unauthorized: "未授權",
|
||||
forbidden: "禁止存取",
|
||||
notFound: "找不到資源",
|
||||
},
|
||||
};
|
||||
@ -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
|
||||
=========================================== */
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
|
||||
@ -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>`;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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,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) =>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user