Merge pull request #1 from A-AL-ANAZI/codex/enhance-application-with-arabic-gui-and-voice-support

Agents: تحسين اختيار الشِل واحترام PATH؛ واجهة: دعم العربية وميزة الصوت وRTL
This commit is contained in:
ABDULLAH 2026-01-30 10:28:33 +03:00 committed by GitHub
commit f3f0cbf1df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 828 additions and 126 deletions

View File

@ -876,7 +876,9 @@ export function createExecTool(
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
})
: mergedEnv;
if (!sandbox && host === "gateway" && !params.env?.PATH) {
const hasExplicitPath =
params.env && Object.prototype.hasOwnProperty.call(params.env, "PATH");
if (!sandbox && host === "gateway" && !hasExplicitPath) {
const shellPath = getShellPathFromLoginShell({
env: process.env,
timeoutMs: resolveShellEnvFallbackTimeoutMs(process.env),
@ -1398,6 +1400,9 @@ export function createExecTool(
timeoutSec: effectiveTimeout,
onUpdate,
});
if (allowBackground && backgroundRequested) {
markBackgrounded(run.session);
}
let yielded = false;
let yieldTimer: NodeJS.Timeout | null = null;

View File

@ -77,6 +77,11 @@ describe("getShellConfig", () => {
delete process.env.SHELL;
process.env.PATH = "";
const { shell } = getShellConfig();
expect(shell).toBe("sh");
const expected = fs.existsSync("/bin/sh")
? "/bin/sh"
: fs.existsSync("/usr/bin/sh")
? "/usr/bin/sh"
: "sh";
expect(shell).toBe(expected);
});
});

View File

@ -38,8 +38,14 @@ export function getShellConfig(): { shell: string; args: string[] } {
if (bash) return { shell: bash, args: ["-c"] };
const sh = resolveShellFromPath("sh");
if (sh) return { shell: sh, args: ["-c"] };
return { shell: envShell, args: ["-c"] };
}
const shell = envShell && envShell.length > 0 ? envShell : "sh";
if (envShell && envShell.length > 0) {
return { shell: envShell, args: ["-c"] };
}
const fallbackShell =
resolveShellFromPath("sh") ?? resolveShellFromCandidates(["/bin/sh", "/usr/bin/sh"]);
const shell = fallbackShell ?? "sh";
return { shell, args: ["-c"] };
}
@ -59,6 +65,18 @@ function resolveShellFromPath(name: string): string | undefined {
return undefined;
}
function resolveShellFromCandidates(candidates: string[]): string | undefined {
for (const candidate of candidates) {
try {
fs.accessSync(candidate, fs.constants.X_OK);
return candidate;
} catch {
// ignore missing or non-executable entries
}
}
return undefined;
}
export function sanitizeBinaryOutput(text: string): string {
const scrubbed = text.replace(/[\p{Format}\p{Surrogate}]/gu, "");
if (!scrubbed) return scrubbed;

View File

@ -1,4 +1,5 @@
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Tajawal:wght@400;500;700&display=swap");
:root {
/* Background - Warmer dark with depth */
@ -111,6 +112,11 @@
color-scheme: dark;
}
:root[lang="ar"] {
--font-body: "Tajawal", "Segoe UI", Tahoma, Arial, sans-serif;
--font-display: "Tajawal", "Segoe UI", Tahoma, Arial, sans-serif;
}
/* Light theme - Clean with subtle warmth */
:root[data-theme="light"] {
--bg: #fafafa;

View File

@ -313,6 +313,12 @@
background: rgba(255, 255, 255, 0.06);
}
.btn--icon.active {
color: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent-muted);
}
/* Controls separator */
.chat-controls__separator {
color: rgba(255, 255, 255, 0.4);
@ -338,6 +344,12 @@
color: var(--muted);
}
:root[data-theme="light"] .btn--icon.active {
color: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent-muted);
}
:root[data-theme="light"] .btn--icon:hover {
background: #ffffff;
border-color: var(--border-strong);
@ -395,3 +407,25 @@
min-width: 120px;
}
}
:root[dir="rtl"] .chat-focus-exit {
right: auto;
left: 12px;
}
:root[dir="rtl"] .chat-attachment__remove {
right: auto;
left: 4px;
}
:root[dir="rtl"] .chat-compose__actions {
flex-direction: row-reverse;
}
:root[dir="rtl"] .chat-compose__field textarea {
text-align: right;
}
:root[dir="rtl"] .chat-attachments {
align-self: flex-end;
}

View File

