feat: Implement internationalization (i18n) with English and Portuguese (pt-BR) locales and integrate into UI components.
This commit is contained in:
parent
9688454a30
commit
54900d6358
5
ui/src/i18n/index.ts
Normal file
5
ui/src/i18n/index.ts
Normal 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';
|
||||
22
ui/src/i18n/lib/lit-controller.ts
Normal file
22
ui/src/i18n/lib/lit-controller.ts
Normal 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?.();
|
||||
}
|
||||
}
|
||||
81
ui/src/i18n/lib/translate.ts
Normal file
81
ui/src/i18n/lib/translate.ts
Normal 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
1
ui/src/i18n/lib/types.ts
Normal file
@ -0,0 +1 @@
|
||||
export type TranslationMap = Record<string, any>; // Relaxed type for nested objects
|
||||
98
ui/src/i18n/locales/en.ts
Normal file
98
ui/src/i18n/locales/en.ts
Normal 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."
|
||||
}
|
||||
}
|
||||
};
|
||||
98
ui/src/i18n/locales/pt-BR.ts
Normal file
98
ui/src/i18n/locales/pt-BR.ts
Normal 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."
|
||||
}
|
||||
}
|
||||
};
|
||||
46
ui/src/i18n/test/translate.test.ts
Normal file
46
ui/src/i18n/test/translate.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -81,6 +81,7 @@ import {
|
||||
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
|
||||
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
||||
import { loadLogs } from "./controllers/logs";
|
||||
import { t } from "../i18n";
|
||||
|
||||
const AVATAR_DATA_RE = /^data:/i;
|
||||
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
||||
@ -104,7 +105,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("status.disconnected") + " from gateway.";
|
||||
const isChat = state.tab === "chat";
|
||||
const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding);
|
||||
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
||||
@ -118,12 +119,19 @@ export function renderApp(state: AppViewState) {
|
||||
<button
|
||||
class="nav-collapse-toggle"
|
||||
@click=${() =>
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
navCollapsed: !state.settings.navCollapsed,
|
||||
})}
|
||||
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
|
||||
aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
navCollapsed: !state.settings.navCollapsed,
|
||||
})}
|
||||
@click=${() =>
|
||||
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>
|
||||
</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" />
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<div class="brand-title">MOLTBOT</div>
|
||||
<div class="brand-sub">Gateway Dashboard</div>
|
||||
<div class="brand-text">
|
||||
<div class="brand-title">${t("brand.title")}</div>
|
||||
<div class="brand-sub">${t("brand.subtitle")}</div>
|
||||
</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>
|
||||
<div class="pill">
|
||||
<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>
|
||||
${renderThemeToggle(state)}
|
||||
</div>
|
||||
</header>
|
||||
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
|
||||
${TAB_GROUPS.map((group) => {
|
||||
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
|
||||
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
|
||||
return html`
|
||||
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
|
||||
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
|
||||
return html`
|
||||
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
|
||||
<button
|
||||
class="nav-label"
|
||||
@click=${() => {
|
||||
const next = { ...state.settings.navGroupsCollapsed };
|
||||
next[group.label] = !isGroupCollapsed;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
navGroupsCollapsed: next,
|
||||
});
|
||||
}}
|
||||
const next = { ...state.settings.navGroupsCollapsed };
|
||||
next[group.label] = !isGroupCollapsed;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
navGroupsCollapsed: next,
|
||||
});
|
||||
}}
|
||||
aria-expanded=${!isGroupCollapsed}
|
||||
>
|
||||
<span class="nav-label__text">${group.label}</span>
|
||||
@ -172,10 +185,14 @@ export function renderApp(state: AppViewState) {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
})}
|
||||
<div class="nav-group nav-group--links">
|
||||
<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 class="nav-group__items">
|
||||
<a
|
||||
@ -186,7 +203,11 @@ export function renderApp(state: AppViewState) {
|
||||
title="Docs (opens in new tab)"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -199,383 +220,383 @@ export function renderApp(state: AppViewState) {
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
${state.lastError
|
||||
? html`<div class="pill danger">${state.lastError}</div>`
|
||||
: nothing}
|
||||
? html`<div class="pill danger">${state.lastError}</div>`
|
||||
: nothing}
|
||||
${isChat ? renderChatControls(state) : nothing}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
${state.tab === "overview"
|
||||
? renderOverview({
|
||||
connected: state.connected,
|
||||
hello: state.hello,
|
||||
settings: state.settings,
|
||||
password: state.password,
|
||||
lastError: state.lastError,
|
||||
presenceCount,
|
||||
sessionsCount,
|
||||
cronEnabled: state.cronStatus?.enabled ?? null,
|
||||
cronNext,
|
||||
lastChannelsRefresh: state.channelsLastSuccess,
|
||||
onSettingsChange: (next) => state.applySettings(next),
|
||||
onPasswordChange: (next) => (state.password = next),
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.resetToolStream();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
},
|
||||
onConnect: () => state.connect(),
|
||||
onRefresh: () => state.loadOverview(),
|
||||
})
|
||||
: nothing}
|
||||
? renderOverview({
|
||||
connected: state.connected,
|
||||
hello: state.hello,
|
||||
settings: state.settings,
|
||||
password: state.password,
|
||||
lastError: state.lastError,
|
||||
presenceCount,
|
||||
sessionsCount,
|
||||
cronEnabled: state.cronStatus?.enabled ?? null,
|
||||
cronNext,
|
||||
lastChannelsRefresh: state.channelsLastSuccess,
|
||||
onSettingsChange: (next) => state.applySettings(next),
|
||||
onPasswordChange: (next) => (state.password = next),
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.resetToolStream();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
},
|
||||
onConnect: () => state.connect(),
|
||||
onRefresh: () => state.loadOverview(),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "channels"
|
||||
? renderChannels({
|
||||
connected: state.connected,
|
||||
loading: state.channelsLoading,
|
||||
snapshot: state.channelsSnapshot,
|
||||
lastError: state.channelsError,
|
||||
lastSuccessAt: state.channelsLastSuccess,
|
||||
whatsappMessage: state.whatsappLoginMessage,
|
||||
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
|
||||
whatsappConnected: state.whatsappLoginConnected,
|
||||
whatsappBusy: state.whatsappBusy,
|
||||
configSchema: state.configSchema,
|
||||
configSchemaLoading: state.configSchemaLoading,
|
||||
configForm: state.configForm,
|
||||
configUiHints: state.configUiHints,
|
||||
configSaving: state.configSaving,
|
||||
configFormDirty: state.configFormDirty,
|
||||
nostrProfileFormState: state.nostrProfileFormState,
|
||||
nostrProfileAccountId: state.nostrProfileAccountId,
|
||||
onRefresh: (probe) => loadChannels(state, probe),
|
||||
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
|
||||
onWhatsAppWait: () => state.handleWhatsAppWait(),
|
||||
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
|
||||
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||
onConfigSave: () => state.handleChannelConfigSave(),
|
||||
onConfigReload: () => state.handleChannelConfigReload(),
|
||||
onNostrProfileEdit: (accountId, profile) =>
|
||||
state.handleNostrProfileEdit(accountId, profile),
|
||||
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
|
||||
onNostrProfileFieldChange: (field, value) =>
|
||||
state.handleNostrProfileFieldChange(field, value),
|
||||
onNostrProfileSave: () => state.handleNostrProfileSave(),
|
||||
onNostrProfileImport: () => state.handleNostrProfileImport(),
|
||||
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
|
||||
})
|
||||
: nothing}
|
||||
? renderChannels({
|
||||
connected: state.connected,
|
||||
loading: state.channelsLoading,
|
||||
snapshot: state.channelsSnapshot,
|
||||
lastError: state.channelsError,
|
||||
lastSuccessAt: state.channelsLastSuccess,
|
||||
whatsappMessage: state.whatsappLoginMessage,
|
||||
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
|
||||
whatsappConnected: state.whatsappLoginConnected,
|
||||
whatsappBusy: state.whatsappBusy,
|
||||
configSchema: state.configSchema,
|
||||
configSchemaLoading: state.configSchemaLoading,
|
||||
configForm: state.configForm,
|
||||
configUiHints: state.configUiHints,
|
||||
configSaving: state.configSaving,
|
||||
configFormDirty: state.configFormDirty,
|
||||
nostrProfileFormState: state.nostrProfileFormState,
|
||||
nostrProfileAccountId: state.nostrProfileAccountId,
|
||||
onRefresh: (probe) => loadChannels(state, probe),
|
||||
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
|
||||
onWhatsAppWait: () => state.handleWhatsAppWait(),
|
||||
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
|
||||
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||
onConfigSave: () => state.handleChannelConfigSave(),
|
||||
onConfigReload: () => state.handleChannelConfigReload(),
|
||||
onNostrProfileEdit: (accountId, profile) =>
|
||||
state.handleNostrProfileEdit(accountId, profile),
|
||||
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
|
||||
onNostrProfileFieldChange: (field, value) =>
|
||||
state.handleNostrProfileFieldChange(field, value),
|
||||
onNostrProfileSave: () => state.handleNostrProfileSave(),
|
||||
onNostrProfileImport: () => state.handleNostrProfileImport(),
|
||||
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "instances"
|
||||
? renderInstances({
|
||||
loading: state.presenceLoading,
|
||||
entries: state.presenceEntries,
|
||||
lastError: state.presenceError,
|
||||
statusMessage: state.presenceStatus,
|
||||
onRefresh: () => loadPresence(state),
|
||||
})
|
||||
: nothing}
|
||||
? renderInstances({
|
||||
loading: state.presenceLoading,
|
||||
entries: state.presenceEntries,
|
||||
lastError: state.presenceError,
|
||||
statusMessage: state.presenceStatus,
|
||||
onRefresh: () => loadPresence(state),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "sessions"
|
||||
? renderSessions({
|
||||
loading: state.sessionsLoading,
|
||||
result: state.sessionsResult,
|
||||
error: state.sessionsError,
|
||||
activeMinutes: state.sessionsFilterActive,
|
||||
limit: state.sessionsFilterLimit,
|
||||
includeGlobal: state.sessionsIncludeGlobal,
|
||||
includeUnknown: state.sessionsIncludeUnknown,
|
||||
basePath: state.basePath,
|
||||
onFiltersChange: (next) => {
|
||||
state.sessionsFilterActive = next.activeMinutes;
|
||||
state.sessionsFilterLimit = next.limit;
|
||||
state.sessionsIncludeGlobal = next.includeGlobal;
|
||||
state.sessionsIncludeUnknown = next.includeUnknown;
|
||||
},
|
||||
onRefresh: () => loadSessions(state),
|
||||
onPatch: (key, patch) => patchSession(state, key, patch),
|
||||
onDelete: (key) => deleteSession(state, key),
|
||||
})
|
||||
: nothing}
|
||||
? renderSessions({
|
||||
loading: state.sessionsLoading,
|
||||
result: state.sessionsResult,
|
||||
error: state.sessionsError,
|
||||
activeMinutes: state.sessionsFilterActive,
|
||||
limit: state.sessionsFilterLimit,
|
||||
includeGlobal: state.sessionsIncludeGlobal,
|
||||
includeUnknown: state.sessionsIncludeUnknown,
|
||||
basePath: state.basePath,
|
||||
onFiltersChange: (next) => {
|
||||
state.sessionsFilterActive = next.activeMinutes;
|
||||
state.sessionsFilterLimit = next.limit;
|
||||
state.sessionsIncludeGlobal = next.includeGlobal;
|
||||
state.sessionsIncludeUnknown = next.includeUnknown;
|
||||
},
|
||||
onRefresh: () => loadSessions(state),
|
||||
onPatch: (key, patch) => patchSession(state, key, patch),
|
||||
onDelete: (key) => deleteSession(state, key),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "cron"
|
||||
? renderCron({
|
||||
loading: state.cronLoading,
|
||||
status: state.cronStatus,
|
||||
jobs: state.cronJobs,
|
||||
error: state.cronError,
|
||||
busy: state.cronBusy,
|
||||
form: state.cronForm,
|
||||
channels: state.channelsSnapshot?.channelMeta?.length
|
||||
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
|
||||
: state.channelsSnapshot?.channelOrder ?? [],
|
||||
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
|
||||
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
|
||||
runsJobId: state.cronRunsJobId,
|
||||
runs: state.cronRuns,
|
||||
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
|
||||
onRefresh: () => state.loadCron(),
|
||||
onAdd: () => addCronJob(state),
|
||||
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
|
||||
onRun: (job) => runCronJob(state, job),
|
||||
onRemove: (job) => removeCronJob(state, job),
|
||||
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
|
||||
})
|
||||
: nothing}
|
||||
? renderCron({
|
||||
loading: state.cronLoading,
|
||||
status: state.cronStatus,
|
||||
jobs: state.cronJobs,
|
||||
error: state.cronError,
|
||||
busy: state.cronBusy,
|
||||
form: state.cronForm,
|
||||
channels: state.channelsSnapshot?.channelMeta?.length
|
||||
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
|
||||
: state.channelsSnapshot?.channelOrder ?? [],
|
||||
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
|
||||
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
|
||||
runsJobId: state.cronRunsJobId,
|
||||
runs: state.cronRuns,
|
||||
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
|
||||
onRefresh: () => state.loadCron(),
|
||||
onAdd: () => addCronJob(state),
|
||||
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
|
||||
onRun: (job) => runCronJob(state, job),
|
||||
onRemove: (job) => removeCronJob(state, job),
|
||||
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "skills"
|
||||
? renderSkills({
|
||||
loading: state.skillsLoading,
|
||||
report: state.skillsReport,
|
||||
error: state.skillsError,
|
||||
filter: state.skillsFilter,
|
||||
edits: state.skillEdits,
|
||||
messages: state.skillMessages,
|
||||
busyKey: state.skillsBusyKey,
|
||||
onFilterChange: (next) => (state.skillsFilter = next),
|
||||
onRefresh: () => loadSkills(state, { clearMessages: true }),
|
||||
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
|
||||
onEdit: (key, value) => updateSkillEdit(state, key, value),
|
||||
onSaveKey: (key) => saveSkillApiKey(state, key),
|
||||
onInstall: (skillKey, name, installId) =>
|
||||
installSkill(state, skillKey, name, installId),
|
||||
})
|
||||
: nothing}
|
||||
? renderSkills({
|
||||
loading: state.skillsLoading,
|
||||
report: state.skillsReport,
|
||||
error: state.skillsError,
|
||||
filter: state.skillsFilter,
|
||||
edits: state.skillEdits,
|
||||
messages: state.skillMessages,
|
||||
busyKey: state.skillsBusyKey,
|
||||
onFilterChange: (next) => (state.skillsFilter = next),
|
||||
onRefresh: () => loadSkills(state, { clearMessages: true }),
|
||||
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
|
||||
onEdit: (key, value) => updateSkillEdit(state, key, value),
|
||||
onSaveKey: (key) => saveSkillApiKey(state, key),
|
||||
onInstall: (skillKey, name, installId) =>
|
||||
installSkill(state, skillKey, name, installId),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "nodes"
|
||||
? renderNodes({
|
||||
loading: state.nodesLoading,
|
||||
nodes: state.nodes,
|
||||
devicesLoading: state.devicesLoading,
|
||||
devicesError: state.devicesError,
|
||||
devicesList: state.devicesList,
|
||||
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
|
||||
configLoading: state.configLoading,
|
||||
configSaving: state.configSaving,
|
||||
configDirty: state.configFormDirty,
|
||||
configFormMode: state.configFormMode,
|
||||
execApprovalsLoading: state.execApprovalsLoading,
|
||||
execApprovalsSaving: state.execApprovalsSaving,
|
||||
execApprovalsDirty: state.execApprovalsDirty,
|
||||
execApprovalsSnapshot: state.execApprovalsSnapshot,
|
||||
execApprovalsForm: state.execApprovalsForm,
|
||||
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
|
||||
execApprovalsTarget: state.execApprovalsTarget,
|
||||
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
|
||||
onRefresh: () => loadNodes(state),
|
||||
onDevicesRefresh: () => loadDevices(state),
|
||||
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
|
||||
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
|
||||
onDeviceRotate: (deviceId, role, scopes) =>
|
||||
rotateDeviceToken(state, { deviceId, role, scopes }),
|
||||
onDeviceRevoke: (deviceId, role) =>
|
||||
revokeDeviceToken(state, { deviceId, role }),
|
||||
onLoadConfig: () => loadConfig(state),
|
||||
onLoadExecApprovals: () => {
|
||||
const target =
|
||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
||||
: { kind: "gateway" as const };
|
||||
return loadExecApprovals(state, target);
|
||||
},
|
||||
onBindDefault: (nodeId) => {
|
||||
if (nodeId) {
|
||||
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
||||
} else {
|
||||
removeConfigFormValue(state, ["tools", "exec", "node"]);
|
||||
}
|
||||
},
|
||||
onBindAgent: (agentIndex, nodeId) => {
|
||||
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
|
||||
if (nodeId) {
|
||||
updateConfigFormValue(state, basePath, nodeId);
|
||||
} else {
|
||||
removeConfigFormValue(state, basePath);
|
||||
}
|
||||
},
|
||||
onSaveBindings: () => saveConfig(state),
|
||||
onExecApprovalsTargetChange: (kind, nodeId) => {
|
||||
state.execApprovalsTarget = kind;
|
||||
state.execApprovalsTargetNodeId = nodeId;
|
||||
state.execApprovalsSnapshot = null;
|
||||
state.execApprovalsForm = null;
|
||||
state.execApprovalsDirty = false;
|
||||
state.execApprovalsSelectedAgent = null;
|
||||
},
|
||||
onExecApprovalsSelectAgent: (agentId) => {
|
||||
state.execApprovalsSelectedAgent = agentId;
|
||||
},
|
||||
onExecApprovalsPatch: (path, value) =>
|
||||
updateExecApprovalsFormValue(state, path, value),
|
||||
onExecApprovalsRemove: (path) =>
|
||||
removeExecApprovalsFormValue(state, path),
|
||||
onSaveExecApprovals: () => {
|
||||
const target =
|
||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
||||
: { kind: "gateway" as const };
|
||||
return saveExecApprovals(state, target);
|
||||
},
|
||||
})
|
||||
: nothing}
|
||||
? renderNodes({
|
||||
loading: state.nodesLoading,
|
||||
nodes: state.nodes,
|
||||
devicesLoading: state.devicesLoading,
|
||||
devicesError: state.devicesError,
|
||||
devicesList: state.devicesList,
|
||||
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
|
||||
configLoading: state.configLoading,
|
||||
configSaving: state.configSaving,
|
||||
configDirty: state.configFormDirty,
|
||||
configFormMode: state.configFormMode,
|
||||
execApprovalsLoading: state.execApprovalsLoading,
|
||||
execApprovalsSaving: state.execApprovalsSaving,
|
||||
execApprovalsDirty: state.execApprovalsDirty,
|
||||
execApprovalsSnapshot: state.execApprovalsSnapshot,
|
||||
execApprovalsForm: state.execApprovalsForm,
|
||||
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
|
||||
execApprovalsTarget: state.execApprovalsTarget,
|
||||
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
|
||||
onRefresh: () => loadNodes(state),
|
||||
onDevicesRefresh: () => loadDevices(state),
|
||||
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
|
||||
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
|
||||
onDeviceRotate: (deviceId, role, scopes) =>
|
||||
rotateDeviceToken(state, { deviceId, role, scopes }),
|
||||
onDeviceRevoke: (deviceId, role) =>
|
||||
revokeDeviceToken(state, { deviceId, role }),
|
||||
onLoadConfig: () => loadConfig(state),
|
||||
onLoadExecApprovals: () => {
|
||||
const target =
|
||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
||||
: { kind: "gateway" as const };
|
||||
return loadExecApprovals(state, target);
|
||||
},
|
||||
onBindDefault: (nodeId) => {
|
||||
if (nodeId) {
|
||||
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
||||
} else {
|
||||
removeConfigFormValue(state, ["tools", "exec", "node"]);
|
||||
}
|
||||
},
|
||||
onBindAgent: (agentIndex, nodeId) => {
|
||||
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
|
||||
if (nodeId) {
|
||||
updateConfigFormValue(state, basePath, nodeId);
|
||||
} else {
|
||||
removeConfigFormValue(state, basePath);
|
||||
}
|
||||
},
|
||||
onSaveBindings: () => saveConfig(state),
|
||||
onExecApprovalsTargetChange: (kind, nodeId) => {
|
||||
state.execApprovalsTarget = kind;
|
||||
state.execApprovalsTargetNodeId = nodeId;
|
||||
state.execApprovalsSnapshot = null;
|
||||
state.execApprovalsForm = null;
|
||||
state.execApprovalsDirty = false;
|
||||
state.execApprovalsSelectedAgent = null;
|
||||
},
|
||||
onExecApprovalsSelectAgent: (agentId) => {
|
||||
state.execApprovalsSelectedAgent = agentId;
|
||||
},
|
||||
onExecApprovalsPatch: (path, value) =>
|
||||
updateExecApprovalsFormValue(state, path, value),
|
||||
onExecApprovalsRemove: (path) =>
|
||||
removeExecApprovalsFormValue(state, path),
|
||||
onSaveExecApprovals: () => {
|
||||
const target =
|
||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
||||
: { kind: "gateway" as const };
|
||||
return saveExecApprovals(state, target);
|
||||
},
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "chat"
|
||||
? renderChat({
|
||||
sessionKey: state.sessionKey,
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.chatAttachments = [];
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
state.chatQueue = [];
|
||||
state.resetToolStream();
|
||||
state.resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
void loadChatHistory(state);
|
||||
void refreshChatAvatar(state);
|
||||
},
|
||||
thinkingLevel: state.chatThinkingLevel,
|
||||
showThinking,
|
||||
loading: state.chatLoading,
|
||||
sending: state.chatSending,
|
||||
compactionStatus: state.compactionStatus,
|
||||
assistantAvatarUrl: chatAvatarUrl,
|
||||
messages: state.chatMessages,
|
||||
toolMessages: state.chatToolMessages,
|
||||
stream: state.chatStream,
|
||||
streamStartedAt: state.chatStreamStartedAt,
|
||||
draft: state.chatMessage,
|
||||
queue: state.chatQueue,
|
||||
connected: state.connected,
|
||||
canSend: state.connected,
|
||||
disabledReason: chatDisabledReason,
|
||||
error: state.lastError,
|
||||
sessions: state.sessionsResult,
|
||||
focusMode: chatFocus,
|
||||
onRefresh: () => {
|
||||
state.resetToolStream();
|
||||
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
||||
},
|
||||
onToggleFocusMode: () => {
|
||||
if (state.onboarding) return;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
chatFocusMode: !state.settings.chatFocusMode,
|
||||
});
|
||||
},
|
||||
onChatScroll: (event) => state.handleChatScroll(event),
|
||||
onDraftChange: (next) => (state.chatMessage = next),
|
||||
attachments: state.chatAttachments,
|
||||
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||
onSend: () => state.handleSendChat(),
|
||||
canAbort: Boolean(state.chatRunId),
|
||||
onAbort: () => void state.handleAbortChat(),
|
||||
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||
onNewSession: () =>
|
||||
state.handleSendChat("/new", { restoreDraft: true }),
|
||||
// Sidebar props for tool output viewing
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
sidebarContent: state.sidebarContent,
|
||||
sidebarError: state.sidebarError,
|
||||
splitRatio: state.splitRatio,
|
||||
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
|
||||
onCloseSidebar: () => state.handleCloseSidebar(),
|
||||
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||
assistantName: state.assistantName,
|
||||
assistantAvatar: state.assistantAvatar,
|
||||
})
|
||||
: nothing}
|
||||
? renderChat({
|
||||
sessionKey: state.sessionKey,
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.chatAttachments = [];
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
state.chatQueue = [];
|
||||
state.resetToolStream();
|
||||
state.resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
void loadChatHistory(state);
|
||||
void refreshChatAvatar(state);
|
||||
},
|
||||
thinkingLevel: state.chatThinkingLevel,
|
||||
showThinking,
|
||||
loading: state.chatLoading,
|
||||
sending: state.chatSending,
|
||||
compactionStatus: state.compactionStatus,
|
||||
assistantAvatarUrl: chatAvatarUrl,
|
||||
messages: state.chatMessages,
|
||||
toolMessages: state.chatToolMessages,
|
||||
stream: state.chatStream,
|
||||
streamStartedAt: state.chatStreamStartedAt,
|
||||
draft: state.chatMessage,
|
||||
queue: state.chatQueue,
|
||||
connected: state.connected,
|
||||
canSend: state.connected,
|
||||
disabledReason: chatDisabledReason,
|
||||
error: state.lastError,
|
||||
sessions: state.sessionsResult,
|
||||
focusMode: chatFocus,
|
||||
onRefresh: () => {
|
||||
state.resetToolStream();
|
||||
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
||||
},
|
||||
onToggleFocusMode: () => {
|
||||
if (state.onboarding) return;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
chatFocusMode: !state.settings.chatFocusMode,
|
||||
});
|
||||
},
|
||||
onChatScroll: (event) => state.handleChatScroll(event),
|
||||
onDraftChange: (next) => (state.chatMessage = next),
|
||||
attachments: state.chatAttachments,
|
||||
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||
onSend: () => state.handleSendChat(),
|
||||
canAbort: Boolean(state.chatRunId),
|
||||
onAbort: () => void state.handleAbortChat(),
|
||||
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||
onNewSession: () =>
|
||||
state.handleSendChat("/new", { restoreDraft: true }),
|
||||
// Sidebar props for tool output viewing
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
sidebarContent: state.sidebarContent,
|
||||
sidebarError: state.sidebarError,
|
||||
splitRatio: state.splitRatio,
|
||||
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
|
||||
onCloseSidebar: () => state.handleCloseSidebar(),
|
||||
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||
assistantName: state.assistantName,
|
||||
assistantAvatar: state.assistantAvatar,
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "config"
|
||||
? renderConfig({
|
||||
raw: state.configRaw,
|
||||
originalRaw: state.configRawOriginal,
|
||||
valid: state.configValid,
|
||||
issues: state.configIssues,
|
||||
loading: state.configLoading,
|
||||
saving: state.configSaving,
|
||||
applying: state.configApplying,
|
||||
updating: state.updateRunning,
|
||||
connected: state.connected,
|
||||
schema: state.configSchema,
|
||||
schemaLoading: state.configSchemaLoading,
|
||||
uiHints: state.configUiHints,
|
||||
formMode: state.configFormMode,
|
||||
formValue: state.configForm,
|
||||
originalValue: state.configFormOriginal,
|
||||
searchQuery: state.configSearchQuery,
|
||||
activeSection: state.configActiveSection,
|
||||
activeSubsection: state.configActiveSubsection,
|
||||
onRawChange: (next) => {
|
||||
state.configRaw = next;
|
||||
},
|
||||
onFormModeChange: (mode) => (state.configFormMode = mode),
|
||||
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||
onSearchChange: (query) => (state.configSearchQuery = query),
|
||||
onSectionChange: (section) => {
|
||||
state.configActiveSection = section;
|
||||
state.configActiveSubsection = null;
|
||||
},
|
||||
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
||||
onReload: () => loadConfig(state),
|
||||
onSave: () => saveConfig(state),
|
||||
onApply: () => applyConfig(state),
|
||||
onUpdate: () => runUpdate(state),
|
||||
})
|
||||
: nothing}
|
||||
? renderConfig({
|
||||
raw: state.configRaw,
|
||||
originalRaw: state.configRawOriginal,
|
||||
valid: state.configValid,
|
||||
issues: state.configIssues,
|
||||
loading: state.configLoading,
|
||||
saving: state.configSaving,
|
||||
applying: state.configApplying,
|
||||
updating: state.updateRunning,
|
||||
connected: state.connected,
|
||||
schema: state.configSchema,
|
||||
schemaLoading: state.configSchemaLoading,
|
||||
uiHints: state.configUiHints,
|
||||
formMode: state.configFormMode,
|
||||
formValue: state.configForm,
|
||||
originalValue: state.configFormOriginal,
|
||||
searchQuery: state.configSearchQuery,
|
||||
activeSection: state.configActiveSection,
|
||||
activeSubsection: state.configActiveSubsection,
|
||||
onRawChange: (next) => {
|
||||
state.configRaw = next;
|
||||
},
|
||||
onFormModeChange: (mode) => (state.configFormMode = mode),
|
||||
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||
onSearchChange: (query) => (state.configSearchQuery = query),
|
||||
onSectionChange: (section) => {
|
||||
state.configActiveSection = section;
|
||||
state.configActiveSubsection = null;
|
||||
},
|
||||
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
||||
onReload: () => loadConfig(state),
|
||||
onSave: () => saveConfig(state),
|
||||
onApply: () => applyConfig(state),
|
||||
onUpdate: () => runUpdate(state),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "debug"
|
||||
? renderDebug({
|
||||
loading: state.debugLoading,
|
||||
status: state.debugStatus,
|
||||
health: state.debugHealth,
|
||||
models: state.debugModels,
|
||||
heartbeat: state.debugHeartbeat,
|
||||
eventLog: state.eventLog,
|
||||
callMethod: state.debugCallMethod,
|
||||
callParams: state.debugCallParams,
|
||||
callResult: state.debugCallResult,
|
||||
callError: state.debugCallError,
|
||||
onCallMethodChange: (next) => (state.debugCallMethod = next),
|
||||
onCallParamsChange: (next) => (state.debugCallParams = next),
|
||||
onRefresh: () => loadDebug(state),
|
||||
onCall: () => callDebugMethod(state),
|
||||
})
|
||||
: nothing}
|
||||
? renderDebug({
|
||||
loading: state.debugLoading,
|
||||
status: state.debugStatus,
|
||||
health: state.debugHealth,
|
||||
models: state.debugModels,
|
||||
heartbeat: state.debugHeartbeat,
|
||||
eventLog: state.eventLog,
|
||||
callMethod: state.debugCallMethod,
|
||||
callParams: state.debugCallParams,
|
||||
callResult: state.debugCallResult,
|
||||
callError: state.debugCallError,
|
||||
onCallMethodChange: (next) => (state.debugCallMethod = next),
|
||||
onCallParamsChange: (next) => (state.debugCallParams = next),
|
||||
onRefresh: () => loadDebug(state),
|
||||
onCall: () => callDebugMethod(state),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "logs"
|
||||
? renderLogs({
|
||||
loading: state.logsLoading,
|
||||
error: state.logsError,
|
||||
file: state.logsFile,
|
||||
entries: state.logsEntries,
|
||||
filterText: state.logsFilterText,
|
||||
levelFilters: state.logsLevelFilters,
|
||||
autoFollow: state.logsAutoFollow,
|
||||
truncated: state.logsTruncated,
|
||||
onFilterTextChange: (next) => (state.logsFilterText = next),
|
||||
onLevelToggle: (level, enabled) => {
|
||||
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
|
||||
},
|
||||
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
|
||||
onRefresh: () => loadLogs(state, { reset: true }),
|
||||
onExport: (lines, label) => state.exportLogs(lines, label),
|
||||
onScroll: (event) => state.handleLogsScroll(event),
|
||||
})
|
||||
: nothing}
|
||||
? renderLogs({
|
||||
loading: state.logsLoading,
|
||||
error: state.logsError,
|
||||
file: state.logsFile,
|
||||
entries: state.logsEntries,
|
||||
filterText: state.logsFilterText,
|
||||
levelFilters: state.logsLevelFilters,
|
||||
autoFollow: state.logsAutoFollow,
|
||||
truncated: state.logsTruncated,
|
||||
onFilterTextChange: (next) => (state.logsFilterText = next),
|
||||
onLevelToggle: (level, enabled) => {
|
||||
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
|
||||
},
|
||||
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
|
||||
onRefresh: () => loadLogs(state, { reset: true }),
|
||||
onExport: (lines, label) => state.exportLogs(lines, label),
|
||||
onScroll: (event) => state.handleLogsScroll(event),
|
||||
})
|
||||
: nothing}
|
||||
</main>
|
||||
${renderExecApprovalPrompt(state)}
|
||||
</div>
|
||||
|
||||
@ -78,6 +78,7 @@ import {
|
||||
} from "./app-channels";
|
||||
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
|
||||
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity";
|
||||
import { i18n, en, ptBR, I18nController } from "../i18n";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -111,6 +112,7 @@ export class MoltbotApp extends LitElement {
|
||||
private eventLogBuffer: EventLogEntry[] = [];
|
||||
private toolStreamSyncTimer: number | null = null;
|
||||
private sidebarCloseTimer: number | null = null;
|
||||
private i18n = new I18nController(this);
|
||||
|
||||
@state() assistantName = injectedAssistantIdentity.name;
|
||||
@state() assistantAvatar = injectedAssistantIdentity.avatar;
|
||||
@ -272,6 +274,9 @@ export class MoltbotApp extends LitElement {
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
@ -332,6 +337,9 @@ export class MoltbotApp extends LitElement {
|
||||
}
|
||||
|
||||
applySettings(next: UiSettings) {
|
||||
if (next.locale !== this.settings.locale) {
|
||||
i18n.setLocale(next.locale);
|
||||
}
|
||||
applySettingsInternal(
|
||||
this as unknown as Parameters<typeof applySettingsInternal>[0],
|
||||
next,
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import type { IconName } from "./icons.js";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export const TAB_GROUPS = [
|
||||
{ label: "Chat", tabs: ["chat"] },
|
||||
{ label: "chat", tabs: ["chat"] },
|
||||
{
|
||||
label: "Control",
|
||||
label: "control",
|
||||
tabs: ["overview", "channels", "instances", "sessions", "cron"],
|
||||
},
|
||||
{ label: "Agent", tabs: ["skills", "nodes"] },
|
||||
{ label: "Settings", tabs: ["config", "debug", "logs"] },
|
||||
{ label: "agent", tabs: ["skills", "nodes"] },
|
||||
{ label: "settings", tabs: ["config", "debug", "logs"] },
|
||||
] as const;
|
||||
|
||||
export type Tab =
|
||||
@ -132,56 +133,56 @@ export function iconForTab(tab: Tab): IconName {
|
||||
export function titleForTab(tab: Tab) {
|
||||
switch (tab) {
|
||||
case "overview":
|
||||
return "Overview";
|
||||
return t("tab.title.overview");
|
||||
case "channels":
|
||||
return "Channels";
|
||||
return t("tab.title.channels");
|
||||
case "instances":
|
||||
return "Instances";
|
||||
return t("tab.title.instances");
|
||||
case "sessions":
|
||||
return "Sessions";
|
||||
return t("tab.title.sessions");
|
||||
case "cron":
|
||||
return "Cron Jobs";
|
||||
return t("tab.title.cron");
|
||||
case "skills":
|
||||
return "Skills";
|
||||
return t("tab.title.skills");
|
||||
case "nodes":
|
||||
return "Nodes";
|
||||
return t("tab.title.nodes");
|
||||
case "chat":
|
||||
return "Chat";
|
||||
return t("tab.title.chat");
|
||||
case "config":
|
||||
return "Config";
|
||||
return t("tab.title.config");
|
||||
case "debug":
|
||||
return "Debug";
|
||||
return t("tab.title.debug");
|
||||
case "logs":
|
||||
return "Logs";
|
||||
return t("tab.title.logs");
|
||||
default:
|
||||
return "Control";
|
||||
return t("tab.title.control");
|
||||
}
|
||||
}
|
||||
|
||||
export function subtitleForTab(tab: Tab) {
|
||||
switch (tab) {
|
||||
case "overview":
|
||||
return "Gateway status, entry points, and a fast health read.";
|
||||
return t("tab.subtitle.overview");
|
||||
case "channels":
|
||||
return "Manage channels and settings.";
|
||||
return t("tab.subtitle.channels");
|
||||
case "instances":
|
||||
return "Presence beacons from connected clients and nodes.";
|
||||
return t("tab.subtitle.instances");
|
||||
case "sessions":
|
||||
return "Inspect active sessions and adjust per-session defaults.";
|
||||
return t("tab.subtitle.sessions");
|
||||
case "cron":
|
||||
return "Schedule wakeups and recurring agent runs.";
|
||||
return t("tab.subtitle.cron");
|
||||
case "skills":
|
||||
return "Manage skill availability and API key injection.";
|
||||
return t("tab.subtitle.skills");
|
||||
case "nodes":
|
||||
return "Paired devices, capabilities, and command exposure.";
|
||||
return t("tab.subtitle.nodes");
|
||||
case "chat":
|
||||
return "Direct gateway chat session for quick interventions.";
|
||||
return t("tab.subtitle.chat");
|
||||
case "config":
|
||||
return "Edit ~/.clawdbot/moltbot.json safely.";
|
||||
return t("tab.subtitle.config");
|
||||
case "debug":
|
||||
return "Gateway snapshots, events, and manual RPC calls.";
|
||||
return t("tab.subtitle.debug");
|
||||
case "logs":
|
||||
return "Live tail of the gateway file logs.";
|
||||
return t("tab.subtitle.logs");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ export type UiSettings = {
|
||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
||||
navCollapsed: boolean; // Collapsible sidebar state
|
||||
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export function loadSettings(): UiSettings {
|
||||
@ -32,6 +33,7 @@ export function loadSettings(): UiSettings {
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
locale: "en",
|
||||
};
|
||||
|
||||
try {
|
||||
@ -50,15 +52,15 @@ export function loadSettings(): UiSettings {
|
||||
: defaults.sessionKey,
|
||||
lastActiveSessionKey:
|
||||
typeof parsed.lastActiveSessionKey === "string" &&
|
||||
parsed.lastActiveSessionKey.trim()
|
||||
parsed.lastActiveSessionKey.trim()
|
||||
? parsed.lastActiveSessionKey.trim()
|
||||
: (typeof parsed.sessionKey === "string" &&
|
||||
parsed.sessionKey.trim()) ||
|
||||
defaults.lastActiveSessionKey,
|
||||
parsed.sessionKey.trim()) ||
|
||||
defaults.lastActiveSessionKey,
|
||||
theme:
|
||||
parsed.theme === "light" ||
|
||||
parsed.theme === "dark" ||
|
||||
parsed.theme === "system"
|
||||
parsed.theme === "dark" ||
|
||||
parsed.theme === "system"
|
||||
? parsed.theme
|
||||
: defaults.theme,
|
||||
chatFocusMode:
|
||||
@ -71,8 +73,8 @@ export function loadSettings(): UiSettings {
|
||||
: defaults.chatShowThinking,
|
||||
splitRatio:
|
||||
typeof parsed.splitRatio === "number" &&
|
||||
parsed.splitRatio >= 0.4 &&
|
||||
parsed.splitRatio <= 0.7
|
||||
parsed.splitRatio >= 0.4 &&
|
||||
parsed.splitRatio <= 0.7
|
||||
? parsed.splitRatio
|
||||
: defaults.splitRatio,
|
||||
navCollapsed:
|
||||
@ -81,9 +83,10 @@ export function loadSettings(): UiSettings {
|
||||
: defaults.navCollapsed,
|
||||
navGroupsCollapsed:
|
||||
typeof parsed.navGroupsCollapsed === "object" &&
|
||||
parsed.navGroupsCollapsed !== null
|
||||
parsed.navGroupsCollapsed !== null
|
||||
? parsed.navGroupsCollapsed
|
||||
: defaults.navGroupsCollapsed,
|
||||
locale: typeof parsed.locale === "string" ? parsed.locale : defaults.locale,
|
||||
};
|
||||
} catch {
|
||||
return defaults;
|
||||
|
||||
@ -127,9 +127,9 @@ export function renderOverview(props: OverviewProps) {
|
||||
<input
|
||||
.value=${props.settings.gatewayUrl}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onSettingsChange({ ...props.settings, gatewayUrl: v });
|
||||
}}
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onSettingsChange({ ...props.settings, gatewayUrl: v });
|
||||
}}
|
||||
placeholder="ws://100.x.y.z:18789"
|
||||
/>
|
||||
</label>
|
||||
@ -138,9 +138,9 @@ export function renderOverview(props: OverviewProps) {
|
||||
<input
|
||||
.value=${props.settings.token}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onSettingsChange({ ...props.settings, token: v });
|
||||
}}
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onSettingsChange({ ...props.settings, token: v });
|
||||
}}
|
||||
placeholder="CLAWDBOT_GATEWAY_TOKEN"
|
||||
/>
|
||||
</label>
|
||||
@ -150,22 +150,35 @@ export function renderOverview(props: OverviewProps) {
|
||||
type="password"
|
||||
.value=${props.password}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onPasswordChange(v);
|
||||
}}
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onPasswordChange(v);
|
||||
}}
|
||||
placeholder="system or shared password"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Default Session Key</span>
|
||||
<input
|
||||
.value=${props.settings.sessionKey}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onSessionKeyChange(v);
|
||||
}}
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onSessionKeyChange(v);
|
||||
}}
|
||||
/>
|
||||
</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 class="row" style="margin-top: 14px;">
|
||||
<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-value">
|
||||
${props.lastChannelsRefresh
|
||||
? formatAgo(props.lastChannelsRefresh)
|
||||
: "n/a"}
|
||||
? formatAgo(props.lastChannelsRefresh)
|
||||
: "n/a"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${props.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 14px;">
|
||||
? html`<div class="callout danger" style="margin-top: 14px;">
|
||||
<div>${props.lastError}</div>
|
||||
${authHint ?? ""}
|
||||
${insecureContextHint ?? ""}
|
||||
</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.
|
||||
</div>`}
|
||||
</div>
|
||||
@ -228,10 +241,10 @@ export function renderOverview(props: OverviewProps) {
|
||||
<div class="stat-label">Cron</div>
|
||||
<div class="stat-value">
|
||||
${props.cronEnabled == null
|
||||
? "n/a"
|
||||
: props.cronEnabled
|
||||
? "Enabled"
|
||||
: "Disabled"}
|
||||
? "n/a"
|
||||
: props.cronEnabled
|
||||
? "Enabled"
|
||||
: "Disabled"}
|
||||
</div>
|
||||
<div class="muted">Next wake ${formatNextRun(props.cronNext)}</div>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user