feat: Implement internationalization (i18n) with English and Portuguese (pt-BR) locales and integrate into UI components.

This commit is contained in:
Lekler 2026-01-28 04:15:40 -03:00
parent 9688454a30
commit 54900d6358
12 changed files with 829 additions and 432 deletions

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

@ -0,0 +1,5 @@
export * from './lib/types';
export * from './lib/translate';
export * from './lib/lit-controller';
export * from './locales/en';
export * from './locales/pt-BR';

View File

@ -0,0 +1,22 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import { i18n } from './translate';
export class I18nController implements ReactiveController {
host: ReactiveControllerHost;
private unsubscribe?: () => void;
constructor(host: ReactiveControllerHost) {
this.host = host;
this.host.addController(this);
}
hostConnected() {
this.unsubscribe = i18n.subscribe(() => {
this.host.requestUpdate();
});
}
hostDisconnected() {
this.unsubscribe?.();
}
}

View File

@ -0,0 +1,81 @@
import type { TranslationMap } from './types';
class Translator {
private locale: string = 'en';
private translations: Record<string, TranslationMap> = {};
private listeners: Set<() => void> = new Set();
constructor() {
// Try to load saved locale
try {
const saved = localStorage.getItem('moltbot-locale');
if (saved) {
this.locale = saved;
} else if (navigator.language.startsWith('pt')) {
this.locale = 'pt-BR';
}
} catch (e) {
// ignore
}
}
get currentLocale() {
return this.locale;
}
subscribe(cb: () => void) {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
private notify() {
this.listeners.forEach(cb => cb());
}
setResources(locale: string, resources: TranslationMap) {
this.translations[locale] = resources;
this.notify();
}
setLocale(locale: string) {
if (this.locale === locale) return;
this.locale = locale;
try {
localStorage.setItem('moltbot-locale', locale);
} catch (e) {
// ignore
}
this.notify();
}
t(key: string, params?: Record<string, string | number>): string {
const table = this.translations[this.locale];
let val = this.resolve(table, key);
if (val === undefined || val === null) {
// Fallback to EN if key missing in current
if (this.locale !== 'en') {
val = this.resolve(this.translations['en'], key);
}
}
if (val === undefined || val === null) return key;
if (typeof val !== 'string') return key;
return this.interpolate(val, params);
}
private resolve(table: TranslationMap | undefined, key: string): any {
if (!table) return undefined;
return key.split('.').reduce((obj, i) => (obj ? obj[i] : null), table);
}
private interpolate(text: string, params?: Record<string, string | number>) {
if (!params) return text;
return text.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`));
}
}
export const i18n = new Translator();
export const t = i18n.t.bind(i18n);

1
ui/src/i18n/lib/types.ts Normal file
View File

@ -0,0 +1 @@
export type TranslationMap = Record<string, any>; // Relaxed type for nested objects

98
ui/src/i18n/locales/en.ts Normal file
View File

@ -0,0 +1,98 @@
export const en = {
brand: {
title: "MOLTBOT",
subtitle: "Gateway Dashboard"
},
sidebar: {
expand: "Expand sidebar",
collapse: "Collapse sidebar"
},
status: {
health: "Health",
ok: "OK",
offline: "Offline",
connected: "Connected",
disconnected: "Disconnected"
},
nav: {
resources: "Resources",
docs: "Docs",
group: {
chat: "Chat",
control: "Control",
agent: "Agent",
settings: "Settings"
}
},
tab: {
title: {
overview: "Overview",
channels: "Channels",
instances: "Instances",
sessions: "Sessions",
cron: "Cron Jobs",
skills: "Skills",
nodes: "Nodes",
chat: "Chat",
config: "Config",
debug: "Debug",
logs: "Logs",
control: "Control"
},
subtitle: {
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."
}
},
overview: {
gateway_access: {
title: "Gateway Access",
sub: "Where the dashboard connects and how it authenticates."
},
snapshot: {
title: "Snapshot",
sub: "Latest gateway handshake information."
},
notes: {
title: "Notes",
sub: "Quick reminders for remote control setups.",
tailscale: "Tailscale serve",
tailscale_sub: "Prefer serve mode to keep the gateway on loopback with tailnet auth.",
session: "Session hygiene",
session_sub: "Use /new or sessions.patch to reset context.",
cron: "Cron reminders",
cron_sub: "Use isolated sessions for recurring runs."
},
field: {
websocket: "WebSocket URL",
token: "Gateway Token",
password: "Password (not stored)",
session: "Default Session Key",
language: "Language"
},
stats: {
uptime: "Uptime",
tick: "Tick Interval",
last_refresh: "Last Channels Refresh",
},
action: {
connect: "Connect",
refresh: "Refresh"
},
hint: {
connect_apply: "Click Connect to apply connection changes.",
use_channels: "Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.",
auth_failed: "Auth failed. Re-copy a tokenized URL with `moltbot dashboard --no-open`, or update the token, then click Connect.",
https_required: "This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open http://127.0.0.1:18789 on the gateway host."
}
}
};

View File

@ -0,0 +1,98 @@
export const ptBR = {
brand: {
title: "MOLTBOT",
subtitle: "Painel do Gateway"
},
sidebar: {
expand: "Expandir barra lateral",
collapse: "Recolher barra lateral"
},
status: {
health: "Saúde",
ok: "OK",
offline: "Offline",
connected: "Conectado",
disconnected: "Desconectado"
},
nav: {
resources: "Recursos",
docs: "Documentação",
group: {
chat: "Chat",
control: "Controle",
agent: "Agente",
settings: "Configurações"
}
},
tab: {
title: {
overview: "Visão Geral",
channels: "Canais",
instances: "Instâncias",
sessions: "Sessões",
cron: "Tarefas Cron",
skills: "Skills",
nodes: "Nós",
chat: "Chat",
config: "Configuração",
debug: "Depuração",
logs: "Logs",
control: "Controle"
},
subtitle: {
overview: "Status do gateway e leitura rápida de saúde.",
channels: "Gerenciar canais e configurações.",
instances: "Beacons de presença de clientes e nós conectados.",
sessions: "Inspecionar sessões ativas e ajustar padrões.",
cron: "Agendar despertares e execuções recorrentes de agentes.",
skills: "Gerenciar disponibilidade de skills e chaves de API.",
nodes: "Dispositivos pareados, capacidades e exposição de comandos.",
chat: "Sessão de chat direta com o gateway para intervenções rápidas.",
config: "Editar ~/.clawdbot/moltbot.json com segurança.",
debug: "Snapshots do gateway, eventos e chamadas RPC manuais.",
logs: "Acompanhamento em tempo real dos logs do gateway."
}
},
overview: {
gateway_access: {
title: "Acesso ao Gateway",
sub: "Onde o painel se conecta e como se autentica."
},
snapshot: {
title: "Snapshot",
sub: "Informações mais recentes do handshake do gateway."
},
notes: {
title: "Notas",
sub: "Lembretes rápidos para configurações de controle remoto.",
tailscale: "Tailscale serve",
tailscale_sub: "Prefira o modo serve para manter o gateway em loopback com autenticação tailnet.",
session: "Higiene de sessão",
session_sub: "Use /new ou sessions.patch para redefinir o contexto.",
cron: "Lembretes do Cron",
cron_sub: "Use sessões isoladas para execuções recorrentes."
},
field: {
websocket: "URL do WebSocket",
token: "Token do Gateway",
password: "Senha (não armazenada)",
session: "Chave de Sessão Padrão",
language: "Idioma"
},
stats: {
uptime: "Tempo de Atividade",
tick: "Intervalo de Tick",
last_refresh: "Última Atualização de Canais",
},
action: {
connect: "Conectar",
refresh: "Atualizar"
},
hint: {
connect_apply: "Clique em Conectar para aplicar as alterações.",
use_channels: "Use Canais para vincular WhatsApp, Telegram, Discord, Signal ou iMessage.",
auth_failed: "Falha na autenticação. Copie novamente uma URL tokenizada com `moltbot dashboard --no-open`, ou atualize o token, depois clique em Conectar.",
https_required: "Esta página é HTTP, então o navegador bloqueia a identidade do dispositivo. Use HTTPS (Tailscale Serve) ou abra http://127.0.0.1:18789 no host do gateway."
}
}
};

View File

@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { i18n } from '../lib/translate';
describe('i18n', () => {
beforeEach(() => {
i18n.setResources('en', {
hello: 'Hello World',
greeting: 'Hello {name}',
nested: {
key: 'Nested Value'
}
});
i18n.setResources('pt-BR', {
hello: 'Olá Mundo',
greeting: 'Olá {name}'
});
i18n.setLocale('en');
});
it('translates simple keys', () => {
expect(i18n.t('hello')).toBe('Hello World');
});
it('translates with interpolation', () => {
expect(i18n.t('greeting', { name: 'Alice' })).toBe('Hello Alice');
});
it('handles nested keys', () => {
expect(i18n.t('nested.key')).toBe('Nested Value');
});
it('switches locales', () => {
i18n.setLocale('pt-BR');
expect(i18n.t('hello')).toBe('Olá Mundo');
});
it('fallbacks to en', () => {
i18n.setLocale('pt-BR');
// 'nested.key' is not in pt-BR
expect(i18n.t('nested.key')).toBe('Nested Value');
});
it('returns key if missing', () => {
expect(i18n.t('missing.key')).toBe('missing.key');
});
});

View File

@ -81,6 +81,7 @@ import {
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron"; import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
import { loadDebug, callDebugMethod } from "./controllers/debug"; import { loadDebug, callDebugMethod } from "./controllers/debug";
import { loadLogs } from "./controllers/logs"; import { loadLogs } from "./controllers/logs";
import { t } from "../i18n";
const AVATAR_DATA_RE = /^data:/i; const AVATAR_DATA_RE = /^data:/i;
const AVATAR_HTTP_RE = /^https?:\/\//i; const AVATAR_HTTP_RE = /^https?:\/\//i;
@ -104,7 +105,7 @@ export function renderApp(state: AppViewState) {
const presenceCount = state.presenceEntries.length; const presenceCount = state.presenceEntries.length;
const sessionsCount = state.sessionsResult?.count ?? null; const sessionsCount = state.sessionsResult?.count ?? null;
const cronNext = state.cronStatus?.nextWakeAtMs ?? null; const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
const chatDisabledReason = state.connected ? null : "Disconnected from gateway."; const chatDisabledReason = state.connected ? null : t("status.disconnected") + " from gateway.";
const isChat = state.tab === "chat"; const isChat = state.tab === "chat";
const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding); const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding);
const showThinking = state.onboarding ? false : state.settings.chatShowThinking; const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
@ -118,12 +119,19 @@ export function renderApp(state: AppViewState) {
<button <button
class="nav-collapse-toggle" class="nav-collapse-toggle"
@click=${() => @click=${() =>
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
navCollapsed: !state.settings.navCollapsed, navCollapsed: !state.settings.navCollapsed,
})} })}
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}" @click=${() =>
aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}" state.applySettings({
...state.settings,
navCollapsed: !state.settings.navCollapsed,
})}
title="${state.settings.navCollapsed ? t("sidebar.expand") : t("sidebar.collapse")}"
aria-label="${state.settings.navCollapsed ? t("sidebar.expand") : t("sidebar.collapse")}"
>
<span class="nav-collapse-toggle__icon">${icons.menu}</span>
> >
<span class="nav-collapse-toggle__icon">${icons.menu}</span> <span class="nav-collapse-toggle__icon">${icons.menu}</span>
</button> </button>
@ -132,36 +140,41 @@ 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" /> <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>
<div class="brand-text"> <div class="brand-text">
<div class="brand-title">MOLTBOT</div> <div class="brand-text">
<div class="brand-sub">Gateway Dashboard</div> <div class="brand-title">${t("brand.title")}</div>
<div class="brand-sub">${t("brand.subtitle")}</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="topbar-status"> <div class="topbar-status">
<div class="pill"> <div class="pill">
<span class="statusDot ${state.connected ? "ok" : ""}"></span> <span class="statusDot ${state.connected ? "ok" : ""}"></span>
<span>Health</span> <div class="pill">
<span class="mono">${state.connected ? "OK" : "Offline"}</span> <span class="statusDot ${state.connected ? "ok" : ""}"></span>
<span>${t("status.health")}</span>
<span class="mono">${state.connected ? t("status.ok") : t("status.offline")}</span>
</div>
</div> </div>
${renderThemeToggle(state)} ${renderThemeToggle(state)}
</div> </div>
</header> </header>
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}"> <aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
${TAB_GROUPS.map((group) => { ${TAB_GROUPS.map((group) => {
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false; const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
const hasActiveTab = group.tabs.some((tab) => tab === state.tab); const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
return html` return html`
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}"> <div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
<button <button
class="nav-label" class="nav-label"
@click=${() => { @click=${() => {
const next = { ...state.settings.navGroupsCollapsed }; const next = { ...state.settings.navGroupsCollapsed };
next[group.label] = !isGroupCollapsed; next[group.label] = !isGroupCollapsed;
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
navGroupsCollapsed: next, navGroupsCollapsed: next,
}); });
}} }}
aria-expanded=${!isGroupCollapsed} aria-expanded=${!isGroupCollapsed}
> >
<span class="nav-label__text">${group.label}</span> <span class="nav-label__text">${group.label}</span>
@ -172,10 +185,14 @@ export function renderApp(state: AppViewState) {
</div> </div>
</div> </div>
`; `;
})} })}
<div class="nav-group nav-group--links"> <div class="nav-group nav-group--links">
<div class="nav-label nav-label--static"> <div class="nav-label nav-label--static">
<span class="nav-label__text">Resources</span> <div class="nav-group nav-group--links">
<div class="nav-label nav-label--static">
<span class="nav-label__text">${t("nav.resources")}</span>
</div>
<div class="nav-group__items">
</div> </div>
<div class="nav-group__items"> <div class="nav-group__items">
<a <a
@ -186,7 +203,11 @@ export function renderApp(state: AppViewState) {
title="Docs (opens in new tab)" title="Docs (opens in new tab)"
> >
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span> <span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
<span class="nav-item__text">Docs</span> title="${t("nav.docs")} (opens in new tab)"
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
<span class="nav-item__text">${t("nav.docs")}</span>
</a>
</a> </a>
</div> </div>
</div> </div>
@ -199,383 +220,383 @@ export function renderApp(state: AppViewState) {
</div> </div>
<div class="page-meta"> <div class="page-meta">
${state.lastError ${state.lastError
? html`<div class="pill danger">${state.lastError}</div>` ? html`<div class="pill danger">${state.lastError}</div>`
: nothing} : nothing}
${isChat ? renderChatControls(state) : nothing} ${isChat ? renderChatControls(state) : nothing}
</div> </div>
</section> </section>
${state.tab === "overview" ${state.tab === "overview"
? renderOverview({ ? renderOverview({
connected: state.connected, connected: state.connected,
hello: state.hello, hello: state.hello,
settings: state.settings, settings: state.settings,
password: state.password, password: state.password,
lastError: state.lastError, lastError: state.lastError,
presenceCount, presenceCount,
sessionsCount, sessionsCount,
cronEnabled: state.cronStatus?.enabled ?? null, cronEnabled: state.cronStatus?.enabled ?? null,
cronNext, cronNext,
lastChannelsRefresh: state.channelsLastSuccess, lastChannelsRefresh: state.channelsLastSuccess,
onSettingsChange: (next) => state.applySettings(next), onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next), onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => { onSessionKeyChange: (next) => {
state.sessionKey = next; state.sessionKey = next;
state.chatMessage = ""; state.chatMessage = "";
state.resetToolStream(); state.resetToolStream();
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
sessionKey: next, sessionKey: next,
lastActiveSessionKey: next, lastActiveSessionKey: next,
}); });
void state.loadAssistantIdentity(); void state.loadAssistantIdentity();
}, },
onConnect: () => state.connect(), onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(), onRefresh: () => state.loadOverview(),
}) })
: nothing} : nothing}
${state.tab === "channels" ${state.tab === "channels"
? renderChannels({ ? renderChannels({
connected: state.connected, connected: state.connected,
loading: state.channelsLoading, loading: state.channelsLoading,
snapshot: state.channelsSnapshot, snapshot: state.channelsSnapshot,
lastError: state.channelsError, lastError: state.channelsError,
lastSuccessAt: state.channelsLastSuccess, lastSuccessAt: state.channelsLastSuccess,
whatsappMessage: state.whatsappLoginMessage, whatsappMessage: state.whatsappLoginMessage,
whatsappQrDataUrl: state.whatsappLoginQrDataUrl, whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappConnected: state.whatsappLoginConnected, whatsappConnected: state.whatsappLoginConnected,
whatsappBusy: state.whatsappBusy, whatsappBusy: state.whatsappBusy,
configSchema: state.configSchema, configSchema: state.configSchema,
configSchemaLoading: state.configSchemaLoading, configSchemaLoading: state.configSchemaLoading,
configForm: state.configForm, configForm: state.configForm,
configUiHints: state.configUiHints, configUiHints: state.configUiHints,
configSaving: state.configSaving, configSaving: state.configSaving,
configFormDirty: state.configFormDirty, configFormDirty: state.configFormDirty,
nostrProfileFormState: state.nostrProfileFormState, nostrProfileFormState: state.nostrProfileFormState,
nostrProfileAccountId: state.nostrProfileAccountId, nostrProfileAccountId: state.nostrProfileAccountId,
onRefresh: (probe) => loadChannels(state, probe), onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force), onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(), onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(), onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value), onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigSave: () => state.handleChannelConfigSave(), onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(), onConfigReload: () => state.handleChannelConfigReload(),
onNostrProfileEdit: (accountId, profile) => onNostrProfileEdit: (accountId, profile) =>
state.handleNostrProfileEdit(accountId, profile), state.handleNostrProfileEdit(accountId, profile),
onNostrProfileCancel: () => state.handleNostrProfileCancel(), onNostrProfileCancel: () => state.handleNostrProfileCancel(),
onNostrProfileFieldChange: (field, value) => onNostrProfileFieldChange: (field, value) =>
state.handleNostrProfileFieldChange(field, value), state.handleNostrProfileFieldChange(field, value),
onNostrProfileSave: () => state.handleNostrProfileSave(), onNostrProfileSave: () => state.handleNostrProfileSave(),
onNostrProfileImport: () => state.handleNostrProfileImport(), onNostrProfileImport: () => state.handleNostrProfileImport(),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(), onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
}) })
: nothing} : nothing}
${state.tab === "instances" ${state.tab === "instances"
? renderInstances({ ? renderInstances({
loading: state.presenceLoading, loading: state.presenceLoading,
entries: state.presenceEntries, entries: state.presenceEntries,
lastError: state.presenceError, lastError: state.presenceError,
statusMessage: state.presenceStatus, statusMessage: state.presenceStatus,
onRefresh: () => loadPresence(state), onRefresh: () => loadPresence(state),
}) })
: nothing} : nothing}
${state.tab === "sessions" ${state.tab === "sessions"
? renderSessions({ ? renderSessions({
loading: state.sessionsLoading, loading: state.sessionsLoading,
result: state.sessionsResult, result: state.sessionsResult,
error: state.sessionsError, error: state.sessionsError,
activeMinutes: state.sessionsFilterActive, activeMinutes: state.sessionsFilterActive,
limit: state.sessionsFilterLimit, limit: state.sessionsFilterLimit,
includeGlobal: state.sessionsIncludeGlobal, includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown, includeUnknown: state.sessionsIncludeUnknown,
basePath: state.basePath, basePath: state.basePath,
onFiltersChange: (next) => { onFiltersChange: (next) => {
state.sessionsFilterActive = next.activeMinutes; state.sessionsFilterActive = next.activeMinutes;
state.sessionsFilterLimit = next.limit; state.sessionsFilterLimit = next.limit;
state.sessionsIncludeGlobal = next.includeGlobal; state.sessionsIncludeGlobal = next.includeGlobal;
state.sessionsIncludeUnknown = next.includeUnknown; state.sessionsIncludeUnknown = next.includeUnknown;
}, },
onRefresh: () => loadSessions(state), onRefresh: () => loadSessions(state),
onPatch: (key, patch) => patchSession(state, key, patch), onPatch: (key, patch) => patchSession(state, key, patch),
onDelete: (key) => deleteSession(state, key), onDelete: (key) => deleteSession(state, key),
}) })
: nothing} : nothing}
${state.tab === "cron" ${state.tab === "cron"
? renderCron({ ? renderCron({
loading: state.cronLoading, loading: state.cronLoading,
status: state.cronStatus, status: state.cronStatus,
jobs: state.cronJobs, jobs: state.cronJobs,
error: state.cronError, error: state.cronError,
busy: state.cronBusy, busy: state.cronBusy,
form: state.cronForm, form: state.cronForm,
channels: state.channelsSnapshot?.channelMeta?.length channels: state.channelsSnapshot?.channelMeta?.length
? state.channelsSnapshot.channelMeta.map((entry) => entry.id) ? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
: state.channelsSnapshot?.channelOrder ?? [], : state.channelsSnapshot?.channelOrder ?? [],
channelLabels: state.channelsSnapshot?.channelLabels ?? {}, channelLabels: state.channelsSnapshot?.channelLabels ?? {},
channelMeta: state.channelsSnapshot?.channelMeta ?? [], channelMeta: state.channelsSnapshot?.channelMeta ?? [],
runsJobId: state.cronRunsJobId, runsJobId: state.cronRunsJobId,
runs: state.cronRuns, runs: state.cronRuns,
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }), onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
onRefresh: () => state.loadCron(), onRefresh: () => state.loadCron(),
onAdd: () => addCronJob(state), onAdd: () => addCronJob(state),
onToggle: (job, enabled) => toggleCronJob(state, job, enabled), onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job) => runCronJob(state, job), onRun: (job) => runCronJob(state, job),
onRemove: (job) => removeCronJob(state, job), onRemove: (job) => removeCronJob(state, job),
onLoadRuns: (jobId) => loadCronRuns(state, jobId), onLoadRuns: (jobId) => loadCronRuns(state, jobId),
}) })
: nothing} : nothing}
${state.tab === "skills" ${state.tab === "skills"
? renderSkills({ ? renderSkills({
loading: state.skillsLoading, loading: state.skillsLoading,
report: state.skillsReport, report: state.skillsReport,
error: state.skillsError, error: state.skillsError,
filter: state.skillsFilter, filter: state.skillsFilter,
edits: state.skillEdits, edits: state.skillEdits,
messages: state.skillMessages, messages: state.skillMessages,
busyKey: state.skillsBusyKey, busyKey: state.skillsBusyKey,
onFilterChange: (next) => (state.skillsFilter = next), onFilterChange: (next) => (state.skillsFilter = next),
onRefresh: () => loadSkills(state, { clearMessages: true }), onRefresh: () => loadSkills(state, { clearMessages: true }),
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled), onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onEdit: (key, value) => updateSkillEdit(state, key, value), onEdit: (key, value) => updateSkillEdit(state, key, value),
onSaveKey: (key) => saveSkillApiKey(state, key), onSaveKey: (key) => saveSkillApiKey(state, key),
onInstall: (skillKey, name, installId) => onInstall: (skillKey, name, installId) =>
installSkill(state, skillKey, name, installId), installSkill(state, skillKey, name, installId),
}) })
: nothing} : nothing}
${state.tab === "nodes" ${state.tab === "nodes"
? renderNodes({ ? renderNodes({
loading: state.nodesLoading, loading: state.nodesLoading,
nodes: state.nodes, nodes: state.nodes,
devicesLoading: state.devicesLoading, devicesLoading: state.devicesLoading,
devicesError: state.devicesError, devicesError: state.devicesError,
devicesList: state.devicesList, devicesList: state.devicesList,
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null), configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
configLoading: state.configLoading, configLoading: state.configLoading,
configSaving: state.configSaving, configSaving: state.configSaving,
configDirty: state.configFormDirty, configDirty: state.configFormDirty,
configFormMode: state.configFormMode, configFormMode: state.configFormMode,
execApprovalsLoading: state.execApprovalsLoading, execApprovalsLoading: state.execApprovalsLoading,
execApprovalsSaving: state.execApprovalsSaving, execApprovalsSaving: state.execApprovalsSaving,
execApprovalsDirty: state.execApprovalsDirty, execApprovalsDirty: state.execApprovalsDirty,
execApprovalsSnapshot: state.execApprovalsSnapshot, execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm, execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent, execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget, execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId, execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state), onRefresh: () => loadNodes(state),
onDevicesRefresh: () => loadDevices(state), onDevicesRefresh: () => loadDevices(state),
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId), onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId), onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
onDeviceRotate: (deviceId, role, scopes) => onDeviceRotate: (deviceId, role, scopes) =>
rotateDeviceToken(state, { deviceId, role, scopes }), rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) => onDeviceRevoke: (deviceId, role) =>
revokeDeviceToken(state, { deviceId, role }), revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => loadConfig(state), onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => { onLoadExecApprovals: () => {
const target = const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const }; : { kind: "gateway" as const };
return loadExecApprovals(state, target); return loadExecApprovals(state, target);
}, },
onBindDefault: (nodeId) => { onBindDefault: (nodeId) => {
if (nodeId) { if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId); updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
} else { } else {
removeConfigFormValue(state, ["tools", "exec", "node"]); removeConfigFormValue(state, ["tools", "exec", "node"]);
} }
}, },
onBindAgent: (agentIndex, nodeId) => { onBindAgent: (agentIndex, nodeId) => {
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"]; const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
if (nodeId) { if (nodeId) {
updateConfigFormValue(state, basePath, nodeId); updateConfigFormValue(state, basePath, nodeId);
} else { } else {
removeConfigFormValue(state, basePath); removeConfigFormValue(state, basePath);
} }
}, },
onSaveBindings: () => saveConfig(state), onSaveBindings: () => saveConfig(state),
onExecApprovalsTargetChange: (kind, nodeId) => { onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind; state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId; state.execApprovalsTargetNodeId = nodeId;
state.execApprovalsSnapshot = null; state.execApprovalsSnapshot = null;
state.execApprovalsForm = null; state.execApprovalsForm = null;
state.execApprovalsDirty = false; state.execApprovalsDirty = false;
state.execApprovalsSelectedAgent = null; state.execApprovalsSelectedAgent = null;
}, },
onExecApprovalsSelectAgent: (agentId) => { onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId; state.execApprovalsSelectedAgent = agentId;
}, },
onExecApprovalsPatch: (path, value) => onExecApprovalsPatch: (path, value) =>
updateExecApprovalsFormValue(state, path, value), updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) => onExecApprovalsRemove: (path) =>
removeExecApprovalsFormValue(state, path), removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => { onSaveExecApprovals: () => {
const target = const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const }; : { kind: "gateway" as const };
return saveExecApprovals(state, target); return saveExecApprovals(state, target);
}, },
}) })
: nothing} : nothing}
${state.tab === "chat" ${state.tab === "chat"
? renderChat({ ? renderChat({
sessionKey: state.sessionKey, sessionKey: state.sessionKey,
onSessionKeyChange: (next) => { onSessionKeyChange: (next) => {
state.sessionKey = next; state.sessionKey = next;
state.chatMessage = ""; state.chatMessage = "";
state.chatAttachments = []; state.chatAttachments = [];
state.chatStream = null; state.chatStream = null;
state.chatStreamStartedAt = null; state.chatStreamStartedAt = null;
state.chatRunId = null; state.chatRunId = null;
state.chatQueue = []; state.chatQueue = [];
state.resetToolStream(); state.resetToolStream();
state.resetChatScroll(); state.resetChatScroll();
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
sessionKey: next, sessionKey: next,
lastActiveSessionKey: next, lastActiveSessionKey: next,
}); });
void state.loadAssistantIdentity(); void state.loadAssistantIdentity();
void loadChatHistory(state); void loadChatHistory(state);
void refreshChatAvatar(state); void refreshChatAvatar(state);
}, },
thinkingLevel: state.chatThinkingLevel, thinkingLevel: state.chatThinkingLevel,
showThinking, showThinking,
loading: state.chatLoading, loading: state.chatLoading,
sending: state.chatSending, sending: state.chatSending,
compactionStatus: state.compactionStatus, compactionStatus: state.compactionStatus,
assistantAvatarUrl: chatAvatarUrl, assistantAvatarUrl: chatAvatarUrl,
messages: state.chatMessages, messages: state.chatMessages,
toolMessages: state.chatToolMessages, toolMessages: state.chatToolMessages,
stream: state.chatStream, stream: state.chatStream,
streamStartedAt: state.chatStreamStartedAt, streamStartedAt: state.chatStreamStartedAt,
draft: state.chatMessage, draft: state.chatMessage,
queue: state.chatQueue, queue: state.chatQueue,
connected: state.connected, connected: state.connected,
canSend: state.connected, canSend: state.connected,
disabledReason: chatDisabledReason, disabledReason: chatDisabledReason,
error: state.lastError, error: state.lastError,
sessions: state.sessionsResult, sessions: state.sessionsResult,
focusMode: chatFocus, focusMode: chatFocus,
onRefresh: () => { onRefresh: () => {
state.resetToolStream(); state.resetToolStream();
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
}, },
onToggleFocusMode: () => { onToggleFocusMode: () => {
if (state.onboarding) return; if (state.onboarding) return;
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
chatFocusMode: !state.settings.chatFocusMode, chatFocusMode: !state.settings.chatFocusMode,
}); });
}, },
onChatScroll: (event) => state.handleChatScroll(event), onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next), onDraftChange: (next) => (state.chatMessage = next),
attachments: state.chatAttachments, attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next), onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(), onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId), canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(), onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id), onQueueRemove: (id) => state.removeQueuedMessage(id),
onNewSession: () => onNewSession: () =>
state.handleSendChat("/new", { restoreDraft: true }), state.handleSendChat("/new", { restoreDraft: true }),
// Sidebar props for tool output viewing // Sidebar props for tool output viewing
sidebarOpen: state.sidebarOpen, sidebarOpen: state.sidebarOpen,
sidebarContent: state.sidebarContent, sidebarContent: state.sidebarContent,
sidebarError: state.sidebarError, sidebarError: state.sidebarError,
splitRatio: state.splitRatio, splitRatio: state.splitRatio,
onOpenSidebar: (content: string) => state.handleOpenSidebar(content), onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
onCloseSidebar: () => state.handleCloseSidebar(), onCloseSidebar: () => state.handleCloseSidebar(),
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio), onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName, assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar, assistantAvatar: state.assistantAvatar,
}) })
: nothing} : nothing}
${state.tab === "config" ${state.tab === "config"
? renderConfig({ ? renderConfig({
raw: state.configRaw, raw: state.configRaw,
originalRaw: state.configRawOriginal, originalRaw: state.configRawOriginal,
valid: state.configValid, valid: state.configValid,
issues: state.configIssues, issues: state.configIssues,
loading: state.configLoading, loading: state.configLoading,
saving: state.configSaving, saving: state.configSaving,
applying: state.configApplying, applying: state.configApplying,
updating: state.updateRunning, updating: state.updateRunning,
connected: state.connected, connected: state.connected,
schema: state.configSchema, schema: state.configSchema,
schemaLoading: state.configSchemaLoading, schemaLoading: state.configSchemaLoading,
uiHints: state.configUiHints, uiHints: state.configUiHints,
formMode: state.configFormMode, formMode: state.configFormMode,
formValue: state.configForm, formValue: state.configForm,
originalValue: state.configFormOriginal, originalValue: state.configFormOriginal,
searchQuery: state.configSearchQuery, searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection, activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection, activeSubsection: state.configActiveSubsection,
onRawChange: (next) => { onRawChange: (next) => {
state.configRaw = next; state.configRaw = next;
}, },
onFormModeChange: (mode) => (state.configFormMode = mode), onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value), onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query), onSearchChange: (query) => (state.configSearchQuery = query),
onSectionChange: (section) => { onSectionChange: (section) => {
state.configActiveSection = section; state.configActiveSection = section;
state.configActiveSubsection = null; state.configActiveSubsection = null;
}, },
onSubsectionChange: (section) => (state.configActiveSubsection = section), onSubsectionChange: (section) => (state.configActiveSubsection = section),
onReload: () => loadConfig(state), onReload: () => loadConfig(state),
onSave: () => saveConfig(state), onSave: () => saveConfig(state),
onApply: () => applyConfig(state), onApply: () => applyConfig(state),
onUpdate: () => runUpdate(state), onUpdate: () => runUpdate(state),
}) })
: nothing} : nothing}
${state.tab === "debug" ${state.tab === "debug"
? renderDebug({ ? renderDebug({
loading: state.debugLoading, loading: state.debugLoading,
status: state.debugStatus, status: state.debugStatus,
health: state.debugHealth, health: state.debugHealth,
models: state.debugModels, models: state.debugModels,
heartbeat: state.debugHeartbeat, heartbeat: state.debugHeartbeat,
eventLog: state.eventLog, eventLog: state.eventLog,
callMethod: state.debugCallMethod, callMethod: state.debugCallMethod,
callParams: state.debugCallParams, callParams: state.debugCallParams,
callResult: state.debugCallResult, callResult: state.debugCallResult,
callError: state.debugCallError, callError: state.debugCallError,
onCallMethodChange: (next) => (state.debugCallMethod = next), onCallMethodChange: (next) => (state.debugCallMethod = next),
onCallParamsChange: (next) => (state.debugCallParams = next), onCallParamsChange: (next) => (state.debugCallParams = next),
onRefresh: () => loadDebug(state), onRefresh: () => loadDebug(state),
onCall: () => callDebugMethod(state), onCall: () => callDebugMethod(state),
}) })
: nothing} : nothing}
${state.tab === "logs" ${state.tab === "logs"
? renderLogs({ ? renderLogs({
loading: state.logsLoading, loading: state.logsLoading,
error: state.logsError, error: state.logsError,
file: state.logsFile, file: state.logsFile,
entries: state.logsEntries, entries: state.logsEntries,
filterText: state.logsFilterText, filterText: state.logsFilterText,
levelFilters: state.logsLevelFilters, levelFilters: state.logsLevelFilters,
autoFollow: state.logsAutoFollow, autoFollow: state.logsAutoFollow,
truncated: state.logsTruncated, truncated: state.logsTruncated,
onFilterTextChange: (next) => (state.logsFilterText = next), onFilterTextChange: (next) => (state.logsFilterText = next),
onLevelToggle: (level, enabled) => { onLevelToggle: (level, enabled) => {
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled }; state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
}, },
onToggleAutoFollow: (next) => (state.logsAutoFollow = next), onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state, { reset: true }), onRefresh: () => loadLogs(state, { reset: true }),
onExport: (lines, label) => state.exportLogs(lines, label), onExport: (lines, label) => state.exportLogs(lines, label),
onScroll: (event) => state.handleLogsScroll(event), onScroll: (event) => state.handleLogsScroll(event),
}) })
: nothing} : nothing}
</main> </main>
${renderExecApprovalPrompt(state)} ${renderExecApprovalPrompt(state)}
</div> </div>

View File

@ -78,6 +78,7 @@ import {
} from "./app-channels"; } from "./app-channels";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form"; import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity"; import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity";
import { i18n, en, ptBR, I18nController } from "../i18n";
declare global { declare global {
interface Window { interface Window {
@ -111,6 +112,7 @@ export class MoltbotApp extends LitElement {
private eventLogBuffer: EventLogEntry[] = []; private eventLogBuffer: EventLogEntry[] = [];
private toolStreamSyncTimer: number | null = null; private toolStreamSyncTimer: number | null = null;
private sidebarCloseTimer: number | null = null; private sidebarCloseTimer: number | null = null;
private i18n = new I18nController(this);
@state() assistantName = injectedAssistantIdentity.name; @state() assistantName = injectedAssistantIdentity.name;
@state() assistantAvatar = injectedAssistantIdentity.avatar; @state() assistantAvatar = injectedAssistantIdentity.avatar;
@ -272,6 +274,9 @@ export class MoltbotApp extends LitElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
i18n.setResources('en', en);
i18n.setResources('pt-BR', ptBR);
i18n.setLocale(this.settings.locale || 'en');
handleConnected(this as unknown as Parameters<typeof handleConnected>[0]); handleConnected(this as unknown as Parameters<typeof handleConnected>[0]);
} }
@ -332,6 +337,9 @@ export class MoltbotApp extends LitElement {
} }
applySettings(next: UiSettings) { applySettings(next: UiSettings) {
if (next.locale !== this.settings.locale) {
i18n.setLocale(next.locale);
}
applySettingsInternal( applySettingsInternal(
this as unknown as Parameters<typeof applySettingsInternal>[0], this as unknown as Parameters<typeof applySettingsInternal>[0],
next, next,

View File

@ -1,13 +1,14 @@
import type { IconName } from "./icons.js"; import type { IconName } from "./icons.js";
import { t } from "../i18n";
export const TAB_GROUPS = [ export const TAB_GROUPS = [
{ label: "Chat", tabs: ["chat"] }, { label: "chat", tabs: ["chat"] },
{ {
label: "Control", label: "control",
tabs: ["overview", "channels", "instances", "sessions", "cron"], tabs: ["overview", "channels", "instances", "sessions", "cron"],
}, },
{ label: "Agent", tabs: ["skills", "nodes"] }, { label: "agent", tabs: ["skills", "nodes"] },
{ label: "Settings", tabs: ["config", "debug", "logs"] }, { label: "settings", tabs: ["config", "debug", "logs"] },
] as const; ] as const;
export type Tab = export type Tab =
@ -132,56 +133,56 @@ export function iconForTab(tab: Tab): IconName {
export function titleForTab(tab: Tab) { export function titleForTab(tab: Tab) {
switch (tab) { switch (tab) {
case "overview": case "overview":
return "Overview"; return t("tab.title.overview");
case "channels": case "channels":
return "Channels"; return t("tab.title.channels");
case "instances": case "instances":
return "Instances"; return t("tab.title.instances");
case "sessions": case "sessions":
return "Sessions"; return t("tab.title.sessions");
case "cron": case "cron":
return "Cron Jobs"; return t("tab.title.cron");
case "skills": case "skills":
return "Skills"; return t("tab.title.skills");
case "nodes": case "nodes":
return "Nodes"; return t("tab.title.nodes");
case "chat": case "chat":
return "Chat"; return t("tab.title.chat");
case "config": case "config":
return "Config"; return t("tab.title.config");
case "debug": case "debug":
return "Debug"; return t("tab.title.debug");
case "logs": case "logs":
return "Logs"; return t("tab.title.logs");
default: default:
return "Control"; return t("tab.title.control");
} }
} }
export function subtitleForTab(tab: Tab) { export function subtitleForTab(tab: Tab) {
switch (tab) { switch (tab) {
case "overview": case "overview":
return "Gateway status, entry points, and a fast health read."; return t("tab.subtitle.overview");
case "channels": case "channels":
return "Manage channels and settings."; return t("tab.subtitle.channels");
case "instances": case "instances":
return "Presence beacons from connected clients and nodes."; return t("tab.subtitle.instances");
case "sessions": case "sessions":
return "Inspect active sessions and adjust per-session defaults."; return t("tab.subtitle.sessions");
case "cron": case "cron":
return "Schedule wakeups and recurring agent runs."; return t("tab.subtitle.cron");
case "skills": case "skills":
return "Manage skill availability and API key injection."; return t("tab.subtitle.skills");
case "nodes": case "nodes":
return "Paired devices, capabilities, and command exposure."; return t("tab.subtitle.nodes");
case "chat": case "chat":
return "Direct gateway chat session for quick interventions."; return t("tab.subtitle.chat");
case "config": case "config":
return "Edit ~/.clawdbot/moltbot.json safely."; return t("tab.subtitle.config");
case "debug": case "debug":
return "Gateway snapshots, events, and manual RPC calls."; return t("tab.subtitle.debug");
case "logs": case "logs":
return "Live tail of the gateway file logs."; return t("tab.subtitle.logs");
default: default:
return ""; return "";
} }

View File

@ -13,6 +13,7 @@ export type UiSettings = {
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6) splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
navCollapsed: boolean; // Collapsible sidebar state navCollapsed: boolean; // Collapsible sidebar state
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
locale: string;
}; };
export function loadSettings(): UiSettings { export function loadSettings(): UiSettings {
@ -32,6 +33,7 @@ export function loadSettings(): UiSettings {
splitRatio: 0.6, splitRatio: 0.6,
navCollapsed: false, navCollapsed: false,
navGroupsCollapsed: {}, navGroupsCollapsed: {},
locale: "en",
}; };
try { try {
@ -50,15 +52,15 @@ export function loadSettings(): UiSettings {
: defaults.sessionKey, : defaults.sessionKey,
lastActiveSessionKey: lastActiveSessionKey:
typeof parsed.lastActiveSessionKey === "string" && typeof parsed.lastActiveSessionKey === "string" &&
parsed.lastActiveSessionKey.trim() parsed.lastActiveSessionKey.trim()
? parsed.lastActiveSessionKey.trim() ? parsed.lastActiveSessionKey.trim()
: (typeof parsed.sessionKey === "string" && : (typeof parsed.sessionKey === "string" &&
parsed.sessionKey.trim()) || parsed.sessionKey.trim()) ||
defaults.lastActiveSessionKey, defaults.lastActiveSessionKey,
theme: theme:
parsed.theme === "light" || parsed.theme === "light" ||
parsed.theme === "dark" || parsed.theme === "dark" ||
parsed.theme === "system" parsed.theme === "system"
? parsed.theme ? parsed.theme
: defaults.theme, : defaults.theme,
chatFocusMode: chatFocusMode:
@ -71,8 +73,8 @@ export function loadSettings(): UiSettings {
: defaults.chatShowThinking, : defaults.chatShowThinking,
splitRatio: splitRatio:
typeof parsed.splitRatio === "number" && typeof parsed.splitRatio === "number" &&
parsed.splitRatio >= 0.4 && parsed.splitRatio >= 0.4 &&
parsed.splitRatio <= 0.7 parsed.splitRatio <= 0.7
? parsed.splitRatio ? parsed.splitRatio
: defaults.splitRatio, : defaults.splitRatio,
navCollapsed: navCollapsed:
@ -81,9 +83,10 @@ export function loadSettings(): UiSettings {
: defaults.navCollapsed, : defaults.navCollapsed,
navGroupsCollapsed: navGroupsCollapsed:
typeof parsed.navGroupsCollapsed === "object" && typeof parsed.navGroupsCollapsed === "object" &&
parsed.navGroupsCollapsed !== null parsed.navGroupsCollapsed !== null
? parsed.navGroupsCollapsed ? parsed.navGroupsCollapsed
: defaults.navGroupsCollapsed, : defaults.navGroupsCollapsed,
locale: typeof parsed.locale === "string" ? parsed.locale : defaults.locale,
}; };
} catch { } catch {
return defaults; return defaults;

View File

@ -127,9 +127,9 @@ export function renderOverview(props: OverviewProps) {
<input <input
.value=${props.settings.gatewayUrl} .value=${props.settings.gatewayUrl}
@input=${(e: Event) => { @input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value; const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, gatewayUrl: v }); props.onSettingsChange({ ...props.settings, gatewayUrl: v });
}} }}
placeholder="ws://100.x.y.z:18789" placeholder="ws://100.x.y.z:18789"
/> />
</label> </label>
@ -138,9 +138,9 @@ export function renderOverview(props: OverviewProps) {
<input <input
.value=${props.settings.token} .value=${props.settings.token}
@input=${(e: Event) => { @input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value; const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, token: v }); props.onSettingsChange({ ...props.settings, token: v });
}} }}
placeholder="CLAWDBOT_GATEWAY_TOKEN" placeholder="CLAWDBOT_GATEWAY_TOKEN"
/> />
</label> </label>
@ -150,22 +150,35 @@ export function renderOverview(props: OverviewProps) {
type="password" type="password"
.value=${props.password} .value=${props.password}
@input=${(e: Event) => { @input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value; const v = (e.target as HTMLInputElement).value;
props.onPasswordChange(v); props.onPasswordChange(v);
}} }}
placeholder="system or shared password" placeholder="system or shared password"
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Default Session Key</span>
<input <input
.value=${props.settings.sessionKey} .value=${props.settings.sessionKey}
@input=${(e: Event) => { @input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value; const v = (e.target as HTMLInputElement).value;
props.onSessionKeyChange(v); props.onSessionKeyChange(v);
}} }}
/> />
</label> </label>
<label class="field">
<span>Language</span>
<select
style="padding: 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-surface); color: var(--text-main);"
.value=${props.settings.locale}
@change=${(e: Event) => {
const v = (e.target as HTMLSelectElement).value;
props.onSettingsChange({ ...props.settings, locale: v });
}}
>
<option value="en">English</option>
<option value="pt-BR">Português (Brasil)</option>
</select>
</label>
</div> </div>
<div class="row" style="margin-top: 14px;"> <div class="row" style="margin-top: 14px;">
<button class="btn" @click=${() => props.onConnect()}>Connect</button> <button class="btn" @click=${() => props.onConnect()}>Connect</button>
@ -196,18 +209,18 @@ export function renderOverview(props: OverviewProps) {
<div class="stat-label">Last Channels Refresh</div> <div class="stat-label">Last Channels Refresh</div>
<div class="stat-value"> <div class="stat-value">
${props.lastChannelsRefresh ${props.lastChannelsRefresh
? formatAgo(props.lastChannelsRefresh) ? formatAgo(props.lastChannelsRefresh)
: "n/a"} : "n/a"}
</div> </div>
</div> </div>
</div> </div>
${props.lastError ${props.lastError
? html`<div class="callout danger" style="margin-top: 14px;"> ? html`<div class="callout danger" style="margin-top: 14px;">
<div>${props.lastError}</div> <div>${props.lastError}</div>
${authHint ?? ""} ${authHint ?? ""}
${insecureContextHint ?? ""} ${insecureContextHint ?? ""}
</div>` </div>`
: html`<div class="callout" style="margin-top: 14px;"> : html`<div class="callout" style="margin-top: 14px;">
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage. Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
</div>`} </div>`}
</div> </div>
@ -228,10 +241,10 @@ export function renderOverview(props: OverviewProps) {
<div class="stat-label">Cron</div> <div class="stat-label">Cron</div>
<div class="stat-value"> <div class="stat-value">
${props.cronEnabled == null ${props.cronEnabled == null
? "n/a" ? "n/a"
: props.cronEnabled : props.cronEnabled
? "Enabled" ? "Enabled"
: "Disabled"} : "Disabled"}
</div> </div>
<div class="muted">Next wake ${formatNextRun(props.cronNext)}</div> <div class="muted">Next wake ${formatNextRun(props.cronNext)}</div>
</div> </div>