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:
commit
f3f0cbf1df
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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],
|
||||
);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>;
|
||||
|
||||
141
ui/src/ui/app.ts
141
ui/src/ui/app.ts
@ -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
187
ui/src/ui/i18n.ts
Normal 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;
|
||||
}
|
||||
@ -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>`,
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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 "";
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
45
ui/src/ui/voice.ts
Normal 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";
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user