feat(transcription): add shared voice input support (#4232)
* feat(webui): add voice transcription input * feat(webui): render ANSI output in code blocks * refactor(webui): isolate voice recorder logic * refactor(transcription): keep websocket ingress thin * refactor(transcription): resolve channel audio settings on demand * style(webui): neutralize voice waveform color * feat(webui): add voice input tooltip * feat(webui): add voice input keyboard shortcut * fix(webui): distinguish voice shortcut platforms * fix(webui): place voice button after model selector * refactor(webui): share voice hold recording helpers * fix(desktop): allow microphone voice input * fix(webui): stabilize token usage month labels * feat(webui): show voice input on settings overview * fix(webui): label voice capability as recognition * fix(webui): align capability overview status * refactor(webui): isolate transcription socket handling * fix(webui): soften silent voice waveform * refactor(audio): clarify transcription service location * docs(transcription): clarify audio and provider boundaries * fix(exec): reduce session output polling flake
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { Suspense, lazy, useCallback, useState } from "react";
|
||||
import { Suspense, lazy, useCallback, useState, type ReactNode } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useThemeValue } from "@/hooks/useTheme";
|
||||
import { hasAnsi, parseAnsiSegments, stripAnsi } from "@/lib/ansi";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CodeBlockProps {
|
||||
@@ -36,6 +37,10 @@ const CODE_FONT_STACK = [
|
||||
"monospace",
|
||||
].join(", ");
|
||||
|
||||
const ANSI_LANGUAGES = new Set(["ansi", "ansi-output"]);
|
||||
const CODE_SURFACE_LIGHT = "#f4f4f5";
|
||||
const CODE_SURFACE_DARK = "#27272a";
|
||||
|
||||
const LazyHighlightedCode = lazy(async () => {
|
||||
const [
|
||||
{ default: SyntaxHighlighter },
|
||||
@@ -74,7 +79,11 @@ const LazyHighlightedCode = lazy(async () => {
|
||||
language={language || "text"}
|
||||
style={transparentTheme}
|
||||
customStyle={{
|
||||
background: chrome === "none" ? "transparent" : undefined,
|
||||
background: chrome === "none"
|
||||
? "transparent"
|
||||
: isDark
|
||||
? CODE_SURFACE_DARK
|
||||
: CODE_SURFACE_LIGHT,
|
||||
margin: 0,
|
||||
padding: chrome === "none" ? "0.75rem 1rem" : "1rem",
|
||||
fontFamily: CODE_FONT_STACK,
|
||||
@@ -83,10 +92,10 @@ const LazyHighlightedCode = lazy(async () => {
|
||||
tabSize: 2,
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: chrome === "none" ? {
|
||||
style: {
|
||||
background: "transparent",
|
||||
fontFamily: CODE_FONT_STACK,
|
||||
} : undefined,
|
||||
},
|
||||
}}
|
||||
lineNumberStyle={{
|
||||
minWidth: "2.6em",
|
||||
@@ -106,14 +115,32 @@ const LazyHighlightedCode = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
function PlainCodeFallback({
|
||||
function renderPlainText(value: string): ReactNode {
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderAnsiText(value: string): ReactNode {
|
||||
return parseAnsiSegments(value).map((segment, index) => (
|
||||
<span key={index} style={segment.style}>
|
||||
{segment.text}
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
function CodeTextBlock({
|
||||
code,
|
||||
chrome,
|
||||
showLineNumbers,
|
||||
testId,
|
||||
className,
|
||||
renderText = renderPlainText,
|
||||
}: {
|
||||
code: string;
|
||||
chrome: "default" | "none";
|
||||
showLineNumbers: boolean;
|
||||
testId: string;
|
||||
className?: string;
|
||||
renderText?: (value: string) => ReactNode;
|
||||
}) {
|
||||
const lines = code.split("\n");
|
||||
return (
|
||||
@@ -121,10 +148,11 @@ function PlainCodeFallback({
|
||||
className={cn(
|
||||
"m-0 overflow-x-auto p-4 font-mono text-sm leading-[1.6] text-foreground/90",
|
||||
showLineNumbers ? "whitespace-pre" : "whitespace-pre-wrap",
|
||||
chrome === "default" ? "bg-background" : "bg-transparent",
|
||||
chrome === "default" ? "bg-zinc-100 dark:bg-zinc-800" : "bg-transparent",
|
||||
chrome === "none" && "p-3 text-[13px] leading-[1.55]",
|
||||
className,
|
||||
)}
|
||||
data-testid="plain-code-fallback"
|
||||
data-testid={testId}
|
||||
>
|
||||
<code className="text-inherit">
|
||||
{showLineNumbers ? (
|
||||
@@ -133,16 +161,21 @@ function PlainCodeFallback({
|
||||
<span className="w-10 shrink-0 select-none pr-4 text-right text-muted-foreground/60">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="whitespace-pre">{line || " "}</span>
|
||||
<span className="whitespace-pre">{renderText(line || " ")}</span>
|
||||
{index < lines.length - 1 ? "\n" : null}
|
||||
</span>
|
||||
))
|
||||
) : code}
|
||||
) : renderText(code)}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRenderAnsi(language: string | undefined, code: string): boolean {
|
||||
const normalized = language?.trim().toLowerCase();
|
||||
return Boolean((normalized && ANSI_LANGUAGES.has(normalized)) || hasAnsi(code));
|
||||
}
|
||||
|
||||
export function CodeBlock({
|
||||
language,
|
||||
code,
|
||||
@@ -156,19 +189,20 @@ export function CodeBlock({
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isDark = useThemeValue() === "dark";
|
||||
const hasChrome = chrome === "default";
|
||||
const renderAnsi = shouldRenderAnsi(language, code);
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (!navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
navigator.clipboard.writeText(renderAnsi ? stripAnsi(code) : code).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1_500);
|
||||
});
|
||||
}, [code]);
|
||||
}, [code, renderAnsi]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden",
|
||||
"not-prose overflow-hidden",
|
||||
hasChrome && "rounded-lg border",
|
||||
hasChrome && (isDark ? "border-white/10" : "border-black/10"),
|
||||
className,
|
||||
@@ -177,7 +211,7 @@ export function CodeBlock({
|
||||
{hasChrome ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 py-1.5 text-xs font-medium",
|
||||
"flex items-center justify-between px-4 pb-1.5 pt-2 text-xs font-medium",
|
||||
isDark
|
||||
? "bg-zinc-800 text-zinc-300"
|
||||
: "bg-zinc-100 text-zinc-600",
|
||||
@@ -206,13 +240,22 @@ export function CodeBlock({
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{highlight ? (
|
||||
{renderAnsi ? (
|
||||
<CodeTextBlock
|
||||
code={code}
|
||||
chrome={chrome}
|
||||
showLineNumbers={showLineNumbers}
|
||||
testId="ansi-code"
|
||||
renderText={renderAnsiText}
|
||||
/>
|
||||
) : highlight ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<PlainCodeFallback
|
||||
<CodeTextBlock
|
||||
code={code}
|
||||
chrome={chrome}
|
||||
showLineNumbers={showLineNumbers}
|
||||
testId="plain-code-fallback"
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -226,10 +269,11 @@ export function CodeBlock({
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<PlainCodeFallback
|
||||
<CodeTextBlock
|
||||
code={code}
|
||||
chrome={chrome}
|
||||
showLineNumbers={showLineNumbers}
|
||||
testId="plain-code-fallback"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Layers,
|
||||
Loader2,
|
||||
LogOut,
|
||||
Mic,
|
||||
Moon,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
@@ -92,6 +93,7 @@ import {
|
||||
updateNetworkSafetySettings,
|
||||
updateProviderSettings,
|
||||
updateSettings,
|
||||
updateTranscriptionSettings,
|
||||
updateWebSearchSettings,
|
||||
} from "@/lib/api";
|
||||
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
|
||||
@@ -115,6 +117,7 @@ import type {
|
||||
ProviderModelsPayload,
|
||||
SettingsPayload,
|
||||
SkillSummary,
|
||||
TranscriptionSettingsUpdate,
|
||||
WebSearchSettingsUpdate,
|
||||
WebuiDefaultAccessMode,
|
||||
} from "@/lib/types";
|
||||
@@ -124,6 +127,7 @@ export type SettingsSectionKey =
|
||||
| "appearance"
|
||||
| "models"
|
||||
| "image"
|
||||
| "voice"
|
||||
| "browser"
|
||||
| "apps"
|
||||
| "skills"
|
||||
@@ -367,6 +371,26 @@ const DEFAULT_IMAGE_GENERATION_FORM: ImageGenerationSettingsUpdate = {
|
||||
maxImagesPerTurn: 4,
|
||||
};
|
||||
|
||||
const DEFAULT_TRANSCRIPTION_FORM: TranscriptionSettingsUpdate = {
|
||||
enabled: true,
|
||||
provider: "groq",
|
||||
model: "",
|
||||
language: "",
|
||||
maxDurationSec: 120,
|
||||
maxUploadMb: 25,
|
||||
};
|
||||
|
||||
const DEFAULT_TRANSCRIPTION_SETTINGS: NonNullable<SettingsPayload["transcription"]> = {
|
||||
enabled: true,
|
||||
provider: "groq",
|
||||
provider_configured: false,
|
||||
model: "whisper-large-v3",
|
||||
language: null,
|
||||
max_duration_sec: 120,
|
||||
max_upload_mb: 25,
|
||||
providers: [],
|
||||
};
|
||||
|
||||
const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = {
|
||||
webuiAllowLocalServiceAccess: true,
|
||||
webuiDefaultAccessMode: "default",
|
||||
@@ -419,6 +443,18 @@ function imageGenerationFormFromPayload(payload: SettingsPayload): ImageGenerati
|
||||
};
|
||||
}
|
||||
|
||||
function transcriptionFormFromPayload(payload: SettingsPayload): TranscriptionSettingsUpdate {
|
||||
const transcription = payload.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
||||
return {
|
||||
enabled: transcription.enabled,
|
||||
provider: transcription.provider,
|
||||
model: transcription.model,
|
||||
language: transcription.language ?? "",
|
||||
maxDurationSec: transcription.max_duration_sec,
|
||||
maxUploadMb: transcription.max_upload_mb,
|
||||
};
|
||||
}
|
||||
|
||||
function networkSafetyFormFromPayload(payload: SettingsPayload): NetworkSafetySettingsUpdate {
|
||||
return {
|
||||
webuiAllowLocalServiceAccess:
|
||||
@@ -479,6 +515,7 @@ export function SettingsView({
|
||||
const [providerSaving, setProviderSaving] = useState<string | null>(null);
|
||||
const [webSearchSaving, setWebSearchSaving] = useState(false);
|
||||
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
|
||||
const [transcriptionSaving, setTranscriptionSaving] = useState(false);
|
||||
const [networkSafetySaving, setNetworkSafetySaving] = useState(false);
|
||||
const [hostEngineApplying, setHostEngineApplying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -511,6 +548,9 @@ export function SettingsView({
|
||||
? imageGenerationFormFromPayload(initialSettings)
|
||||
: DEFAULT_IMAGE_GENERATION_FORM,
|
||||
);
|
||||
const [transcriptionForm, setTranscriptionForm] = useState<TranscriptionSettingsUpdate>(
|
||||
() => initialSettings ? transcriptionFormFromPayload(initialSettings) : DEFAULT_TRANSCRIPTION_FORM,
|
||||
);
|
||||
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>(() =>
|
||||
initialSettings ? networkSafetyFormFromPayload(initialSettings) : DEFAULT_NETWORK_SAFETY_FORM,
|
||||
);
|
||||
@@ -543,6 +583,7 @@ export function SettingsView({
|
||||
setForm(agentDraftFromPayload(payload));
|
||||
setWebSearchForm((prev) => webSearchFormFromPayload(payload, prev));
|
||||
setImageGenerationForm(imageGenerationFormFromPayload(payload));
|
||||
setTranscriptionForm(transcriptionFormFromPayload(payload));
|
||||
setNetworkSafetyForm(networkSafetyFormFromPayload(payload));
|
||||
if (payload.restart_required_sections) {
|
||||
setPendingRestartSections(pendingRestartSectionsFromPayload(payload));
|
||||
@@ -711,6 +752,19 @@ export function SettingsView({
|
||||
);
|
||||
}, [imageGenerationForm, settings]);
|
||||
|
||||
const transcriptionDirty = useMemo(() => {
|
||||
if (!settings) return false;
|
||||
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
||||
return (
|
||||
transcriptionForm.enabled !== transcription.enabled ||
|
||||
transcriptionForm.provider !== transcription.provider ||
|
||||
transcriptionForm.model !== transcription.model ||
|
||||
transcriptionForm.language !== (transcription.language ?? "") ||
|
||||
transcriptionForm.maxDurationSec !== transcription.max_duration_sec ||
|
||||
transcriptionForm.maxUploadMb !== transcription.max_upload_mb
|
||||
);
|
||||
}, [settings, transcriptionForm]);
|
||||
|
||||
const networkSafetyDirty = useMemo(() => {
|
||||
if (!settings) return false;
|
||||
const currentLocalServiceAccess =
|
||||
@@ -913,6 +967,24 @@ export function SettingsView({
|
||||
}
|
||||
};
|
||||
|
||||
const saveTranscriptionSettings = async () => {
|
||||
if (!settings || !transcriptionDirty || transcriptionSaving) return;
|
||||
setTranscriptionSaving(true);
|
||||
try {
|
||||
const payload = await updateTranscriptionSettings(token, transcriptionForm);
|
||||
applyPayload(payload);
|
||||
if (payload.requires_restart) {
|
||||
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
||||
}
|
||||
await maybeRestartHostEngine(payload);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setTranscriptionSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveNetworkSafetySettings = async () => {
|
||||
if (!settings || !networkSafetyDirty || networkSafetySaving) return;
|
||||
setNetworkSafetySaving(true);
|
||||
@@ -1333,6 +1405,22 @@ export function SettingsView({
|
||||
requiresRestartPending={pendingRestartSections.image}
|
||||
/>
|
||||
);
|
||||
case "voice":
|
||||
return (
|
||||
<TranscriptionSettings
|
||||
settings={settings}
|
||||
form={transcriptionForm}
|
||||
dirty={transcriptionDirty}
|
||||
saving={transcriptionSaving}
|
||||
onChangeForm={setTranscriptionForm}
|
||||
onSave={saveTranscriptionSettings}
|
||||
onOpenProviders={() => selectSection("models")}
|
||||
showBrandLogos={localPrefs.brandLogos}
|
||||
onRestart={restartViaSettingsSurface}
|
||||
isRestarting={isRestarting || hostEngineApplying}
|
||||
requiresRestartPending={pendingRestartSections.browser}
|
||||
/>
|
||||
);
|
||||
case "browser":
|
||||
return (
|
||||
<WebSettings
|
||||
@@ -1523,6 +1611,7 @@ const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fal
|
||||
{ key: "appearance", icon: Palette, fallback: "Appearance" },
|
||||
{ key: "models", icon: SlidersHorizontal, fallback: "Models" },
|
||||
{ key: "image", icon: ImageIcon, fallback: "Image" },
|
||||
{ key: "voice", icon: Mic, fallback: "Voice" },
|
||||
{ key: "browser", icon: Globe2, fallback: "Web" },
|
||||
{ key: "runtime", icon: Server, fallback: "System" },
|
||||
{ key: "advanced", icon: ShieldCheck, fallback: "Security" },
|
||||
@@ -1642,6 +1731,24 @@ function OverviewSettings({
|
||||
const webStatus = settings.web.enable
|
||||
? tx("settings.values.enabled", "Enabled")
|
||||
: tx("settings.values.disabled", "Disabled");
|
||||
const webSearchProvider =
|
||||
settings.web_search.providers.find((provider) => provider.name === settings.web_search.provider) ??
|
||||
settings.web_search.providers[0];
|
||||
const webSearchProviderLabel = providerDisplayLabel(
|
||||
settings.web_search.providers,
|
||||
settings.web_search.provider,
|
||||
);
|
||||
const webSearchCredentialStatus =
|
||||
webSearchProvider?.credential === "none"
|
||||
? tx("settings.byok.webSearch.noCredentialRequired", "No key required")
|
||||
: webSearchProvider?.credential === "base_url"
|
||||
? settings.web_search.base_url
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured")
|
||||
: settings.web_search.api_key_hint
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured");
|
||||
const webCaption = `${webSearchProviderLabel} · ${webSearchCredentialStatus}`;
|
||||
const imageStatus = settings.image_generation.enabled
|
||||
? tx("settings.values.enabled", "Enabled")
|
||||
: tx("settings.values.disabled", "Disabled");
|
||||
@@ -1650,6 +1757,15 @@ function OverviewSettings({
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured")
|
||||
}`;
|
||||
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
||||
const voiceStatus = transcription.enabled
|
||||
? tx("settings.values.enabled", "Enabled")
|
||||
: tx("settings.values.disabled", "Disabled");
|
||||
const voiceCaption = `${providerDisplayLabel(transcription.providers, transcription.provider)} · ${
|
||||
transcription.provider_configured
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured")
|
||||
}`;
|
||||
const isNativeHost = (settings.surface ?? settings.runtime_surface) === "native";
|
||||
const workspaceCaption = shortWorkspacePath(settings.runtime.workspace_path);
|
||||
const runtimeTitle = isNativeHost
|
||||
@@ -1691,8 +1807,8 @@ function OverviewSettings({
|
||||
icon={Globe2}
|
||||
valueLogoProvider={settings.web_search.provider}
|
||||
title={tx("settings.overview.webSearch", "Web search")}
|
||||
value={providerDisplayLabel(settings.web_search.providers, settings.web_search.provider)}
|
||||
caption={webStatus}
|
||||
value={webStatus}
|
||||
caption={webCaption}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onClick={() => onSelectSection("browser")}
|
||||
/>
|
||||
@@ -1705,6 +1821,15 @@ function OverviewSettings({
|
||||
showBrandLogos={showBrandLogos}
|
||||
onClick={() => onSelectSection("image")}
|
||||
/>
|
||||
<OverviewListRow
|
||||
icon={Mic}
|
||||
valueLogoProvider={transcription.provider}
|
||||
title={tx("settings.overview.voiceInput", "Voice input")}
|
||||
value={voiceStatus}
|
||||
caption={voiceCaption}
|
||||
showBrandLogos={showBrandLogos}
|
||||
onClick={() => onSelectSection("voice")}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
@@ -2654,6 +2779,137 @@ function ImageGenerationSettings({
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptionSettings({
|
||||
settings,
|
||||
form,
|
||||
dirty,
|
||||
saving,
|
||||
onChangeForm,
|
||||
onSave,
|
||||
onOpenProviders,
|
||||
showBrandLogos,
|
||||
onRestart,
|
||||
isRestarting,
|
||||
requiresRestartPending,
|
||||
}: {
|
||||
settings: SettingsPayload;
|
||||
form: TranscriptionSettingsUpdate;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
onChangeForm: Dispatch<SetStateAction<TranscriptionSettingsUpdate>>;
|
||||
onSave: () => void;
|
||||
onOpenProviders: () => void;
|
||||
showBrandLogos: boolean;
|
||||
onRestart?: () => void;
|
||||
isRestarting?: boolean;
|
||||
requiresRestartPending: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
||||
const selectedProvider =
|
||||
transcription.providers.find((provider) => provider.name === form.provider) ??
|
||||
transcription.providers[0];
|
||||
const providerConfigured = !!selectedProvider?.configured;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.sections.voiceInput", "Voice input")}</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.transcription", "Transcription")}
|
||||
description={tx("settings.help.transcription", "Transcribe microphone input before sending it. Chat channel voice messages use the same settings.")}
|
||||
>
|
||||
<ToggleButton
|
||||
checked={form.enabled}
|
||||
onChange={(enabled) => onChangeForm((prev) => ({ ...prev, enabled }))}
|
||||
ariaLabel={tx("settings.rows.transcription", "Transcription")}
|
||||
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.transcriptionProvider", "Provider")}
|
||||
description={tx("settings.help.transcriptionProvider", "Uses the matching provider credentials from Providers.")}
|
||||
>
|
||||
<ProviderPicker
|
||||
providers={transcription.providers}
|
||||
value={form.provider}
|
||||
emptyLabel={tx("settings.voice.selectProvider", "Select provider")}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={(provider) => onChangeForm((prev) => ({ ...prev, provider }))}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.transcriptionProviderStatus", "Provider status")}
|
||||
description={tx("settings.help.transcriptionProviderStatus", "API keys stay under providers, not in transcription settings.")}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<StatusPill tone={providerConfigured ? "success" : "neutral"}>
|
||||
{providerConfigured
|
||||
? tx("settings.values.configured", "Configured")
|
||||
: tx("settings.values.notConfigured", "Not configured")}
|
||||
</StatusPill>
|
||||
{!providerConfigured ? (
|
||||
<Button size="sm" variant="outline" onClick={onOpenProviders} className="rounded-full">
|
||||
{tx("settings.voice.configureProvider", "Configure provider")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.transcriptionModel", "Model")}
|
||||
description={tx("settings.help.transcriptionModel", "Leave as the resolved default unless your provider needs a custom model id.")}
|
||||
>
|
||||
<Input
|
||||
value={form.model}
|
||||
onChange={(event) => onChangeForm((prev) => ({ ...prev, model: event.target.value }))}
|
||||
className="h-8 w-[min(300px,70vw)] rounded-full text-[13px]"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.transcriptionLanguage", "Language")}
|
||||
description={tx("settings.help.transcriptionLanguage", "Optional ISO-639 hint such as en, zh, ja, or ko.")}
|
||||
>
|
||||
<Input
|
||||
value={form.language}
|
||||
onChange={(event) => onChangeForm((prev) => ({ ...prev, language: event.target.value }))}
|
||||
placeholder={tx("settings.voice.languageAuto", "Auto")}
|
||||
className="h-8 w-[min(180px,60vw)] rounded-full text-[13px]"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow title={tx("settings.rows.voiceLimits", "Limits")}>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<NumberInput
|
||||
value={form.maxDurationSec}
|
||||
min={1}
|
||||
max={600}
|
||||
suffix="s"
|
||||
onChange={(maxDurationSec) => onChangeForm((prev) => ({ ...prev, maxDurationSec }))}
|
||||
/>
|
||||
<NumberInput
|
||||
value={form.maxUploadMb}
|
||||
min={1}
|
||||
max={100}
|
||||
suffix="MB"
|
||||
onChange={(maxUploadMb) => onChangeForm((prev) => ({ ...prev, maxUploadMb }))}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<RestartSettingsFooter
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
pendingRestart={requiresRestartPending}
|
||||
dirtyMessage={tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")}
|
||||
pendingMessage={tx("settings.status.savedRestartApply", "Saved. Restart when ready.")}
|
||||
onSave={onSave}
|
||||
onRestart={onRestart}
|
||||
isRestarting={isRestarting}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WebSettings({
|
||||
settings,
|
||||
form,
|
||||
|
||||
@@ -78,16 +78,13 @@ function buildTokenUsageCalendar(
|
||||
const today = utcDateFromIsoDay(isoDayInTimeZone(new Date(), timeZone));
|
||||
const end = addUtcDays(today, 6 - today.getUTCDay());
|
||||
const start = addUtcDays(end, -(TOKEN_HEATMAP_CELLS - 1));
|
||||
const seenMonths = new Set<string>();
|
||||
const monthLabels: TokenUsageMonthLabel[] = [];
|
||||
|
||||
const cells = Array.from({ length: TOKEN_HEATMAP_CELLS }, (_, index) => {
|
||||
const date = addUtcDays(start, index);
|
||||
const key = isoDay(date);
|
||||
const row = byDate.get(key);
|
||||
const monthKey = key.slice(0, 7);
|
||||
if (!seenMonths.has(monthKey)) {
|
||||
seenMonths.add(monthKey);
|
||||
if (date.getUTCDate() === 1) {
|
||||
monthLabels.push({
|
||||
label: monthFormatter.format(date),
|
||||
column: Math.floor(index / 7) + 1,
|
||||
@@ -186,16 +183,12 @@ export function TokenUsageHeatmap({
|
||||
{tx("settings.usage.shortTitle", "Token Usage")}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="mb-2 grid min-h-4 gap-1.5 text-[10px] font-normal leading-4 text-muted-foreground/62"
|
||||
style={{ gridTemplateColumns: `repeat(${TOKEN_HEATMAP_COLUMNS}, minmax(0, 1fr))` }}
|
||||
aria-hidden
|
||||
>
|
||||
<div className="relative mb-2 h-4 text-[10px] font-normal leading-4 text-muted-foreground/62" aria-hidden>
|
||||
{monthLabels.map((month) => (
|
||||
<span
|
||||
key={`${month.label}-${month.column}`}
|
||||
className="whitespace-nowrap"
|
||||
style={{ gridColumnStart: month.column, gridColumnEnd: "span 4" }}
|
||||
className="absolute top-0 whitespace-nowrap"
|
||||
style={{ left: `${((month.column - 1) / TOKEN_HEATMAP_COLUMNS) * 100}%` }}
|
||||
>
|
||||
{month.label}
|
||||
</span>
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
History,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Mic,
|
||||
Plus,
|
||||
RotateCw,
|
||||
Shield,
|
||||
@@ -46,6 +47,12 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
WorkspaceAccessMenu,
|
||||
WorkspaceProjectPicker,
|
||||
@@ -59,6 +66,7 @@ import {
|
||||
} from "@/hooks/useAttachedImages";
|
||||
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
||||
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
|
||||
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
GoalStateWsPayload,
|
||||
@@ -79,6 +87,9 @@ import { cn } from "@/lib/utils";
|
||||
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
|
||||
* deliberately excluded to avoid an embedded-script XSS surface. */
|
||||
const ACCEPT_ATTR = "image/png,image/jpeg,image/webp,image/gif";
|
||||
const VOICE_SHORTCUT_CODE = "KeyD";
|
||||
const VOICE_SHORTCUT_ARIA = "Control+Shift+D";
|
||||
type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows";
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
@@ -86,6 +97,54 @@ function formatBytes(n: number): string {
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function isVoiceShortcutDown(event: KeyboardEvent): boolean {
|
||||
return (
|
||||
event.code === VOICE_SHORTCUT_CODE
|
||||
&& event.ctrlKey
|
||||
&& event.shiftKey
|
||||
&& !event.altKey
|
||||
&& !event.metaKey
|
||||
);
|
||||
}
|
||||
|
||||
function isVoiceShortcutRelease(event: KeyboardEvent): boolean {
|
||||
return (
|
||||
event.code === VOICE_SHORTCUT_CODE
|
||||
|| event.key === "Control"
|
||||
|| event.key === "Shift"
|
||||
);
|
||||
}
|
||||
|
||||
function getVoiceShortcutPlatform(): VoiceShortcutPlatform {
|
||||
if (typeof navigator === "undefined") return "other";
|
||||
const userAgentData = (navigator as Navigator & { userAgentData?: { platform?: string } })
|
||||
.userAgentData;
|
||||
const platform = [
|
||||
userAgentData?.platform,
|
||||
navigator.platform,
|
||||
navigator.userAgent,
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
const isIpadPretendingToBeMac =
|
||||
navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
|
||||
if (isIpadPretendingToBeMac || /mac|iphone|ipad|ipod/.test(platform)) return "apple";
|
||||
if (/win/.test(platform)) return "windows";
|
||||
if (/cros/.test(platform)) return "chromeos";
|
||||
if (/linux|x11|android/.test(platform)) return "linux";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function getVoiceShortcutLabel(): string {
|
||||
switch (getVoiceShortcutPlatform()) {
|
||||
case "apple":
|
||||
return "⌃⇧D";
|
||||
case "chromeos":
|
||||
case "linux":
|
||||
case "windows":
|
||||
case "other":
|
||||
return "Ctrl ⇧ D";
|
||||
}
|
||||
}
|
||||
|
||||
interface ThreadComposerProps {
|
||||
onSend: (content: string, images?: SendImage[], options?: SendOptions) => void;
|
||||
disabled?: boolean;
|
||||
@@ -101,6 +160,7 @@ interface ThreadComposerProps {
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
onStop?: () => void;
|
||||
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
||||
/** Unix seconds from server; turn elapsed timer above input while set. */
|
||||
runStartedAt?: number | null;
|
||||
/** Sustained objective for this chat (WebSocket ``goal_state``). */
|
||||
@@ -138,6 +198,45 @@ const QUEUED_PROMPTS_STORAGE_PREFIX = "nanobot.webui.composerQueuedGuidance.v1:"
|
||||
const QUEUED_PROMPTS_LIMIT = 20;
|
||||
const QUEUED_PROMPT_MAX_CHARS = 4000;
|
||||
|
||||
function VoiceRecordingMeter({
|
||||
ariaLabel,
|
||||
className,
|
||||
elapsedLabel,
|
||||
isHero,
|
||||
levels,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
elapsedLabel: string;
|
||||
isHero: boolean;
|
||||
levels: number[];
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2 text-neutral-700 dark:text-white",
|
||||
isHero ? "h-8" : "h-9",
|
||||
className,
|
||||
)}
|
||||
aria-live="polite"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span className="flex h-5 min-w-0 flex-1 items-center justify-between overflow-hidden" aria-hidden>
|
||||
{levels.map((height, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="w-[2px] rounded-full bg-current opacity-85 transition-[height] duration-75 ease-linear motion-reduce:transition-none"
|
||||
style={{ height }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="min-w-[2.1rem] text-right text-[12px] font-medium tabular-nums text-muted-foreground">
|
||||
{elapsedLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SlashPalettePlacement = "above" | "below";
|
||||
|
||||
interface SlashPaletteLayout {
|
||||
@@ -656,6 +755,7 @@ export function ThreadComposer({
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
onStop,
|
||||
onTranscribeAudio,
|
||||
runStartedAt = null,
|
||||
goalState,
|
||||
workspaceScope = null,
|
||||
@@ -685,7 +785,9 @@ export function ThreadComposer({
|
||||
const wasStreamingRef = useRef(isStreaming);
|
||||
const skipNextQueuedFlushRef = useRef(false);
|
||||
const skipQueuedPromptPersistRef = useRef(false);
|
||||
const voiceShortcutDownRef = useRef(false);
|
||||
const isHero = variant === "hero";
|
||||
const voiceShortcutLabel = useMemo(getVoiceShortcutLabel, []);
|
||||
const queuedPromptStorageKey = useMemo(
|
||||
() => queuedPromptsStorageKey(pendingQueueKey),
|
||||
[pendingQueueKey],
|
||||
@@ -1026,6 +1128,65 @@ export function ThreadComposer({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const appendTranscription = useCallback((text: string) => {
|
||||
const transcript = text.trim();
|
||||
if (!transcript) return;
|
||||
setValue((current) => {
|
||||
if (!current.trim()) return transcript;
|
||||
const separator = /[\s\n]$/.test(current) ? "" : " ";
|
||||
return `${current}${separator}${transcript}`;
|
||||
});
|
||||
setSlashMenuDismissed(false);
|
||||
setCliAppMenuDismissed(false);
|
||||
setInlineError(null);
|
||||
resizeTextarea();
|
||||
}, [resizeTextarea]);
|
||||
|
||||
const clearInlineError = useCallback(() => setInlineError(null), []);
|
||||
const setVoiceError = useCallback((key: VoiceRecorderErrorKey) => {
|
||||
setInlineError(t(`thread.composer.voiceErrors.${key}`));
|
||||
}, [t]);
|
||||
const voiceRecorder = useVoiceRecorder({
|
||||
disabled,
|
||||
onClearError: clearInlineError,
|
||||
onError: setVoiceError,
|
||||
onTranscript: appendTranscription,
|
||||
onTranscribeAudio,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!onTranscribeAudio) return;
|
||||
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
if (!isVoiceShortcutDown(event) || event.repeat || voiceShortcutDownRef.current) return;
|
||||
event.preventDefault();
|
||||
voiceShortcutDownRef.current = true;
|
||||
voiceRecorder.beginShortcutHold();
|
||||
}
|
||||
|
||||
function onKeyUp(event: KeyboardEvent): void {
|
||||
if (!voiceShortcutDownRef.current || !isVoiceShortcutRelease(event)) return;
|
||||
event.preventDefault();
|
||||
voiceShortcutDownRef.current = false;
|
||||
voiceRecorder.endShortcutHold();
|
||||
}
|
||||
|
||||
function onWindowBlur(): void {
|
||||
if (!voiceShortcutDownRef.current) return;
|
||||
voiceShortcutDownRef.current = false;
|
||||
voiceRecorder.endShortcutHold();
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
window.addEventListener("blur", onWindowBlur);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
window.removeEventListener("blur", onWindowBlur);
|
||||
};
|
||||
}, [onTranscribeAudio, voiceRecorder.beginShortcutHold, voiceRecorder.endShortcutHold]);
|
||||
|
||||
const chooseSlashCommand = useCallback(
|
||||
(command: SlashCommand) => {
|
||||
if (command.command === "/stop" && isStreaming && onStop) {
|
||||
@@ -1341,6 +1502,23 @@ export function ThreadComposer({
|
||||
);
|
||||
|
||||
const attachButtonDisabled = disabled || full;
|
||||
const showVoiceButton = Boolean(onTranscribeAudio);
|
||||
const voiceRecordingStatusLabel = t("thread.composer.voice.recordingStatus", {
|
||||
time: voiceRecorder.elapsedLabel,
|
||||
defaultValue: `Recording ${voiceRecorder.elapsedLabel}`,
|
||||
});
|
||||
const voiceButtonLabel =
|
||||
voiceRecorder.state === "recording"
|
||||
? t("thread.composer.voice.stop")
|
||||
: voiceRecorder.state === "transcribing"
|
||||
? t("thread.composer.voice.transcribing")
|
||||
: t("thread.composer.tools.voice");
|
||||
const voiceButtonTooltip =
|
||||
voiceRecorder.state === "recording"
|
||||
? t("thread.composer.voice.stop")
|
||||
: voiceRecorder.state === "transcribing"
|
||||
? t("thread.composer.voice.transcribing")
|
||||
: t("thread.composer.voice.hint");
|
||||
const showStopButton = isStreaming && !!onStop;
|
||||
const relaxedHeroInput = isHero && images.length === 0 && !isStreaming;
|
||||
const inputTextClasses = cn(
|
||||
@@ -1531,7 +1709,15 @@ export function ThreadComposer({
|
||||
>
|
||||
<Plus className={cn(isHero ? "h-[18px] w-[18px]" : "h-4 w-4")} />
|
||||
</Button>
|
||||
{workspaceScope ? (
|
||||
{voiceRecorder.isRecording ? (
|
||||
<VoiceRecordingMeter
|
||||
ariaLabel={voiceRecordingStatusLabel}
|
||||
className="mx-1 flex-1"
|
||||
elapsedLabel={voiceRecorder.elapsedLabel}
|
||||
isHero={isHero}
|
||||
levels={voiceRecorder.levels}
|
||||
/>
|
||||
) : workspaceScope ? (
|
||||
<WorkspaceAccessMenu
|
||||
scope={workspaceScope}
|
||||
disabled={disabled || workspaceScopeDisabled}
|
||||
@@ -1542,7 +1728,7 @@ export function ThreadComposer({
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn("flex shrink-0 items-center", isHero ? "gap-1.5" : "gap-2")}>
|
||||
{modelLabel ? (
|
||||
{modelLabel && !voiceRecorder.isRecording ? (
|
||||
<ComposerModelBadge
|
||||
label={modelLabel}
|
||||
provider={modelProvider}
|
||||
@@ -1552,6 +1738,53 @@ export function ThreadComposer({
|
||||
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{showVoiceButton ? (
|
||||
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={voiceRecorder.buttonDisabled}
|
||||
aria-label={voiceButtonLabel}
|
||||
aria-keyshortcuts={VOICE_SHORTCUT_ARIA}
|
||||
title={voiceButtonTooltip}
|
||||
onPointerDown={voiceRecorder.beginPress}
|
||||
onPointerUp={voiceRecorder.endPress}
|
||||
onPointerCancel={voiceRecorder.endPress}
|
||||
onClick={voiceRecorder.handleClick}
|
||||
className={cn(
|
||||
"rounded-full border border-transparent text-muted-foreground hover:bg-muted/65 hover:text-foreground",
|
||||
isHero ? "h-8 w-8" : "h-9 w-9",
|
||||
voiceRecorder.isRecording &&
|
||||
"bg-red-500 text-white shadow-[0_8px_20px_rgba(239,68,68,0.22)] hover:bg-red-500 hover:text-white",
|
||||
)}
|
||||
>
|
||||
{voiceRecorder.state === "transcribing" ? (
|
||||
<Loader2 className={cn(isHero ? "h-4 w-4" : "h-4 w-4", "animate-spin")} />
|
||||
) : voiceRecorder.isRecording ? (
|
||||
<Square className={cn(isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} fill="currentColor" />
|
||||
) : (
|
||||
<Mic className={cn(isHero ? "h-4 w-4" : "h-4 w-4")} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
className="flex items-center gap-2 rounded-full border border-border/70 bg-background px-3 py-1.5 text-[13px] font-medium text-foreground shadow-[0_8px_24px_rgba(15,23,42,0.13)] dark:border-white/10 dark:bg-neutral-900 dark:text-white"
|
||||
>
|
||||
<span>{voiceButtonTooltip}</span>
|
||||
{voiceRecorder.state === "idle" ? (
|
||||
<kbd className="rounded-full bg-muted px-2 py-0.5 font-sans text-[12px] font-semibold leading-none text-muted-foreground dark:bg-white/10 dark:text-white/80">
|
||||
{voiceShortcutLabel}
|
||||
</kbd>
|
||||
) : null}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
<Button
|
||||
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
|
||||
size="icon"
|
||||
|
||||
@@ -302,6 +302,7 @@ export function ThreadShell({
|
||||
runStartedAt,
|
||||
goalState,
|
||||
send,
|
||||
transcribeAudio,
|
||||
stop,
|
||||
setMessages,
|
||||
streamError,
|
||||
@@ -642,6 +643,7 @@ export function ThreadShell({
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
onStop={stop}
|
||||
onTranscribeAudio={transcribeAudio}
|
||||
runStartedAt={runStartedAt}
|
||||
goalState={goalState}
|
||||
workspaceScope={workspaceScope}
|
||||
@@ -672,6 +674,7 @@ export function ThreadShell({
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
runStartedAt={runStartedAt}
|
||||
onTranscribeAudio={transcribeAudio}
|
||||
goalState={goalState}
|
||||
workspaceScope={workspaceScope}
|
||||
workspaceDefaultScope={workspaceDefaultScope}
|
||||
|
||||
Reference in New Issue
Block a user