@ -22,6 +22,18 @@
overflow: hidden;
}
:root[dir="rtl"] .shell {
grid-template-columns: minmax(0, 1fr) var(--shell-nav-width);
grid-template-areas:
"topbar topbar"
"content nav";
}
:root[dir="rtl"] .shell--nav-collapsed,
:root[dir="rtl"] .shell--chat-focus {
grid-template-columns: minmax(0, 1fr) 0px;
}
@supports (height: 100dvh) {
.shell {
height: 100dvh;
@ -84,6 +96,18 @@
background: var(--bg);
}
:root[dir="rtl"] .topbar {
flex-direction: row-reverse;
}
:root[dir="rtl"] .topbar-left {
flex-direction: row-reverse;
}
:root[dir="rtl"] .brand-text {
text-align: right;
}
.topbar-left {
display: flex;
align-items: center;
@ -155,6 +179,48 @@
gap: 8px;
}
.language-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
}
.language-toggle__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
}
.language-toggle__icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
}
.language-toggle select {
border: none;
background: transparent;
color: inherit;
font-size: 12px;
font-family: var(--font-body);
outline: none;
cursor: pointer;
}
:root[dir="rtl"] .language-toggle {
flex-direction: row-reverse;
}
.topbar-status .pill {
padding: 6px 10px;
gap: 6px;
@ -316,6 +382,10 @@
background var(--duration-fast) ease;
}
:root[dir="rtl"] .nav-label {
text-align: right;
}
.nav-label:hover {
color: var(--text);
background: var(--bg-hover);
@ -364,6 +434,14 @@
color var(--duration-fast) ease;
}
:root[dir="rtl"] .nav-item {
justify-content: flex-end;
}
:root[dir="rtl"] .nav-item__text {
text-align: right;
}
.nav-item__icon {
width: 16px;
height: 16px;

View File

@ -80,6 +80,15 @@
flex-wrap: nowrap;
}
.language-toggle {
height: 28px;
padding: 0 6px;
}
.language-toggle select {
font-size: 11px;
}
.topbar-status .pill {
padding: 4px 8px;
font-size: 11px;

View File

@ -5,6 +5,7 @@ import {
attachThemeListener,
detachThemeListener,
inferBasePath,
syncLocaleWithSettings,
syncTabWithLocation,
syncThemeWithSettings,
} from "./app-settings";
@ -45,6 +46,9 @@ export function handleConnected(host: LifecycleHost) {
syncThemeWithSettings(
host as unknown as Parameters<typeof syncThemeWithSettings>[0],
);
syncLocaleWithSettings(
host as unknown as Parameters<typeof syncLocaleWithSettings>[0],
);
attachThemeListener(
host as unknown as Parameters<typeof attachThemeListener>[0],
);

View File

@ -10,8 +10,9 @@ import { syncUrlWithSessionKey } from "./app-settings";
import type { SessionsListResult } from "./types";
import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition";
import { t, type LanguageSetting, type Locale } from "./i18n";
export function renderTab(state: AppViewState, tab: Tab) {
export function renderTab(state: AppViewState, tab: Tab, locale: Locale) {
const href = pathForTab(tab, state.basePath);
return html`
<a
@ -31,15 +32,15 @@ export function renderTab(state: AppViewState, tab: Tab) {
event.preventDefault();
state.setTab(tab);
}}
title=${titleForTab(tab)}
title=${titleForTab(tab, locale)}
>
<span class="nav-item__icon" aria-hidden="true">${icons[iconForTab(tab)]}</span>
<span class="nav-item__text">${titleForTab(tab)}</span>
<span class="nav-item__text">${titleForTab(tab, locale)}</span>
</a>
`;
}
export function renderChatControls(state: AppViewState) {
export function renderChatControls(state: AppViewState, locale: Locale) {
const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult);
const sessionOptions = resolveSessionOptions(
state.sessionKey,
@ -95,7 +96,7 @@ export function renderChatControls(state: AppViewState) {
state.resetToolStream();
void refreshChat(state as unknown as Parameters<typeof refreshChat>[0]);
}}
title="Refresh chat data"
title=${t(locale, "chat.controls.refresh")}
>
${refreshIcon}
</button>
@ -112,8 +113,8 @@ export function renderChatControls(state: AppViewState) {
}}
aria-pressed=${showThinking}
title=${disableThinkingToggle
? "Disabled during onboarding"
: "Toggle assistant thinking/working output"}
? t(locale, "chat.controls.disabled")
: t(locale, "chat.controls.thinking")}
>
${icons.brain}
</button>
@ -129,8 +130,8 @@ export function renderChatControls(state: AppViewState) {
}}
aria-pressed=${focusActive}
title=${disableFocusToggle
? "Disabled during onboarding"
: "Toggle focus mode (hide sidebar + page header)"}
? t(locale, "chat.controls.disabled")
: t(locale, "chat.controls.focus")}
>
${focusIcon}
</button>
@ -209,14 +210,14 @@ export function renderThemeToggle(state: AppViewState) {
return html`
<div class="theme-toggle" style="--theme-index: ${index};">
<div class="theme-toggle__track" role="group" aria-label="Theme">
<div class="theme-toggle__track" role="group" aria-label=${t(state.locale, "theme.label")}>
<span class="theme-toggle__indicator"></span>
<button
class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
@click=${applyTheme("system")}
aria-pressed=${state.theme === "system"}
aria-label="System theme"
title="System"
aria-label=${t(state.locale, "theme.system")}
title=${t(state.locale, "theme.system")}
>
${renderMonitorIcon()}
</button>
@ -224,8 +225,8 @@ export function renderThemeToggle(state: AppViewState) {
class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
@click=${applyTheme("light")}
aria-pressed=${state.theme === "light"}
aria-label="Light theme"
title="Light"
aria-label=${t(state.locale, "theme.light")}
title=${t(state.locale, "theme.light")}
>
${renderSunIcon()}
</button>
@ -233,8 +234,8 @@ export function renderThemeToggle(state: AppViewState) {
class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
@click=${applyTheme("dark")}
aria-pressed=${state.theme === "dark"}
aria-label="Dark theme"
title="Dark"
aria-label=${t(state.locale, "theme.dark")}
title=${t(state.locale, "theme.dark")}
>
${renderMoonIcon()}
</button>
@ -243,6 +244,33 @@ export function renderThemeToggle(state: AppViewState) {
`;
}
const LANGUAGE_OPTIONS: Array<{ value: LanguageSetting; labelKey: string }> = [
{ value: "system", labelKey: "language.system" },
{ value: "en", labelKey: "language.en" },
{ value: "ar", labelKey: "language.ar" },
];
export function renderLanguageToggle(state: AppViewState) {
return html`
<label class="language-toggle" aria-label=${t(state.locale, "language.label")}>
<span class="language-toggle__icon">${icons.globe}</span>
<select
.value=${state.settings.language}
@change=${(event: Event) => {
const next = (event.target as HTMLSelectElement).value as LanguageSetting;
state.applySettings({ ...state.settings, language: next });
}}
aria-label=${t(state.locale, "language.label")}
>
${LANGUAGE_OPTIONS.map(
(option) =>
html`<option value=${option.value}>${t(state.locale, option.labelKey)}</option>`,
)}
</select>
</label>
`;
}
function renderSunIcon() {
return html`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">

View File

@ -3,14 +3,7 @@ import { html, nothing } from "lit";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import type { AppViewState } from "./app-view-state";
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
import {
TAB_GROUPS,
iconForTab,
pathForTab,
subtitleForTab,
titleForTab,
type Tab,
} from "./navigation";
import { TAB_GROUPS, subtitleForTab, titleForTab, type Tab } from "./navigation";
import { icons } from "./icons";
import type { UiSettings } from "./storage";
import type { ThemeMode } from "./theme";
@ -51,7 +44,12 @@ import {
rotateDeviceToken,
} from "./controllers/devices";
import { renderSkills } from "./views/skills";
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers";
import {
renderChatControls,
renderLanguageToggle,
renderTab,
renderThemeToggle,
} from "./app-render.helpers";
import { loadChannels } from "./controllers/channels";
import { loadPresence } from "./controllers/presence";
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions";
@ -82,6 +80,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;
@ -105,7 +104,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(state.locale, "chat.disabled");
const isChat = state.tab === "chat";
const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding);
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
@ -113,7 +112,11 @@ export function renderApp(state: AppViewState) {
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
return html`
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
<div
class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}"
dir=${state.dir}
lang=${state.locale}
>
<header class="topbar">
<div class="topbar-left">
<button
@ -123,8 +126,12 @@ export function renderApp(state: AppViewState) {
...state.settings,
navCollapsed: !state.settings.navCollapsed,
})}
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
title=${state.settings.navCollapsed
? t(state.locale, "nav.expand")
: t(state.locale, "nav.collapse")}
aria-label=${state.settings.navCollapsed
? t(state.locale, "nav.expand")
: t(state.locale, "nav.collapse")}
>
<span class="nav-collapse-toggle__icon">${icons.menu}</span>
</button>
@ -134,22 +141,27 @@ export function renderApp(state: AppViewState) {
</div>
<div class="brand-text">
<div class="brand-title">MOLTBOT</div>
<div class="brand-sub">Gateway Dashboard</div>
<div class="brand-sub">${t(state.locale, "app.brand.subtitle")}</div>
</div>
</div>
</div>
<div class="topbar-status">
<div class="pill">
<span class="statusDot ${state.connected ? "ok" : ""}"></span>
<span>Health</span>
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
<span>${t(state.locale, "app.health")}</span>
<span class="mono">
${state.connected
? t(state.locale, "app.status.ok")
: t(state.locale, "app.status.offline")}
</span>
</div>
${renderLanguageToggle(state)}
${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 isGroupCollapsed = state.settings.navGroupsCollapsed[group.id] ?? false;
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
return html`
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
@ -157,7 +169,7 @@ export function renderApp(state: AppViewState) {
class="nav-label"
@click=${() => {
const next = { ...state.settings.navGroupsCollapsed };
next[group.label] = !isGroupCollapsed;
next[group.id] = !isGroupCollapsed;
state.applySettings({
...state.settings,
navGroupsCollapsed: next,
@ -165,18 +177,18 @@ export function renderApp(state: AppViewState) {
}}
aria-expanded=${!isGroupCollapsed}
>
<span class="nav-label__text">${group.label}</span>
<span class="nav-label__text">${t(state.locale, group.labelKey)}</span>
<span class="nav-label__chevron">${isGroupCollapsed ? "+" : ""}</span>
</button>
<div class="nav-group__items">
${group.tabs.map((tab) => renderTab(state, tab))}
${group.tabs.map((tab) => renderTab(state, tab, state.locale))}
</div>
</div>
`;
})}
<div class="nav-group nav-group--links">
<div class="nav-label nav-label--static">
<span class="nav-label__text">Resources</span>
<span class="nav-label__text">${t(state.locale, "nav.resources")}</span>
</div>
<div class="nav-group__items">
<a
@ -184,10 +196,10 @@ export function renderApp(state: AppViewState) {
href="https://docs.molt.bot"
target="_blank"
rel="noreferrer"
title="Docs (opens in new tab)"
title=${t(state.locale, "nav.docs")}
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
<span class="nav-item__text">Docs</span>
<span class="nav-item__text">${t(state.locale, "nav.docs")}</span>
</a>
</div>
</div>
@ -195,14 +207,14 @@ export function renderApp(state: AppViewState) {
<main class="content ${isChat ? "content--chat" : ""}">
<section class="content-header">
<div>
<div class="page-title">${titleForTab(state.tab)}</div>
<div class="page-sub">${subtitleForTab(state.tab)}</div>
<div class="page-title">${titleForTab(state.tab, state.locale)}</div>
<div class="page-sub">${subtitleForTab(state.tab, state.locale)}</div>
</div>
<div class="page-meta">
${state.lastError
? html`<div class="pill danger">${state.lastError}</div>`
: nothing}
${isChat ? renderChatControls(state) : nothing}
${isChat ? renderChatControls(state, state.locale) : nothing}
</div>
</section>
@ -428,6 +440,7 @@ export function renderApp(state: AppViewState) {
${state.tab === "chat"
? renderChat({
locale: state.locale,
sessionKey: state.sessionKey,
onSessionKeyChange: (next) => {
state.sessionKey = next;
@ -497,6 +510,13 @@ export function renderApp(state: AppViewState) {
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar,
voiceSupported: state.voiceSupported,
voiceListening: state.voiceListening,
voiceSpeaking: state.voiceSpeaking,
voiceError: state.voiceError,
onToggleVoiceInput: () => state.toggleVoiceInput(),
onSpeakLastReply: () => state.speakLastReply(),
onStopSpeaking: () => state.stopSpeaking(),
})
: nothing}

View File

@ -20,7 +20,10 @@ const createHost = (tab: Tab): SettingsHost => ({
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
language: "system",
},
locale: "en",
dir: "ltr",
theme: "system",
themeResolved: "dark",
applySessionKey: "main",

View File

@ -12,6 +12,7 @@ import { loadSkills } from "./controllers/skills";
import { inferBasePathFromPathname, normalizeBasePath, normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation";
import { saveSettings, type UiSettings } from "./storage";
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
import { applyLocaleToDocument, isRtl, resolveLocale, type Locale } from "./i18n";
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling";
@ -20,6 +21,8 @@ import type { MoltbotApp } from "./app";
type SettingsHost = {
settings: UiSettings;
locale: Locale;
dir: "ltr" | "rtl";
theme: ThemeMode;
themeResolved: ResolvedTheme;
applySessionKey: string;
@ -37,6 +40,7 @@ type SettingsHost = {
};
export function applySettings(host: SettingsHost, next: UiSettings) {
const nextLocale = resolveLocale(next.language);
const normalized = {
...next,
lastActiveSessionKey: next.lastActiveSessionKey?.trim() || next.sessionKey.trim() || "main",
@ -47,6 +51,11 @@ export function applySettings(host: SettingsHost, next: UiSettings) {
host.theme = next.theme;
applyResolvedTheme(host, resolveTheme(next.theme));
}
if (host.locale !== nextLocale) {
host.locale = nextLocale;
host.dir = isRtl(nextLocale) ? "rtl" : "ltr";
applyLocaleToDocument(nextLocale);
}
host.applySessionKey = host.settings.lastActiveSessionKey;
}
@ -194,6 +203,12 @@ export function syncThemeWithSettings(host: SettingsHost) {
applyResolvedTheme(host, resolveTheme(host.theme));
}
export function syncLocaleWithSettings(host: SettingsHost) {
host.locale = resolveLocale(host.settings.language);
host.dir = isRtl(host.locale) ? "rtl" : "ltr";
applyLocaleToDocument(host.locale);
}
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
host.themeResolved = resolved;
if (typeof document === "undefined") return;

View File

@ -3,6 +3,7 @@ import type { Tab } from "./navigation";
import type { UiSettings } from "./storage";
import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition";
import type { Locale } from "./i18n";
import type {
AgentsListResult,
ChannelsStatusSnapshot,
@ -32,6 +33,8 @@ import type { NostrProfileFormState } from "./views/channels.nostr-profile-form"
export type AppViewState = {
settings: UiSettings;
locale: Locale;
dir: "ltr" | "rtl";
password: string;
tab: Tab;
onboarding: boolean;
@ -57,6 +60,10 @@ export type AppViewState = {
chatAvatarUrl: string | null;
chatThinkingLevel: string | null;
chatQueue: ChatQueueItem[];
voiceSupported: boolean;
voiceListening: boolean;
voiceSpeaking: boolean;
voiceError: string | null;
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
devicesLoading: boolean;
@ -151,6 +158,9 @@ export type AppViewState = {
setTab: (tab: Tab) => void;
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
applySettings: (next: UiSettings) => void;
toggleVoiceInput: () => void;
speakLastReply: () => void;
stopSpeaking: () => void;
loadOverview: () => Promise<void>;
loadAssistantIdentity: () => Promise<void>;
loadCron: () => Promise<void>;

View File

@ -7,6 +7,7 @@ import { loadSettings, type UiSettings } from "./storage";
import { renderApp } from "./app-render";
import type { Tab } from "./navigation";
import type { ResolvedTheme, ThemeMode } from "./theme";
import { isRtl, resolveLocale, t, type Locale } from "./i18n";
import type {
AgentsListResult,
ConfigSnapshot,
@ -78,6 +79,13 @@ import {
} from "./app-channels";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity";
import {
getSpeechRecognitionConstructor,
resolveSpeechLanguage,
supportsSpeechRecognition,
type SpeechRecognitionLike,
} from "./voice";
import { normalizeMessage } from "./chat/message-normalizer";
declare global {
interface Window {
@ -99,6 +107,8 @@ function resolveOnboardingMode(): boolean {
@customElement("moltbot-app")
export class MoltbotApp extends LitElement {
@state() settings: UiSettings = loadSettings();
@state() locale: Locale = resolveLocale(this.settings.language);
@state() dir: "ltr" | "rtl" = isRtl(this.locale) ? "rtl" : "ltr";
@state() password = "";
@state() tab: Tab = "chat";
@state() onboarding = resolveOnboardingMode();
@ -111,6 +121,9 @@ export class MoltbotApp extends LitElement {
private eventLogBuffer: EventLogEntry[] = [];
private toolStreamSyncTimer: number | null = null;
private sidebarCloseTimer: number | null = null;
private speechRecognition: SpeechRecognitionLike | null = null;
private speechUtterance: SpeechSynthesisUtterance | null = null;
private speechDraftSnapshot: string = "";
@state() assistantName = injectedAssistantIdentity.name;
@state() assistantAvatar = injectedAssistantIdentity.avatar;
@ -130,6 +143,10 @@ export class MoltbotApp extends LitElement {
@state() chatThinkingLevel: string | null = null;
@state() chatQueue: ChatQueueItem[] = [];
@state() chatAttachments: ChatAttachment[] = [];
@state() voiceSupported = supportsSpeechRecognition();
@state() voiceListening = false;
@state() voiceSpeaking = false;
@state() voiceError: string | null = null;
// Sidebar state for tool output viewing
@state() sidebarOpen = false;
@state() sidebarContent: string | null = null;
@ -282,6 +299,8 @@ export class MoltbotApp extends LitElement {
}
disconnectedCallback() {
this.stopVoiceInput();
this.stopSpeaking();
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
super.disconnectedCallback();
}
@ -388,6 +407,128 @@ export class MoltbotApp extends LitElement {
);
}
toggleVoiceInput() {
if (this.voiceListening) {
this.stopVoiceInput();
return;
}
this.startVoiceInput();
}
private ensureSpeechRecognition() {
if (!this.voiceSupported) return null;
if (this.speechRecognition) return this.speechRecognition;
const ctor = getSpeechRecognitionConstructor();
if (!ctor) {
this.voiceSupported = false;
return null;
}
const recognition = new ctor();
recognition.continuous = false;
recognition.interimResults = true;
recognition.maxAlternatives = 1;
recognition.onstart = () => {
this.voiceListening = true;
this.voiceError = null;
};
recognition.onend = () => {
this.voiceListening = false;
};
recognition.onerror = () => {
this.voiceListening = false;
this.voiceError = t(this.locale, "voice.error");
};
recognition.onresult = (event) => {
let finalText = "";
let interimText = "";
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
const transcript = result[0]?.transcript ?? "";
if (result.isFinal) {
finalText += transcript;
} else {
interimText += transcript;
}
}
const nextText = (finalText || interimText).trim();
const prefix = this.speechDraftSnapshot.trim();
if (nextText) {
this.chatMessage = [prefix, nextText].filter(Boolean).join(" ");
} else if (!prefix) {
this.chatMessage = "";
}
};
this.speechRecognition = recognition;
return recognition;
}
private startVoiceInput() {
const recognition = this.ensureSpeechRecognition();
if (!recognition) {
this.voiceError = t(this.locale, "voice.error");
return;
}
this.voiceError = null;
this.speechDraftSnapshot = this.chatMessage;
recognition.lang = resolveSpeechLanguage(this.locale);
try {
recognition.start();
} catch {
this.voiceError = t(this.locale, "voice.error");
this.voiceListening = false;
}
}
private stopVoiceInput() {
this.speechRecognition?.stop();
this.voiceListening = false;
}
speakLastReply() {
if (typeof window === "undefined" || !window.speechSynthesis) {
this.voiceError = t(this.locale, "voice.error");
return;
}
const text = this.resolveLastAssistantText();
if (!text) return;
this.stopSpeaking();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = resolveSpeechLanguage(this.locale);
utterance.onend = () => {
this.voiceSpeaking = false;
this.speechUtterance = null;
};
utterance.onerror = () => {
this.voiceSpeaking = false;
this.speechUtterance = null;
this.voiceError = t(this.locale, "voice.error");
};
this.speechUtterance = utterance;
this.voiceSpeaking = true;
window.speechSynthesis.speak(utterance);
}
stopSpeaking() {
if (typeof window === "undefined" || !window.speechSynthesis) return;
window.speechSynthesis.cancel();
this.voiceSpeaking = false;
this.speechUtterance = null;
}
private resolveLastAssistantText(): string | null {
if (!Array.isArray(this.chatMessages)) return null;
for (let i = this.chatMessages.length - 1; i >= 0; i--) {
const normalized = normalizeMessage(this.chatMessages[i]);
if (normalized.role.toLowerCase() !== "assistant") continue;
const text = normalized.content
.map((item) => (typeof item.text === "string" ? item.text.trim() : ""))
.filter(Boolean)
.join(" ");
if (text) return text;
}
return null;
}
async handleWhatsAppStart(force: boolean) {
await handleWhatsAppStartInternal(this, force);
}

187
ui/src/ui/i18n.ts Normal file
View File

@ -0,0 +1,187 @@
export type Locale = "en" | "ar";
export type LanguageSetting = "system" | Locale;
type TranslationValue = string | ((vars: Record<string, string | number>) => string);
type TranslationTable = Record<string, TranslationValue>;
const translations: Record<Locale, TranslationTable> = {
en: {
"app.brand.subtitle": "Gateway Dashboard",
"app.health": "Health",
"app.status.ok": "OK",
"app.status.offline": "Offline",
"nav.group.chat": "Chat",
"nav.group.control": "Control",
"nav.group.agent": "Agent",
"nav.group.settings": "Settings",
"nav.resources": "Resources",
"nav.docs": "Docs",
"nav.expand": "Expand sidebar",
"nav.collapse": "Collapse sidebar",
"tab.overview": "Overview",
"tab.channels": "Channels",
"tab.instances": "Instances",
"tab.sessions": "Sessions",
"tab.cron": "Cron Jobs",
"tab.skills": "Skills",
"tab.nodes": "Nodes",
"tab.chat": "Chat",
"tab.config": "Config",
"tab.debug": "Debug",
"tab.logs": "Logs",
"subtitle.overview": "Gateway status, entry points, and a fast health read.",
"subtitle.channels": "Manage channels and settings.",
"subtitle.instances": "Presence beacons from connected clients and nodes.",
"subtitle.sessions": "Inspect active sessions and adjust per-session defaults.",
"subtitle.cron": "Schedule wakeups and recurring agent runs.",
"subtitle.skills": "Manage skill availability and API key injection.",
"subtitle.nodes": "Paired devices, capabilities, and command exposure.",
"subtitle.chat": "Direct gateway chat session for quick interventions.",
"subtitle.config": "Edit ~/.clawdbot/moltbot.json safely.",
"subtitle.debug": "Gateway snapshots, events, and manual RPC calls.",
"subtitle.logs": "Live tail of the gateway file logs.",
"chat.disabled": "Disconnected from gateway.",
"chat.loading": "Loading chat…",
"chat.compaction.active": "Compacting context...",
"chat.compaction.done": "Context compacted",
"chat.focus.exit": "Exit focus mode",
"chat.queue.title": ({ count }) => `Queued (${count})`,
"chat.queue.image": ({ count }) => `Image (${count})`,
"chat.queue.remove": "Remove queued message",
"chat.compose.label": "Message",
"chat.compose.placeholder":
"Message (↩ to send, Shift+↩ for line breaks, paste images)",
"chat.compose.placeholder.attachments": "Add a message or paste more images...",
"chat.compose.placeholder.disconnected": "Connect to the gateway to start chatting…",
"chat.compose.send": "Send",
"chat.compose.queue": "Queue",
"chat.compose.stop": "Stop",
"chat.compose.new": "New session",
"chat.compose.attachment.preview": "Attachment preview",
"chat.compose.attachment.remove": "Remove attachment",
"chat.history.notice": ({ limit, hidden }) =>
`Showing last ${limit} messages (${hidden} hidden).`,
"chat.controls.refresh": "Refresh chat data",
"chat.controls.thinking": "Toggle assistant thinking/working output",
"chat.controls.focus": "Toggle focus mode (hide sidebar + page header)",
"chat.controls.disabled": "Disabled during onboarding",
"theme.label": "Theme",
"theme.system": "System",
"theme.light": "Light",
"theme.dark": "Dark",
"language.label": "Language",
"language.system": "System",
"language.en": "English",
"language.ar": "Arabic",
"voice.start": "Start voice input",
"voice.stop": "Stop voice input",
"voice.unsupported": "Voice input not supported",
"voice.speak": "Speak last reply",
"voice.speak.stop": "Stop speaking",
"voice.error": "Voice input failed. Check your microphone permissions.",
},
ar: {
"app.brand.subtitle": "لوحة تحكم البوابة",
"app.health": "الصحة",
"app.status.ok": "جيد",
"app.status.offline": "غير متصل",
"nav.group.chat": "المحادثة",
"nav.group.control": "التحكم",
"nav.group.agent": "الوكيل",
"nav.group.settings": "الإعدادات",
"nav.resources": "الموارد",
"nav.docs": "الوثائق",
"nav.expand": "توسيع الشريط الجانبي",
"nav.collapse": "تصغير الشريط الجانبي",
"tab.overview": "نظرة عامة",
"tab.channels": "القنوات",
"tab.instances": "الأجهزة",
"tab.sessions": "الجلسات",
"tab.cron": "مهام كرون",
"tab.skills": "المهارات",
"tab.nodes": "العقد",
"tab.chat": "المحادثة",
"tab.config": "التهيئة",
"tab.debug": "التصحيح",
"tab.logs": "السجلات",
"subtitle.overview": "حالة البوابة ونقاط الدخول وقراءة سريعة للصحة.",
"subtitle.channels": "إدارة القنوات والإعدادات.",
"subtitle.instances": "إشارات التواجد من العملاء والعقد المتصلة.",
"subtitle.sessions": "عرض الجلسات النشطة وضبط الافتراضات.",
"subtitle.cron": "جدولة التنبيهات وتشغيلات الوكيل المتكررة.",
"subtitle.skills": "إدارة تفعيل المهارات وحقن مفاتيح API.",
"subtitle.nodes": "الأجهزة المقترنة والقدرات وصلاحيات الأوامر.",
"subtitle.chat": "جلسة محادثة مباشرة مع البوابة للتدخل السريع.",
"subtitle.config": "تعديل ~/.clawdbot/moltbot.json بأمان.",
"subtitle.debug": "لقطات البوابة والأحداث واستدعاءات RPC اليدوية.",
"subtitle.logs": "عرض مباشر لسجلات ملفات البوابة.",
"chat.disabled": "غير متصل بالبوابة.",
"chat.loading": "جارٍ تحميل المحادثة…",
"chat.compaction.active": "جارٍ ضغط السياق...",
"chat.compaction.done": "تم ضغط السياق",
"chat.focus.exit": "الخروج من وضع التركيز",
"chat.queue.title": ({ count }) => `قائمة الانتظار (${count})`,
"chat.queue.image": ({ count }) => `صورة (${count})`,
"chat.queue.remove": "إزالة الرسالة من الانتظار",
"chat.compose.label": "الرسالة",
"chat.compose.placeholder":
"اكتب رسالة (↩ للإرسال، Shift+↩ لأسطر جديدة، يمكن لصق الصور)",
"chat.compose.placeholder.attachments": "أضف رسالة أو الصق المزيد من الصور...",
"chat.compose.placeholder.disconnected": "اتصل بالبوابة لبدء المحادثة…",
"chat.compose.send": "إرسال",
"chat.compose.queue": "انتظار",
"chat.compose.stop": "إيقاف",
"chat.compose.new": "جلسة جديدة",
"chat.compose.attachment.preview": "معاينة المرفق",
"chat.compose.attachment.remove": "إزالة المرفق",
"chat.history.notice": ({ limit, hidden }) =>
`عرض آخر ${limit} رسالة (مخفي ${hidden}).`,
"chat.controls.refresh": "تحديث بيانات المحادثة",
"chat.controls.thinking": "إظهار تفكير/عمل المساعد",
"chat.controls.focus": "تفعيل وضع التركيز (إخفاء الشريط الجانبي والعنوان)",
"chat.controls.disabled": "غير متاح أثناء الإعداد",
"theme.label": "المظهر",
"theme.system": "النظام",
"theme.light": "فاتح",
"theme.dark": "داكن",
"language.label": "اللغة",
"language.system": "لغة النظام",
"language.en": "الإنجليزية",
"language.ar": "العربية",
"voice.start": "بدء الإدخال الصوتي",
"voice.stop": "إيقاف الإدخال الصوتي",
"voice.unsupported": "الإدخال الصوتي غير مدعوم",
"voice.speak": "قراءة آخر رد صوتيًا",
"voice.speak.stop": "إيقاف القراءة الصوتية",
"voice.error": "فشل الإدخال الصوتي. تحقّق من أذونات الميكروفون.",
},
};
export function resolveLocale(setting: LanguageSetting, browserLanguage?: string): Locale {
if (setting === "en" || setting === "ar") return setting;
const candidate = (browserLanguage || navigator.language || "").toLowerCase();
if (candidate.startsWith("ar")) return "ar";
return "en";
}
export function isRtl(locale: Locale): boolean {
return locale === "ar";
}
export function applyLocaleToDocument(locale: Locale) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.lang = locale;
root.dir = isRtl(locale) ? "rtl" : "ltr";
}
export function t(
locale: Locale,
key: string,
vars: Record<string, string | number> = {},
): string {
const entry = translations[locale]?.[key] ?? translations.en?.[key];
if (!entry) return key;
if (typeof entry === "function") return entry(vars);
return entry;
}

View File

@ -26,6 +26,8 @@ export const icons = {
brain: html`<svg viewBox="0 0 24 24"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/><path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/><path d="M3.477 10.896a4 4 0 0 1 .585-.396"/><path d="M19.938 10.5a4 4 0 0 1 .585.396"/><path d="M6 18a4 4 0 0 1-1.967-.516"/><path d="M19.967 17.484A4 4 0 0 1 18 18"/></svg>`,
book: html`<svg viewBox="0 0 24 24"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>`,
loader: html`<svg viewBox="0 0 24 24"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg>`,
mic: html`<svg viewBox="0 0 24 24"><path d="M12 14a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3Z"/><path d="M19 11a7 7 0 0 1-14 0"/><path d="M12 19v3"/></svg>`,
volume2: html`<svg viewBox="0 0 24 24"><path d="M11 5 6 9H2v6h4l5 4V5Z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M18.5 5.5a9 9 0 0 1 0 13"/></svg>`,
// Tool icons
wrench: html`<svg viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`,

View File

@ -27,53 +27,53 @@ describe("iconForTab", () => {
});
it("returns stable icons for known tabs", () => {
expect(iconForTab("chat")).toBe("💬");
expect(iconForTab("overview")).toBe("📊");
expect(iconForTab("channels")).toBe("🔗");
expect(iconForTab("instances")).toBe("📡");
expect(iconForTab("sessions")).toBe("📄");
expect(iconForTab("cron")).toBe("");
expect(iconForTab("skills")).toBe("⚡️");
expect(iconForTab("nodes")).toBe("🖥️");
expect(iconForTab("config")).toBe("⚙️");
expect(iconForTab("debug")).toBe("🐞");
expect(iconForTab("logs")).toBe("🧾");
expect(iconForTab("chat")).toBe("messageSquare");
expect(iconForTab("overview")).toBe("barChart");
expect(iconForTab("channels")).toBe("link");
expect(iconForTab("instances")).toBe("radio");
expect(iconForTab("sessions")).toBe("fileText");
expect(iconForTab("cron")).toBe("loader");
expect(iconForTab("skills")).toBe("zap");
expect(iconForTab("nodes")).toBe("monitor");
expect(iconForTab("config")).toBe("settings");
expect(iconForTab("debug")).toBe("bug");
expect(iconForTab("logs")).toBe("scrollText");
});
it("returns a fallback icon for unknown tab", () => {
// TypeScript won't allow this normally, but runtime could receive unexpected values
const unknownTab = "unknown" as Tab;
expect(iconForTab(unknownTab)).toBe("📁");
expect(iconForTab(unknownTab)).toBe("folder");
});
});
describe("titleForTab", () => {
it("returns a non-empty string for every tab", () => {
for (const tab of ALL_TABS) {
const title = titleForTab(tab);
const title = titleForTab(tab, "en");
expect(title).toBeTruthy();
expect(typeof title).toBe("string");
}
});
it("returns expected titles", () => {
expect(titleForTab("chat")).toBe("Chat");
expect(titleForTab("overview")).toBe("Overview");
expect(titleForTab("cron")).toBe("Cron Jobs");
expect(titleForTab("chat", "en")).toBe("Chat");
expect(titleForTab("overview", "en")).toBe("Overview");
expect(titleForTab("cron", "en")).toBe("Cron Jobs");
});
});
describe("subtitleForTab", () => {
it("returns a string for every tab", () => {
for (const tab of ALL_TABS) {
const subtitle = subtitleForTab(tab);
const subtitle = subtitleForTab(tab, "en");
expect(typeof subtitle).toBe("string");
}
});
it("returns descriptive subtitles", () => {
expect(subtitleForTab("chat")).toContain("chat session");
expect(subtitleForTab("config")).toContain("moltbot.json");
expect(subtitleForTab("chat", "en")).toContain("chat session");
expect(subtitleForTab("config", "en")).toContain("moltbot.json");
});
});
@ -175,11 +175,11 @@ describe("inferBasePathFromPathname", () => {
describe("TAB_GROUPS", () => {
it("contains all expected groups", () => {
const labels = TAB_GROUPS.map((g) => g.label);
expect(labels).toContain("Chat");
expect(labels).toContain("Control");
expect(labels).toContain("Agent");
expect(labels).toContain("Settings");
const ids = TAB_GROUPS.map((g) => g.id);
expect(ids).toContain("chat");
expect(ids).toContain("control");
expect(ids).toContain("agent");
expect(ids).toContain("settings");
});
it("all tabs are unique", () => {

View File

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

View File

@ -1,6 +1,7 @@
const KEY = "moltbot.control.settings.v1";
import type { ThemeMode } from "./theme";
import type { LanguageSetting } from "./i18n";
export type UiSettings = {
gatewayUrl: string;
@ -13,6 +14,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
language: LanguageSetting;
};
export function loadSettings(): UiSettings {
@ -32,6 +34,27 @@ export function loadSettings(): UiSettings {
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
language: "system",
};
const normalizeNavGroupsCollapsed = (
raw: unknown,
): UiSettings["navGroupsCollapsed"] => {
if (typeof raw !== "object" || raw === null) return defaults.navGroupsCollapsed;
const entries = raw as Record<string, boolean>;
const mapped: Record<string, boolean> = { ...entries };
const legacyMap: Record<string, string> = {
Chat: "chat",
Control: "control",
Agent: "agent",
Settings: "settings",
};
for (const [legacy, next] of Object.entries(legacyMap)) {
if (legacy in mapped && !(next in mapped)) {
mapped[next] = Boolean(mapped[legacy]);
}
}
return mapped;
};
try {
@ -79,11 +102,11 @@ export function loadSettings(): UiSettings {
typeof parsed.navCollapsed === "boolean"
? parsed.navCollapsed
: defaults.navCollapsed,
navGroupsCollapsed:
typeof parsed.navGroupsCollapsed === "object" &&
parsed.navGroupsCollapsed !== null
? parsed.navGroupsCollapsed
: defaults.navGroupsCollapsed,
navGroupsCollapsed: normalizeNavGroupsCollapsed(parsed.navGroupsCollapsed),
language:
parsed.language === "en" || parsed.language === "ar" || parsed.language === "system"
? parsed.language
: defaults.language,
};
} catch {
return defaults;

View File

@ -16,6 +16,7 @@ function createSessions(): SessionsListResult {
function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
return {
locale: "en",
sessionKey: "main",
onSessionKeyChange: () => undefined,
thinkingLevel: null,
@ -45,6 +46,13 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
onSend: () => undefined,
onQueueRemove: () => undefined,
onNewSession: () => undefined,
voiceSupported: true,
voiceListening: false,
voiceSpeaking: false,
voiceError: null,
onToggleVoiceInput: () => undefined,
onSpeakLastReply: () => undefined,
onStopSpeaking: () => undefined,
...overrides,
};
}

View File

@ -16,6 +16,7 @@ import {
} from "../chat/grouped-render";
import { renderMarkdownSidebar } from "./markdown-sidebar";
import "../components/resizable-divider";
import { t, type Locale } from "../i18n";
export type CompactionIndicatorStatus = {
active: boolean;
@ -24,6 +25,7 @@ export type CompactionIndicatorStatus = {
};
export type ChatProps = {
locale: Locale;
sessionKey: string;
onSessionKeyChange: (next: string) => void;
thinkingLevel: string | null;
@ -68,6 +70,13 @@ export type ChatProps = {
onCloseSidebar?: () => void;
onSplitRatioChange?: (ratio: number) => void;
onChatScroll?: (event: Event) => void;
voiceSupported: boolean;
voiceListening: boolean;
voiceSpeaking: boolean;
voiceError: string | null;
onToggleVoiceInput: () => void;
onSpeakLastReply: () => void;
onStopSpeaking: () => void;
};
const COMPACTION_TOAST_DURATION_MS = 5000;
@ -77,14 +86,17 @@ function adjustTextareaHeight(el: HTMLTextAreaElement) {
el.style.height = `${el.scrollHeight}px`;
}
function renderCompactionIndicator(status: CompactionIndicatorStatus | null | undefined) {
function renderCompactionIndicator(
status: CompactionIndicatorStatus | null | undefined,
locale: Locale,
) {
if (!status) return nothing;
// Show "compacting..." while active
if (status.active) {
return html`
<div class="callout info compaction-indicator compaction-indicator--active">
${icons.loader} Compacting context...
${icons.loader} ${t(locale, "chat.compaction.active")}
</div>
`;
}
@ -95,7 +107,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
return html`
<div class="callout success compaction-indicator compaction-indicator--complete">
${icons.check} Context compacted
${icons.check} ${t(locale, "chat.compaction.done")}
</div>
`;
}
@ -157,13 +169,13 @@ function renderAttachmentPreview(props: ChatProps) {
<div class="chat-attachment">
<img
src=${att.dataUrl}
alt="Attachment preview"
alt=${t(props.locale, "chat.compose.attachment.preview")}
class="chat-attachment__img"
/>
<button
class="chat-attachment__remove"
type="button"
aria-label="Remove attachment"
aria-label=${t(props.locale, "chat.compose.attachment.remove")}
@click=${() => {
const next = (props.attachments ?? []).filter(
(a) => a.id !== att.id,
@ -197,9 +209,9 @@ export function renderChat(props: ChatProps) {
const hasAttachments = (props.attachments?.length ?? 0) > 0;
const composePlaceholder = props.connected
? hasAttachments
? "Add a message or paste more images..."
: "Message (↩ to send, Shift+↩ for line breaks, paste images)"
: "Connect to the gateway to start chatting…";
? t(props.locale, "chat.compose.placeholder.attachments")
: t(props.locale, "chat.compose.placeholder")
: t(props.locale, "chat.compose.placeholder.disconnected");
const splitRatio = props.splitRatio ?? 0.6;
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
@ -210,7 +222,9 @@ export function renderChat(props: ChatProps) {
aria-live="polite"
@scroll=${props.onChatScroll}
>
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
${props.loading
? html`<div class="muted">${t(props.locale, "chat.loading")}</div>`
: nothing}
${repeat(buildChatItems(props), (item) => item.key, (item) => {
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity);
@ -249,7 +263,11 @@ export function renderChat(props: ChatProps) {
? html`<div class="callout danger">${props.error}</div>`
: nothing}
${renderCompactionIndicator(props.compactionStatus)}
${props.voiceError
? html`<div class="callout warn">${props.voiceError}</div>`
: nothing}
${renderCompactionIndicator(props.compactionStatus, props.locale)}
${props.focusMode
? html`
@ -257,8 +275,8 @@ export function renderChat(props: ChatProps) {
class="chat-focus-exit"
type="button"
@click=${props.onToggleFocusMode}
aria-label="Exit focus mode"
title="Exit focus mode"
aria-label=${t(props.locale, "chat.focus.exit")}
title=${t(props.locale, "chat.focus.exit")}
>
${icons.x}
</button>
@ -300,7 +318,9 @@ export function renderChat(props: ChatProps) {
${props.queue.length
? html`
<div class="chat-queue" role="status" aria-live="polite">
<div class="chat-queue__title">Queued (${props.queue.length})</div>
<div class="chat-queue__title">
${t(props.locale, "chat.queue.title", { count: props.queue.length })}
</div>
<div class="chat-queue__list">
${props.queue.map(
(item) => html`
@ -308,13 +328,15 @@ export function renderChat(props: ChatProps) {
<div class="chat-queue__text">
${item.text ||
(item.attachments?.length
? `Image (${item.attachments.length})`
? t(props.locale, "chat.queue.image", {
count: item.attachments.length,
})
: "")}
</div>
<button
class="btn chat-queue__remove"
type="button"
aria-label="Remove queued message"
aria-label=${t(props.locale, "chat.queue.remove")}
@click=${() => props.onQueueRemove(item.id)}
>
${icons.x}
@ -331,7 +353,7 @@ export function renderChat(props: ChatProps) {
${renderAttachmentPreview(props)}
<div class="chat-compose__row">
<label class="field chat-compose__field">
<span>Message</span>
<span>${t(props.locale, "chat.compose.label")}</span>
<textarea
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
.value=${props.draft}
@ -359,14 +381,48 @@ export function renderChat(props: ChatProps) {
?disabled=${!props.connected || (!canAbort && props.sending)}
@click=${canAbort ? props.onAbort : props.onNewSession}
>
${canAbort ? "Stop" : "New session"}
${canAbort
? t(props.locale, "chat.compose.stop")
: t(props.locale, "chat.compose.new")}
</button>
<button
class="btn btn--icon ${props.voiceListening ? "active" : ""}"
?disabled=${!props.voiceSupported}
@click=${props.onToggleVoiceInput}
title=${props.voiceSupported
? props.voiceListening
? t(props.locale, "voice.stop")
: t(props.locale, "voice.start")
: t(props.locale, "voice.unsupported")}
aria-label=${props.voiceSupported
? props.voiceListening
? t(props.locale, "voice.stop")
: t(props.locale, "voice.start")
: t(props.locale, "voice.unsupported")}
>
${icons.mic}
</button>
<button
class="btn btn--icon ${props.voiceSpeaking ? "active" : ""}"
@click=${props.voiceSpeaking ? props.onStopSpeaking : props.onSpeakLastReply}
title=${props.voiceSpeaking
? t(props.locale, "voice.speak.stop")
: t(props.locale, "voice.speak")}
aria-label=${props.voiceSpeaking
? t(props.locale, "voice.speak.stop")
: t(props.locale, "voice.speak")}
>
${icons.volume2}
</button>
<button
class="btn primary"
?disabled=${!props.connected}
@click=${props.onSend}
>
${isBusy ? "Queue" : "Send"}<kbd class="btn-kbd"></kbd>
${isBusy
? t(props.locale, "chat.compose.queue")
: t(props.locale, "chat.compose.send")}
<kbd class="btn-kbd"></kbd>
</button>
</div>
</div>
@ -423,11 +479,14 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
items.push({
kind: "message",
key: "chat:history:notice",
message: {
role: "system",
content: `Showing last ${CHAT_HISTORY_RENDER_LIMIT} messages (${historyStart} hidden).`,
timestamp: Date.now(),
},
message: {
role: "system",
content: t(props.locale, "chat.history.notice", {
limit: CHAT_HISTORY_RENDER_LIMIT,
hidden: historyStart,
}),
timestamp: Date.now(),
},
});
}
for (let i = historyStart; i < history.length; i++) {

45
ui/src/ui/voice.ts Normal file
View File

@ -0,0 +1,45 @@
import type { Locale } from "./i18n";
export type SpeechRecognitionResultLike = {
readonly isFinal: boolean;
readonly length: number;
item: (index: number) => { transcript: string };
[index: number]: { transcript: string };
};
export type SpeechRecognitionEventLike = {
readonly resultIndex: number;
readonly results: ArrayLike<SpeechRecognitionResultLike>;
};
export type SpeechRecognitionLike = {
lang: string;
continuous: boolean;
interimResults: boolean;
maxAlternatives: number;
start: () => void;
stop: () => void;
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
onerror: ((event: { error?: string }) => void) | null;
onend: (() => void) | null;
onstart: (() => void) | null;
};
type SpeechRecognitionConstructor = new () => SpeechRecognitionLike;
export function getSpeechRecognitionConstructor(): SpeechRecognitionConstructor | null {
if (typeof window === "undefined") return null;
const anyWindow = window as unknown as {
SpeechRecognition?: SpeechRecognitionConstructor;
webkitSpeechRecognition?: SpeechRecognitionConstructor;
};
return anyWindow.SpeechRecognition ?? anyWindow.webkitSpeechRecognition ?? null;
}
export function supportsSpeechRecognition(): boolean {
return Boolean(getSpeechRecognitionConstructor());
}
export function resolveSpeechLanguage(locale: Locale): string {
return locale === "ar" ? "ar-SA" : "en-US";
}