Agents: keep fish fallback and adjust sh tests
This commit is contained in:
parent
c9fe062824
commit
534fb92391
@ -876,7 +876,9 @@ export function createExecTool(
|
|||||||
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
|
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
|
||||||
})
|
})
|
||||||
: mergedEnv;
|
: 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({
|
const shellPath = getShellPathFromLoginShell({
|
||||||
env: process.env,
|
env: process.env,
|
||||||
timeoutMs: resolveShellEnvFallbackTimeoutMs(process.env),
|
timeoutMs: resolveShellEnvFallbackTimeoutMs(process.env),
|
||||||
@ -1398,6 +1400,9 @@ export function createExecTool(
|
|||||||
timeoutSec: effectiveTimeout,
|
timeoutSec: effectiveTimeout,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
});
|
});
|
||||||
|
if (allowBackground && backgroundRequested) {
|
||||||
|
markBackgrounded(run.session);
|
||||||
|
}
|
||||||
|
|
||||||
let yielded = false;
|
let yielded = false;
|
||||||
let yieldTimer: NodeJS.Timeout | null = null;
|
let yieldTimer: NodeJS.Timeout | null = null;
|
||||||
|
|||||||
@ -77,6 +77,11 @@ describe("getShellConfig", () => {
|
|||||||
delete process.env.SHELL;
|
delete process.env.SHELL;
|
||||||
process.env.PATH = "";
|
process.env.PATH = "";
|
||||||
const { shell } = getShellConfig();
|
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"] };
|
if (bash) return { shell: bash, args: ["-c"] };
|
||||||
const sh = resolveShellFromPath("sh");
|
const sh = resolveShellFromPath("sh");
|
||||||
if (sh) return { shell: sh, args: ["-c"] };
|
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"] };
|
return { shell, args: ["-c"] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,6 +65,18 @@ function resolveShellFromPath(name: string): string | undefined {
|
|||||||
return 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 {
|
export function sanitizeBinaryOutput(text: string): string {
|
||||||
const scrubbed = text.replace(/[\p{Format}\p{Surrogate}]/gu, "");
|
const scrubbed = text.replace(/[\p{Format}\p{Surrogate}]/gu, "");
|
||||||
if (!scrubbed) return scrubbed;
|
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=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 {
|
:root {
|
||||||
/* Background - Warmer dark with depth */
|
/* Background - Warmer dark with depth */
|
||||||
@ -111,6 +112,11 @@
|
|||||||
color-scheme: dark;
|
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 */
|
/* Light theme - Clean with subtle warmth */
|
||||||
:root[data-theme="light"] {
|
:root[data-theme="light"] {
|
||||||
--bg: #fafafa;
|
--bg: #fafafa;
|
||||||
|
|||||||
@ -313,6 +313,12 @@
|
|||||||
background: rgba(255, 255, 255, 0.06);
|
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 */
|
/* Controls separator */
|
||||||
.chat-controls__separator {
|
.chat-controls__separator {
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
@ -338,6 +344,12 @@
|
|||||||
color: var(--muted);
|
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 {
|
:root[data-theme="light"] .btn--icon:hover {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
@ -395,3 +407,25 @@
|
|||||||
min-width: 120px;
|
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;
|
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) {
|
@supports (height: 100dvh) {
|
||||||
.shell {
|
.shell {
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
@ -84,6 +96,18 @@
|
|||||||
background: var(--bg);
|
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 {
|
.topbar-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -155,6 +179,48 @@
|
|||||||
gap: 8px;
|
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 {
|
.topbar-status .pill {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@ -316,6 +382,10 @@
|
|||||||
background var(--duration-fast) ease;
|
background var(--duration-fast) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] .nav-label {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-label:hover {
|
.nav-label:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
@ -364,6 +434,14 @@
|
|||||||
color var(--duration-fast) ease;
|
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 {
|
.nav-item__icon {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
|
|||||||
@ -80,6 +80,15 @@
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.language-toggle {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-toggle select {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar-status .pill {
|
.topbar-status .pill {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
attachThemeListener,
|
attachThemeListener,
|
||||||
detachThemeListener,
|
detachThemeListener,
|
||||||
inferBasePath,
|
inferBasePath,
|
||||||
|
syncLocaleWithSettings,
|
||||||
syncTabWithLocation,
|
syncTabWithLocation,
|
||||||
syncThemeWithSettings,
|
syncThemeWithSettings,
|
||||||
} from "./app-settings";
|
} from "./app-settings";
|
||||||
@ -45,6 +46,9 @@ export function handleConnected(host: LifecycleHost) {
|
|||||||
syncThemeWithSettings(
|
syncThemeWithSettings(
|
||||||
host as unknown as Parameters<typeof syncThemeWithSettings>[0],
|
host as unknown as Parameters<typeof syncThemeWithSettings>[0],
|
||||||
);
|
);
|
||||||
|
syncLocaleWithSettings(
|
||||||
|
host as unknown as Parameters<typeof syncLocaleWithSettings>[0],
|
||||||
|
);
|
||||||
attachThemeListener(
|
attachThemeListener(
|
||||||
host as unknown as Parameters<typeof attachThemeListener>[0],
|
host as unknown as Parameters<typeof attachThemeListener>[0],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -10,8 +10,9 @@ import { syncUrlWithSessionKey } from "./app-settings";
|
|||||||
import type { SessionsListResult } from "./types";
|
import type { SessionsListResult } from "./types";
|
||||||
import type { ThemeMode } from "./theme";
|
import type { ThemeMode } from "./theme";
|
||||||
import type { ThemeTransitionContext } from "./theme-transition";
|
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);
|
const href = pathForTab(tab, state.basePath);
|
||||||
return html`
|
return html`
|
||||||
<a
|
<a
|
||||||
@ -31,15 +32,15 @@ export function renderTab(state: AppViewState, tab: Tab) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
state.setTab(tab);
|
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__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>
|
</a>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderChatControls(state: AppViewState) {
|
export function renderChatControls(state: AppViewState, locale: Locale) {
|
||||||
const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult);
|
const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult);
|
||||||
const sessionOptions = resolveSessionOptions(
|
const sessionOptions = resolveSessionOptions(
|
||||||
state.sessionKey,
|
state.sessionKey,
|
||||||
@ -95,7 +96,7 @@ export function renderChatControls(state: AppViewState) {
|
|||||||
state.resetToolStream();
|
state.resetToolStream();
|
||||||
void refreshChat(state as unknown as Parameters<typeof refreshChat>[0]);
|
void refreshChat(state as unknown as Parameters<typeof refreshChat>[0]);
|
||||||
}}
|
}}
|
||||||
title="Refresh chat data"
|
title=${t(locale, "chat.controls.refresh")}
|
||||||
>
|
>
|
||||||
${refreshIcon}
|
${refreshIcon}
|
||||||
</button>
|
</button>
|
||||||
@ -112,8 +113,8 @@ export function renderChatControls(state: AppViewState) {
|
|||||||
}}
|
}}
|
||||||
aria-pressed=${showThinking}
|
aria-pressed=${showThinking}
|
||||||
title=${disableThinkingToggle
|
title=${disableThinkingToggle
|
||||||
? "Disabled during onboarding"
|
? t(locale, "chat.controls.disabled")
|
||||||
: "Toggle assistant thinking/working output"}
|
: t(locale, "chat.controls.thinking")}
|
||||||
>
|
>
|
||||||
${icons.brain}
|
${icons.brain}
|
||||||
</button>
|
</button>
|
||||||
@ -129,8 +130,8 @@ export function renderChatControls(state: AppViewState) {
|
|||||||
}}
|
}}
|
||||||
aria-pressed=${focusActive}
|
aria-pressed=${focusActive}
|
||||||
title=${disableFocusToggle
|
title=${disableFocusToggle
|
||||||
? "Disabled during onboarding"
|
? t(locale, "chat.controls.disabled")
|
||||||
: "Toggle focus mode (hide sidebar + page header)"}
|
: t(locale, "chat.controls.focus")}
|
||||||
>
|
>
|
||||||
${focusIcon}
|
${focusIcon}
|
||||||
</button>
|
</button>
|
||||||
@ -209,14 +210,14 @@ export function renderThemeToggle(state: AppViewState) {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="theme-toggle" style="--theme-index: ${index};">
|
<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>
|
<span class="theme-toggle__indicator"></span>
|
||||||
<button
|
<button
|
||||||
class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
|
class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
|
||||||
@click=${applyTheme("system")}
|
@click=${applyTheme("system")}
|
||||||
aria-pressed=${state.theme === "system"}
|
aria-pressed=${state.theme === "system"}
|
||||||
aria-label="System theme"
|
aria-label=${t(state.locale, "theme.system")}
|
||||||
title="System"
|
title=${t(state.locale, "theme.system")}
|
||||||
>
|
>
|
||||||
${renderMonitorIcon()}
|
${renderMonitorIcon()}
|
||||||
</button>
|
</button>
|
||||||
@ -224,8 +225,8 @@ export function renderThemeToggle(state: AppViewState) {
|
|||||||
class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
|
class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
|
||||||
@click=${applyTheme("light")}
|
@click=${applyTheme("light")}
|
||||||
aria-pressed=${state.theme === "light"}
|
aria-pressed=${state.theme === "light"}
|
||||||
aria-label="Light theme"
|
aria-label=${t(state.locale, "theme.light")}
|
||||||
title="Light"
|
title=${t(state.locale, "theme.light")}
|
||||||
>
|
>
|
||||||
${renderSunIcon()}
|
${renderSunIcon()}
|
||||||
</button>
|
</button>
|
||||||
@ -233,8 +234,8 @@ export function renderThemeToggle(state: AppViewState) {
|
|||||||
class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
|
class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
|
||||||
@click=${applyTheme("dark")}
|
@click=${applyTheme("dark")}
|
||||||
aria-pressed=${state.theme === "dark"}
|
aria-pressed=${state.theme === "dark"}
|
||||||
aria-label="Dark theme"
|
aria-label=${t(state.locale, "theme.dark")}
|
||||||
title="Dark"
|
title=${t(state.locale, "theme.dark")}
|
||||||
>
|
>
|
||||||
${renderMoonIcon()}
|
${renderMoonIcon()}
|
||||||
</button>
|
</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() {
|
function renderSunIcon() {
|
||||||
return html`
|
return html`
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
<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 { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
|
||||||
import type { AppViewState } from "./app-view-state";
|
import type { AppViewState } from "./app-view-state";
|
||||||
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
|
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
|
||||||
import {
|
import { TAB_GROUPS, subtitleForTab, titleForTab, type Tab } from "./navigation";
|
||||||
TAB_GROUPS,
|
|
||||||
iconForTab,
|
|
||||||
pathForTab,
|
|
||||||
subtitleForTab,
|
|
||||||
titleForTab,
|
|
||||||
type Tab,
|
|
||||||
} from "./navigation";
|
|
||||||
import { icons } from "./icons";
|
import { icons } from "./icons";
|
||||||
import type { UiSettings } from "./storage";
|
import type { UiSettings } from "./storage";
|
||||||
import type { ThemeMode } from "./theme";
|
import type { ThemeMode } from "./theme";
|
||||||
@ -51,7 +44,12 @@ import {
|
|||||||
rotateDeviceToken,
|
rotateDeviceToken,
|
||||||
} from "./controllers/devices";
|
} from "./controllers/devices";
|
||||||
import { renderSkills } from "./views/skills";
|
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 { loadChannels } from "./controllers/channels";
|
||||||
import { loadPresence } from "./controllers/presence";
|
import { loadPresence } from "./controllers/presence";
|
||||||
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions";
|
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions";
|
||||||
@ -82,6 +80,7 @@ import {
|
|||||||
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
|
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
|
||||||
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
||||||
import { loadLogs } from "./controllers/logs";
|
import { loadLogs } from "./controllers/logs";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
|
||||||
const AVATAR_DATA_RE = /^data:/i;
|
const AVATAR_DATA_RE = /^data:/i;
|
||||||
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
||||||
@ -105,7 +104,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
const presenceCount = state.presenceEntries.length;
|
const presenceCount = state.presenceEntries.length;
|
||||||
const sessionsCount = state.sessionsResult?.count ?? null;
|
const sessionsCount = state.sessionsResult?.count ?? null;
|
||||||
const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
|
const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
|
||||||
const chatDisabledReason = state.connected ? null : "Disconnected from gateway.";
|
const chatDisabledReason = state.connected ? null : t(state.locale, "chat.disabled");
|
||||||
const isChat = state.tab === "chat";
|
const isChat = state.tab === "chat";
|
||||||
const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding);
|
const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding);
|
||||||
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
||||||
@ -113,7 +112,11 @@ export function renderApp(state: AppViewState) {
|
|||||||
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
|
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
|
||||||
|
|
||||||
return html`
|
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">
|
<header class="topbar">
|
||||||
<div class="topbar-left">
|
<div class="topbar-left">
|
||||||
<button
|
<button
|
||||||
@ -123,8 +126,12 @@ export function renderApp(state: AppViewState) {
|
|||||||
...state.settings,
|
...state.settings,
|
||||||
navCollapsed: !state.settings.navCollapsed,
|
navCollapsed: !state.settings.navCollapsed,
|
||||||
})}
|
})}
|
||||||
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
|
title=${state.settings.navCollapsed
|
||||||
aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
|
? 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>
|
<span class="nav-collapse-toggle__icon">${icons.menu}</span>
|
||||||
</button>
|
</button>
|
||||||
@ -134,22 +141,27 @@ export function renderApp(state: AppViewState) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="brand-text">
|
<div class="brand-text">
|
||||||
<div class="brand-title">MOLTBOT</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-status">
|
<div class="topbar-status">
|
||||||
<div class="pill">
|
<div class="pill">
|
||||||
<span class="statusDot ${state.connected ? "ok" : ""}"></span>
|
<span class="statusDot ${state.connected ? "ok" : ""}"></span>
|
||||||
<span>Health</span>
|
<span>${t(state.locale, "app.health")}</span>
|
||||||
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
|
<span class="mono">
|
||||||
|
${state.connected
|
||||||
|
? t(state.locale, "app.status.ok")
|
||||||
|
: t(state.locale, "app.status.offline")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
${renderLanguageToggle(state)}
|
||||||
${renderThemeToggle(state)}
|
${renderThemeToggle(state)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
|
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
|
||||||
${TAB_GROUPS.map((group) => {
|
${TAB_GROUPS.map((group) => {
|
||||||
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
|
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.id] ?? false;
|
||||||
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
|
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
|
||||||
return html`
|
return html`
|
||||||
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
|
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
|
||||||
@ -157,7 +169,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
class="nav-label"
|
class="nav-label"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
const next = { ...state.settings.navGroupsCollapsed };
|
const next = { ...state.settings.navGroupsCollapsed };
|
||||||
next[group.label] = !isGroupCollapsed;
|
next[group.id] = !isGroupCollapsed;
|
||||||
state.applySettings({
|
state.applySettings({
|
||||||
...state.settings,
|
...state.settings,
|
||||||
navGroupsCollapsed: next,
|
navGroupsCollapsed: next,
|
||||||
@ -165,18 +177,18 @@ export function renderApp(state: AppViewState) {
|
|||||||
}}
|
}}
|
||||||
aria-expanded=${!isGroupCollapsed}
|
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>
|
<span class="nav-label__chevron">${isGroupCollapsed ? "+" : "−"}</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="nav-group__items">
|
<div class="nav-group__items">
|
||||||
${group.tabs.map((tab) => renderTab(state, tab))}
|
${group.tabs.map((tab) => renderTab(state, tab, state.locale))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
<div class="nav-group nav-group--links">
|
<div class="nav-group nav-group--links">
|
||||||
<div class="nav-label nav-label--static">
|
<div class="nav-label nav-label--static">
|
||||||
<span class="nav-label__text">Resources</span>
|
<span class="nav-label__text">${t(state.locale, "nav.resources")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-group__items">
|
<div class="nav-group__items">
|
||||||
<a
|
<a
|
||||||
@ -184,10 +196,10 @@ export function renderApp(state: AppViewState) {
|
|||||||
href="https://docs.molt.bot"
|
href="https://docs.molt.bot"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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__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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -195,14 +207,14 @@ export function renderApp(state: AppViewState) {
|
|||||||
<main class="content ${isChat ? "content--chat" : ""}">
|
<main class="content ${isChat ? "content--chat" : ""}">
|
||||||
<section class="content-header">
|
<section class="content-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="page-title">${titleForTab(state.tab)}</div>
|
<div class="page-title">${titleForTab(state.tab, state.locale)}</div>
|
||||||
<div class="page-sub">${subtitleForTab(state.tab)}</div>
|
<div class="page-sub">${subtitleForTab(state.tab, state.locale)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-meta">
|
<div class="page-meta">
|
||||||
${state.lastError
|
${state.lastError
|
||||||
? html`<div class="pill danger">${state.lastError}</div>`
|
? html`<div class="pill danger">${state.lastError}</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${isChat ? renderChatControls(state) : nothing}
|
${isChat ? renderChatControls(state, state.locale) : nothing}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -428,6 +440,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
|
|
||||||
${state.tab === "chat"
|
${state.tab === "chat"
|
||||||
? renderChat({
|
? renderChat({
|
||||||
|
locale: state.locale,
|
||||||
sessionKey: state.sessionKey,
|
sessionKey: state.sessionKey,
|
||||||
onSessionKeyChange: (next) => {
|
onSessionKeyChange: (next) => {
|
||||||
state.sessionKey = next;
|
state.sessionKey = next;
|
||||||
@ -497,6 +510,13 @@ export function renderApp(state: AppViewState) {
|
|||||||
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||||
assistantName: state.assistantName,
|
assistantName: state.assistantName,
|
||||||
assistantAvatar: state.assistantAvatar,
|
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}
|
: nothing}
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,10 @@ const createHost = (tab: Tab): SettingsHost => ({
|
|||||||
splitRatio: 0.6,
|
splitRatio: 0.6,
|
||||||
navCollapsed: false,
|
navCollapsed: false,
|
||||||
navGroupsCollapsed: {},
|
navGroupsCollapsed: {},
|
||||||
|
language: "system",
|
||||||
},
|
},
|
||||||
|
locale: "en",
|
||||||
|
dir: "ltr",
|
||||||
theme: "system",
|
theme: "system",
|
||||||
themeResolved: "dark",
|
themeResolved: "dark",
|
||||||
applySessionKey: "main",
|
applySessionKey: "main",
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { loadSkills } from "./controllers/skills";
|
|||||||
import { inferBasePathFromPathname, normalizeBasePath, normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation";
|
import { inferBasePathFromPathname, normalizeBasePath, normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation";
|
||||||
import { saveSettings, type UiSettings } from "./storage";
|
import { saveSettings, type UiSettings } from "./storage";
|
||||||
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
|
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
|
||||||
|
import { applyLocaleToDocument, isRtl, resolveLocale, type Locale } from "./i18n";
|
||||||
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
|
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
|
||||||
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
|
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
|
||||||
import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling";
|
import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling";
|
||||||
@ -20,6 +21,8 @@ import type { MoltbotApp } from "./app";
|
|||||||
|
|
||||||
type SettingsHost = {
|
type SettingsHost = {
|
||||||
settings: UiSettings;
|
settings: UiSettings;
|
||||||
|
locale: Locale;
|
||||||
|
dir: "ltr" | "rtl";
|
||||||
theme: ThemeMode;
|
theme: ThemeMode;
|
||||||
themeResolved: ResolvedTheme;
|
themeResolved: ResolvedTheme;
|
||||||
applySessionKey: string;
|
applySessionKey: string;
|
||||||
@ -37,6 +40,7 @@ type SettingsHost = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function applySettings(host: SettingsHost, next: UiSettings) {
|
export function applySettings(host: SettingsHost, next: UiSettings) {
|
||||||
|
const nextLocale = resolveLocale(next.language);
|
||||||
const normalized = {
|
const normalized = {
|
||||||
...next,
|
...next,
|
||||||
lastActiveSessionKey: next.lastActiveSessionKey?.trim() || next.sessionKey.trim() || "main",
|
lastActiveSessionKey: next.lastActiveSessionKey?.trim() || next.sessionKey.trim() || "main",
|
||||||
@ -47,6 +51,11 @@ export function applySettings(host: SettingsHost, next: UiSettings) {
|
|||||||
host.theme = next.theme;
|
host.theme = next.theme;
|
||||||
applyResolvedTheme(host, resolveTheme(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;
|
host.applySessionKey = host.settings.lastActiveSessionKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,6 +203,12 @@ export function syncThemeWithSettings(host: SettingsHost) {
|
|||||||
applyResolvedTheme(host, resolveTheme(host.theme));
|
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) {
|
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
|
||||||
host.themeResolved = resolved;
|
host.themeResolved = resolved;
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { Tab } from "./navigation";
|
|||||||
import type { UiSettings } from "./storage";
|
import type { UiSettings } from "./storage";
|
||||||
import type { ThemeMode } from "./theme";
|
import type { ThemeMode } from "./theme";
|
||||||
import type { ThemeTransitionContext } from "./theme-transition";
|
import type { ThemeTransitionContext } from "./theme-transition";
|
||||||
|
import type { Locale } from "./i18n";
|
||||||
import type {
|
import type {
|
||||||
AgentsListResult,
|
AgentsListResult,
|
||||||
ChannelsStatusSnapshot,
|
ChannelsStatusSnapshot,
|
||||||
@ -32,6 +33,8 @@ import type { NostrProfileFormState } from "./views/channels.nostr-profile-form"
|
|||||||
|
|
||||||
export type AppViewState = {
|
export type AppViewState = {
|
||||||
settings: UiSettings;
|
settings: UiSettings;
|
||||||
|
locale: Locale;
|
||||||
|
dir: "ltr" | "rtl";
|
||||||
password: string;
|
password: string;
|
||||||
tab: Tab;
|
tab: Tab;
|
||||||
onboarding: boolean;
|
onboarding: boolean;
|
||||||
@ -57,6 +60,10 @@ export type AppViewState = {
|
|||||||
chatAvatarUrl: string | null;
|
chatAvatarUrl: string | null;
|
||||||
chatThinkingLevel: string | null;
|
chatThinkingLevel: string | null;
|
||||||
chatQueue: ChatQueueItem[];
|
chatQueue: ChatQueueItem[];
|
||||||
|
voiceSupported: boolean;
|
||||||
|
voiceListening: boolean;
|
||||||
|
voiceSpeaking: boolean;
|
||||||
|
voiceError: string | null;
|
||||||
nodesLoading: boolean;
|
nodesLoading: boolean;
|
||||||
nodes: Array<Record<string, unknown>>;
|
nodes: Array<Record<string, unknown>>;
|
||||||
devicesLoading: boolean;
|
devicesLoading: boolean;
|
||||||
@ -151,6 +158,9 @@ export type AppViewState = {
|
|||||||
setTab: (tab: Tab) => void;
|
setTab: (tab: Tab) => void;
|
||||||
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
|
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
|
||||||
applySettings: (next: UiSettings) => void;
|
applySettings: (next: UiSettings) => void;
|
||||||
|
toggleVoiceInput: () => void;
|
||||||
|
speakLastReply: () => void;
|
||||||
|
stopSpeaking: () => void;
|
||||||
loadOverview: () => Promise<void>;
|
loadOverview: () => Promise<void>;
|
||||||
loadAssistantIdentity: () => Promise<void>;
|
loadAssistantIdentity: () => Promise<void>;
|
||||||
loadCron: () => 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 { renderApp } from "./app-render";
|
||||||
import type { Tab } from "./navigation";
|
import type { Tab } from "./navigation";
|
||||||
import type { ResolvedTheme, ThemeMode } from "./theme";
|
import type { ResolvedTheme, ThemeMode } from "./theme";
|
||||||
|
import { isRtl, resolveLocale, t, type Locale } from "./i18n";
|
||||||
import type {
|
import type {
|
||||||
AgentsListResult,
|
AgentsListResult,
|
||||||
ConfigSnapshot,
|
ConfigSnapshot,
|
||||||
@ -78,6 +79,13 @@ import {
|
|||||||
} from "./app-channels";
|
} from "./app-channels";
|
||||||
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
|
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
|
||||||
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity";
|
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity";
|
||||||
|
import {
|
||||||
|
getSpeechRecognitionConstructor,
|
||||||
|
resolveSpeechLanguage,
|
||||||
|
supportsSpeechRecognition,
|
||||||
|
type SpeechRecognitionLike,
|
||||||
|
} from "./voice";
|
||||||
|
import { normalizeMessage } from "./chat/message-normalizer";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -99,6 +107,8 @@ function resolveOnboardingMode(): boolean {
|
|||||||
@customElement("moltbot-app")
|
@customElement("moltbot-app")
|
||||||
export class MoltbotApp extends LitElement {
|
export class MoltbotApp extends LitElement {
|
||||||
@state() settings: UiSettings = loadSettings();
|
@state() settings: UiSettings = loadSettings();
|
||||||
|
@state() locale: Locale = resolveLocale(this.settings.language);
|
||||||
|
@state() dir: "ltr" | "rtl" = isRtl(this.locale) ? "rtl" : "ltr";
|
||||||
@state() password = "";
|
@state() password = "";
|
||||||
@state() tab: Tab = "chat";
|
@state() tab: Tab = "chat";
|
||||||
@state() onboarding = resolveOnboardingMode();
|
@state() onboarding = resolveOnboardingMode();
|
||||||
@ -111,6 +121,9 @@ export class MoltbotApp extends LitElement {
|
|||||||
private eventLogBuffer: EventLogEntry[] = [];
|
private eventLogBuffer: EventLogEntry[] = [];
|
||||||
private toolStreamSyncTimer: number | null = null;
|
private toolStreamSyncTimer: number | null = null;
|
||||||
private sidebarCloseTimer: number | null = null;
|
private sidebarCloseTimer: number | null = null;
|
||||||
|
private speechRecognition: SpeechRecognitionLike | null = null;
|
||||||
|
private speechUtterance: SpeechSynthesisUtterance | null = null;
|
||||||
|
private speechDraftSnapshot: string = "";
|
||||||
|
|
||||||
@state() assistantName = injectedAssistantIdentity.name;
|
@state() assistantName = injectedAssistantIdentity.name;
|
||||||
@state() assistantAvatar = injectedAssistantIdentity.avatar;
|
@state() assistantAvatar = injectedAssistantIdentity.avatar;
|
||||||
@ -130,6 +143,10 @@ export class MoltbotApp extends LitElement {
|
|||||||
@state() chatThinkingLevel: string | null = null;
|
@state() chatThinkingLevel: string | null = null;
|
||||||
@state() chatQueue: ChatQueueItem[] = [];
|
@state() chatQueue: ChatQueueItem[] = [];
|
||||||
@state() chatAttachments: ChatAttachment[] = [];
|
@state() chatAttachments: ChatAttachment[] = [];
|
||||||
|
@state() voiceSupported = supportsSpeechRecognition();
|
||||||
|
@state() voiceListening = false;
|
||||||
|
@state() voiceSpeaking = false;
|
||||||
|
@state() voiceError: string | null = null;
|
||||||
// Sidebar state for tool output viewing
|
// Sidebar state for tool output viewing
|
||||||
@state() sidebarOpen = false;
|
@state() sidebarOpen = false;
|
||||||
@state() sidebarContent: string | null = null;
|
@state() sidebarContent: string | null = null;
|
||||||
@ -282,6 +299,8 @@ export class MoltbotApp extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
|
this.stopVoiceInput();
|
||||||
|
this.stopSpeaking();
|
||||||
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
|
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
|
||||||
super.disconnectedCallback();
|
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) {
|
async handleWhatsAppStart(force: boolean) {
|
||||||
await handleWhatsAppStartInternal(this, force);
|
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>`,
|
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>`,
|
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>`,
|
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
|
// 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>`,
|
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", () => {
|
it("returns stable icons for known tabs", () => {
|
||||||
expect(iconForTab("chat")).toBe("💬");
|
expect(iconForTab("chat")).toBe("messageSquare");
|
||||||
expect(iconForTab("overview")).toBe("📊");
|
expect(iconForTab("overview")).toBe("barChart");
|
||||||
expect(iconForTab("channels")).toBe("🔗");
|
expect(iconForTab("channels")).toBe("link");
|
||||||
expect(iconForTab("instances")).toBe("📡");
|
expect(iconForTab("instances")).toBe("radio");
|
||||||
expect(iconForTab("sessions")).toBe("📄");
|
expect(iconForTab("sessions")).toBe("fileText");
|
||||||
expect(iconForTab("cron")).toBe("⏰");
|
expect(iconForTab("cron")).toBe("loader");
|
||||||
expect(iconForTab("skills")).toBe("⚡️");
|
expect(iconForTab("skills")).toBe("zap");
|
||||||
expect(iconForTab("nodes")).toBe("🖥️");
|
expect(iconForTab("nodes")).toBe("monitor");
|
||||||
expect(iconForTab("config")).toBe("⚙️");
|
expect(iconForTab("config")).toBe("settings");
|
||||||
expect(iconForTab("debug")).toBe("🐞");
|
expect(iconForTab("debug")).toBe("bug");
|
||||||
expect(iconForTab("logs")).toBe("🧾");
|
expect(iconForTab("logs")).toBe("scrollText");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a fallback icon for unknown tab", () => {
|
it("returns a fallback icon for unknown tab", () => {
|
||||||
// TypeScript won't allow this normally, but runtime could receive unexpected values
|
// TypeScript won't allow this normally, but runtime could receive unexpected values
|
||||||
const unknownTab = "unknown" as Tab;
|
const unknownTab = "unknown" as Tab;
|
||||||
expect(iconForTab(unknownTab)).toBe("📁");
|
expect(iconForTab(unknownTab)).toBe("folder");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("titleForTab", () => {
|
describe("titleForTab", () => {
|
||||||
it("returns a non-empty string for every tab", () => {
|
it("returns a non-empty string for every tab", () => {
|
||||||
for (const tab of ALL_TABS) {
|
for (const tab of ALL_TABS) {
|
||||||
const title = titleForTab(tab);
|
const title = titleForTab(tab, "en");
|
||||||
expect(title).toBeTruthy();
|
expect(title).toBeTruthy();
|
||||||
expect(typeof title).toBe("string");
|
expect(typeof title).toBe("string");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns expected titles", () => {
|
it("returns expected titles", () => {
|
||||||
expect(titleForTab("chat")).toBe("Chat");
|
expect(titleForTab("chat", "en")).toBe("Chat");
|
||||||
expect(titleForTab("overview")).toBe("Overview");
|
expect(titleForTab("overview", "en")).toBe("Overview");
|
||||||
expect(titleForTab("cron")).toBe("Cron Jobs");
|
expect(titleForTab("cron", "en")).toBe("Cron Jobs");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("subtitleForTab", () => {
|
describe("subtitleForTab", () => {
|
||||||
it("returns a string for every tab", () => {
|
it("returns a string for every tab", () => {
|
||||||
for (const tab of ALL_TABS) {
|
for (const tab of ALL_TABS) {
|
||||||
const subtitle = subtitleForTab(tab);
|
const subtitle = subtitleForTab(tab, "en");
|
||||||
expect(typeof subtitle).toBe("string");
|
expect(typeof subtitle).toBe("string");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns descriptive subtitles", () => {
|
it("returns descriptive subtitles", () => {
|
||||||
expect(subtitleForTab("chat")).toContain("chat session");
|
expect(subtitleForTab("chat", "en")).toContain("chat session");
|
||||||
expect(subtitleForTab("config")).toContain("moltbot.json");
|
expect(subtitleForTab("config", "en")).toContain("moltbot.json");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -175,11 +175,11 @@ describe("inferBasePathFromPathname", () => {
|
|||||||
|
|
||||||
describe("TAB_GROUPS", () => {
|
describe("TAB_GROUPS", () => {
|
||||||
it("contains all expected groups", () => {
|
it("contains all expected groups", () => {
|
||||||
const labels = TAB_GROUPS.map((g) => g.label);
|
const ids = TAB_GROUPS.map((g) => g.id);
|
||||||
expect(labels).toContain("Chat");
|
expect(ids).toContain("chat");
|
||||||
expect(labels).toContain("Control");
|
expect(ids).toContain("control");
|
||||||
expect(labels).toContain("Agent");
|
expect(ids).toContain("agent");
|
||||||
expect(labels).toContain("Settings");
|
expect(ids).toContain("settings");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("all tabs are unique", () => {
|
it("all tabs are unique", () => {
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
import type { IconName } from "./icons.js";
|
import type { IconName } from "./icons.js";
|
||||||
|
import { t, type Locale } from "./i18n";
|
||||||
|
|
||||||
export const TAB_GROUPS = [
|
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"],
|
tabs: ["overview", "channels", "instances", "sessions", "cron"],
|
||||||
},
|
},
|
||||||
{ label: "Agent", tabs: ["skills", "nodes"] },
|
{ id: "agent", labelKey: "nav.group.agent", tabs: ["skills", "nodes"] },
|
||||||
{ label: "Settings", tabs: ["config", "debug", "logs"] },
|
{ id: "settings", labelKey: "nav.group.settings", tabs: ["config", "debug", "logs"] },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Tab =
|
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) {
|
switch (tab) {
|
||||||
case "overview":
|
case "overview":
|
||||||
return "Overview";
|
return t(locale, "tab.overview");
|
||||||
case "channels":
|
case "channels":
|
||||||
return "Channels";
|
return t(locale, "tab.channels");
|
||||||
case "instances":
|
case "instances":
|
||||||
return "Instances";
|
return t(locale, "tab.instances");
|
||||||
case "sessions":
|
case "sessions":
|
||||||
return "Sessions";
|
return t(locale, "tab.sessions");
|
||||||
case "cron":
|
case "cron":
|
||||||
return "Cron Jobs";
|
return t(locale, "tab.cron");
|
||||||
case "skills":
|
case "skills":
|
||||||
return "Skills";
|
return t(locale, "tab.skills");
|
||||||
case "nodes":
|
case "nodes":
|
||||||
return "Nodes";
|
return t(locale, "tab.nodes");
|
||||||
case "chat":
|
case "chat":
|
||||||
return "Chat";
|
return t(locale, "tab.chat");
|
||||||
case "config":
|
case "config":
|
||||||
return "Config";
|
return t(locale, "tab.config");
|
||||||
case "debug":
|
case "debug":
|
||||||
return "Debug";
|
return t(locale, "tab.debug");
|
||||||
case "logs":
|
case "logs":
|
||||||
return "Logs";
|
return t(locale, "tab.logs");
|
||||||
default:
|
default:
|
||||||
return "Control";
|
return t(locale, "nav.group.control");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subtitleForTab(tab: Tab) {
|
export function subtitleForTab(tab: Tab, locale: Locale) {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "overview":
|
case "overview":
|
||||||
return "Gateway status, entry points, and a fast health read.";
|
return t(locale, "subtitle.overview");
|
||||||
case "channels":
|
case "channels":
|
||||||
return "Manage channels and settings.";
|
return t(locale, "subtitle.channels");
|
||||||
case "instances":
|
case "instances":
|
||||||
return "Presence beacons from connected clients and nodes.";
|
return t(locale, "subtitle.instances");
|
||||||
case "sessions":
|
case "sessions":
|
||||||
return "Inspect active sessions and adjust per-session defaults.";
|
return t(locale, "subtitle.sessions");
|
||||||
case "cron":
|
case "cron":
|
||||||
return "Schedule wakeups and recurring agent runs.";
|
return t(locale, "subtitle.cron");
|
||||||
case "skills":
|
case "skills":
|
||||||
return "Manage skill availability and API key injection.";
|
return t(locale, "subtitle.skills");
|
||||||
case "nodes":
|
case "nodes":
|
||||||
return "Paired devices, capabilities, and command exposure.";
|
return t(locale, "subtitle.nodes");
|
||||||
case "chat":
|
case "chat":
|
||||||
return "Direct gateway chat session for quick interventions.";
|
return t(locale, "subtitle.chat");
|
||||||
case "config":
|
case "config":
|
||||||
return "Edit ~/.clawdbot/moltbot.json safely.";
|
return t(locale, "subtitle.config");
|
||||||
case "debug":
|
case "debug":
|
||||||
return "Gateway snapshots, events, and manual RPC calls.";
|
return t(locale, "subtitle.debug");
|
||||||
case "logs":
|
case "logs":
|
||||||
return "Live tail of the gateway file logs.";
|
return t(locale, "subtitle.logs");
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
const KEY = "moltbot.control.settings.v1";
|
const KEY = "moltbot.control.settings.v1";
|
||||||
|
|
||||||
import type { ThemeMode } from "./theme";
|
import type { ThemeMode } from "./theme";
|
||||||
|
import type { LanguageSetting } from "./i18n";
|
||||||
|
|
||||||
export type UiSettings = {
|
export type UiSettings = {
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
@ -13,6 +14,7 @@ export type UiSettings = {
|
|||||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
||||||
navCollapsed: boolean; // Collapsible sidebar state
|
navCollapsed: boolean; // Collapsible sidebar state
|
||||||
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
||||||
|
language: LanguageSetting;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function loadSettings(): UiSettings {
|
export function loadSettings(): UiSettings {
|
||||||
@ -32,6 +34,27 @@ export function loadSettings(): UiSettings {
|
|||||||
splitRatio: 0.6,
|
splitRatio: 0.6,
|
||||||
navCollapsed: false,
|
navCollapsed: false,
|
||||||
navGroupsCollapsed: {},
|
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 {
|
try {
|
||||||
@ -79,11 +102,11 @@ export function loadSettings(): UiSettings {
|
|||||||
typeof parsed.navCollapsed === "boolean"
|
typeof parsed.navCollapsed === "boolean"
|
||||||
? parsed.navCollapsed
|
? parsed.navCollapsed
|
||||||
: defaults.navCollapsed,
|
: defaults.navCollapsed,
|
||||||
navGroupsCollapsed:
|
navGroupsCollapsed: normalizeNavGroupsCollapsed(parsed.navGroupsCollapsed),
|
||||||
typeof parsed.navGroupsCollapsed === "object" &&
|
language:
|
||||||
parsed.navGroupsCollapsed !== null
|
parsed.language === "en" || parsed.language === "ar" || parsed.language === "system"
|
||||||
? parsed.navGroupsCollapsed
|
? parsed.language
|
||||||
: defaults.navGroupsCollapsed,
|
: defaults.language,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return defaults;
|
return defaults;
|
||||||
|
|||||||
@ -16,6 +16,7 @@ function createSessions(): SessionsListResult {
|
|||||||
|
|
||||||
function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
|
function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
|
||||||
return {
|
return {
|
||||||
|
locale: "en",
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
onSessionKeyChange: () => undefined,
|
onSessionKeyChange: () => undefined,
|
||||||
thinkingLevel: null,
|
thinkingLevel: null,
|
||||||
@ -45,6 +46,13 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
|
|||||||
onSend: () => undefined,
|
onSend: () => undefined,
|
||||||
onQueueRemove: () => undefined,
|
onQueueRemove: () => undefined,
|
||||||
onNewSession: () => undefined,
|
onNewSession: () => undefined,
|
||||||
|
voiceSupported: true,
|
||||||
|
voiceListening: false,
|
||||||
|
voiceSpeaking: false,
|
||||||
|
voiceError: null,
|
||||||
|
onToggleVoiceInput: () => undefined,
|
||||||
|
onSpeakLastReply: () => undefined,
|
||||||
|
onStopSpeaking: () => undefined,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
} from "../chat/grouped-render";
|
} from "../chat/grouped-render";
|
||||||
import { renderMarkdownSidebar } from "./markdown-sidebar";
|
import { renderMarkdownSidebar } from "./markdown-sidebar";
|
||||||
import "../components/resizable-divider";
|
import "../components/resizable-divider";
|
||||||
|
import { t, type Locale } from "../i18n";
|
||||||
|
|
||||||
export type CompactionIndicatorStatus = {
|
export type CompactionIndicatorStatus = {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@ -24,6 +25,7 @@ export type CompactionIndicatorStatus = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ChatProps = {
|
export type ChatProps = {
|
||||||
|
locale: Locale;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
onSessionKeyChange: (next: string) => void;
|
onSessionKeyChange: (next: string) => void;
|
||||||
thinkingLevel: string | null;
|
thinkingLevel: string | null;
|
||||||
@ -68,6 +70,13 @@ export type ChatProps = {
|
|||||||
onCloseSidebar?: () => void;
|
onCloseSidebar?: () => void;
|
||||||
onSplitRatioChange?: (ratio: number) => void;
|
onSplitRatioChange?: (ratio: number) => void;
|
||||||
onChatScroll?: (event: Event) => 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;
|
const COMPACTION_TOAST_DURATION_MS = 5000;
|
||||||
@ -77,14 +86,17 @@ function adjustTextareaHeight(el: HTMLTextAreaElement) {
|
|||||||
el.style.height = `${el.scrollHeight}px`;
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCompactionIndicator(status: CompactionIndicatorStatus | null | undefined) {
|
function renderCompactionIndicator(
|
||||||
|
status: CompactionIndicatorStatus | null | undefined,
|
||||||
|
locale: Locale,
|
||||||
|
) {
|
||||||
if (!status) return nothing;
|
if (!status) return nothing;
|
||||||
|
|
||||||
// Show "compacting..." while active
|
// Show "compacting..." while active
|
||||||
if (status.active) {
|
if (status.active) {
|
||||||
return html`
|
return html`
|
||||||
<div class="callout info compaction-indicator compaction-indicator--active">
|
<div class="callout info compaction-indicator compaction-indicator--active">
|
||||||
${icons.loader} Compacting context...
|
${icons.loader} ${t(locale, "chat.compaction.active")}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -95,7 +107,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
|
|||||||
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
|
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
|
||||||
return html`
|
return html`
|
||||||
<div class="callout success compaction-indicator compaction-indicator--complete">
|
<div class="callout success compaction-indicator compaction-indicator--complete">
|
||||||
${icons.check} Context compacted
|
${icons.check} ${t(locale, "chat.compaction.done")}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -157,13 +169,13 @@ function renderAttachmentPreview(props: ChatProps) {
|
|||||||
<div class="chat-attachment">
|
<div class="chat-attachment">
|
||||||
<img
|
<img
|
||||||
src=${att.dataUrl}
|
src=${att.dataUrl}
|
||||||
alt="Attachment preview"
|
alt=${t(props.locale, "chat.compose.attachment.preview")}
|
||||||
class="chat-attachment__img"
|
class="chat-attachment__img"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="chat-attachment__remove"
|
class="chat-attachment__remove"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Remove attachment"
|
aria-label=${t(props.locale, "chat.compose.attachment.remove")}
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
const next = (props.attachments ?? []).filter(
|
const next = (props.attachments ?? []).filter(
|
||||||
(a) => a.id !== att.id,
|
(a) => a.id !== att.id,
|
||||||
@ -197,9 +209,9 @@ export function renderChat(props: ChatProps) {
|
|||||||
const hasAttachments = (props.attachments?.length ?? 0) > 0;
|
const hasAttachments = (props.attachments?.length ?? 0) > 0;
|
||||||
const composePlaceholder = props.connected
|
const composePlaceholder = props.connected
|
||||||
? hasAttachments
|
? hasAttachments
|
||||||
? "Add a message or paste more images..."
|
? t(props.locale, "chat.compose.placeholder.attachments")
|
||||||
: "Message (↩ to send, Shift+↩ for line breaks, paste images)"
|
: t(props.locale, "chat.compose.placeholder")
|
||||||
: "Connect to the gateway to start chatting…";
|
: t(props.locale, "chat.compose.placeholder.disconnected");
|
||||||
|
|
||||||
const splitRatio = props.splitRatio ?? 0.6;
|
const splitRatio = props.splitRatio ?? 0.6;
|
||||||
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
|
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
|
||||||
@ -210,7 +222,9 @@ export function renderChat(props: ChatProps) {
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
@scroll=${props.onChatScroll}
|
@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) => {
|
${repeat(buildChatItems(props), (item) => item.key, (item) => {
|
||||||
if (item.kind === "reading-indicator") {
|
if (item.kind === "reading-indicator") {
|
||||||
return renderReadingIndicatorGroup(assistantIdentity);
|
return renderReadingIndicatorGroup(assistantIdentity);
|
||||||
@ -249,7 +263,11 @@ export function renderChat(props: ChatProps) {
|
|||||||
? html`<div class="callout danger">${props.error}</div>`
|
? html`<div class="callout danger">${props.error}</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${renderCompactionIndicator(props.compactionStatus)}
|
${props.voiceError
|
||||||
|
? html`<div class="callout warn">${props.voiceError}</div>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
|
${renderCompactionIndicator(props.compactionStatus, props.locale)}
|
||||||
|
|
||||||
${props.focusMode
|
${props.focusMode
|
||||||
? html`
|
? html`
|
||||||
@ -257,8 +275,8 @@ export function renderChat(props: ChatProps) {
|
|||||||
class="chat-focus-exit"
|
class="chat-focus-exit"
|
||||||
type="button"
|
type="button"
|
||||||
@click=${props.onToggleFocusMode}
|
@click=${props.onToggleFocusMode}
|
||||||
aria-label="Exit focus mode"
|
aria-label=${t(props.locale, "chat.focus.exit")}
|
||||||
title="Exit focus mode"
|
title=${t(props.locale, "chat.focus.exit")}
|
||||||
>
|
>
|
||||||
${icons.x}
|
${icons.x}
|
||||||
</button>
|
</button>
|
||||||
@ -300,7 +318,9 @@ export function renderChat(props: ChatProps) {
|
|||||||
${props.queue.length
|
${props.queue.length
|
||||||
? html`
|
? html`
|
||||||
<div class="chat-queue" role="status" aria-live="polite">
|
<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">
|
<div class="chat-queue__list">
|
||||||
${props.queue.map(
|
${props.queue.map(
|
||||||
(item) => html`
|
(item) => html`
|
||||||
@ -308,13 +328,15 @@ export function renderChat(props: ChatProps) {
|
|||||||
<div class="chat-queue__text">
|
<div class="chat-queue__text">
|
||||||
${item.text ||
|
${item.text ||
|
||||||
(item.attachments?.length
|
(item.attachments?.length
|
||||||
? `Image (${item.attachments.length})`
|
? t(props.locale, "chat.queue.image", {
|
||||||
|
count: item.attachments.length,
|
||||||
|
})
|
||||||
: "")}
|
: "")}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="btn chat-queue__remove"
|
class="btn chat-queue__remove"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Remove queued message"
|
aria-label=${t(props.locale, "chat.queue.remove")}
|
||||||
@click=${() => props.onQueueRemove(item.id)}
|
@click=${() => props.onQueueRemove(item.id)}
|
||||||
>
|
>
|
||||||
${icons.x}
|
${icons.x}
|
||||||
@ -331,7 +353,7 @@ export function renderChat(props: ChatProps) {
|
|||||||
${renderAttachmentPreview(props)}
|
${renderAttachmentPreview(props)}
|
||||||
<div class="chat-compose__row">
|
<div class="chat-compose__row">
|
||||||
<label class="field chat-compose__field">
|
<label class="field chat-compose__field">
|
||||||
<span>Message</span>
|
<span>${t(props.locale, "chat.compose.label")}</span>
|
||||||
<textarea
|
<textarea
|
||||||
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
|
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
|
||||||
.value=${props.draft}
|
.value=${props.draft}
|
||||||
@ -359,14 +381,48 @@ export function renderChat(props: ChatProps) {
|
|||||||
?disabled=${!props.connected || (!canAbort && props.sending)}
|
?disabled=${!props.connected || (!canAbort && props.sending)}
|
||||||
@click=${canAbort ? props.onAbort : props.onNewSession}
|
@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>
|
||||||
<button
|
<button
|
||||||
class="btn primary"
|
class="btn primary"
|
||||||
?disabled=${!props.connected}
|
?disabled=${!props.connected}
|
||||||
@click=${props.onSend}
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -423,11 +479,14 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
|
|||||||
items.push({
|
items.push({
|
||||||
kind: "message",
|
kind: "message",
|
||||||
key: "chat:history:notice",
|
key: "chat:history:notice",
|
||||||
message: {
|
message: {
|
||||||
role: "system",
|
role: "system",
|
||||||
content: `Showing last ${CHAT_HISTORY_RENDER_LIMIT} messages (${historyStart} hidden).`,
|
content: t(props.locale, "chat.history.notice", {
|
||||||
timestamp: Date.now(),
|
limit: CHAT_HISTORY_RENDER_LIMIT,
|
||||||
},
|
hidden: historyStart,
|
||||||
|
}),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (let i = historyStart; i < history.length; i++) {
|
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