feat: add OpenRouter dictation and speech recognition settings
This commit is contained in:
+113
-15
@@ -3,14 +3,17 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Loader2Icon, MicIcon } from "lucide-react";
|
||||
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
||||
import { dictationUrl } from "@/src/core";
|
||||
import { DEFAULT_STT_MODEL, OPENROUTER_STT_MODEL, STT_MAX_AUDIO_BYTES, WAV_HEADER_BYTES, dictationUrl, dictationWav, type SttModel, type SttLanguage } from "@/src/core";
|
||||
import { LanguageSelect, useI18n } from "@/components/i18n";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
|
||||
/**
|
||||
* Voice dictation — the cloud engine (STT wave 1, 2026-09-17).
|
||||
* Voice dictation — direct Deepgram streaming or whole-recording OpenRouter STT.
|
||||
* MAI-Transcribe-2 records locally, drains the final PCM frame on Stop, then
|
||||
* sends one WAV through /api/stt/transcribe. That route holds the API key.
|
||||
* Both engines buffer text and append once when dictation finishes.
|
||||
*
|
||||
* Architecture (docs/research/stt.md): the renderer captures mic audio in
|
||||
* Deepgram architecture: the renderer captures mic audio in
|
||||
* an AudioWorklet (public/worklets/pcm-capture.js → linear16 16kHz mono),
|
||||
* opens the Deepgram streaming WebSocket DIRECTLY (audio never loops
|
||||
* through our server) with a short-lived token minted by /api/stt/token
|
||||
@@ -107,6 +110,7 @@ export const DEEPGRAM_KEY_ID = "DEEPGRAM_API_KEY";
|
||||
* instead. Exported for pins; the errored idle button's click recognizes
|
||||
* it and deep-links Settings → Keys → the Deepgram row. */
|
||||
export const DICTATION_KEYLESS = "Dictation needs a Deepgram key";
|
||||
export const OPENROUTER_DICTATION_KEYLESS = "Dictation needs an OpenRouter key";
|
||||
|
||||
/** Mint a fresh short-lived token (the route holds the real key
|
||||
* server-side). Module-level: the start path's warm cache and the
|
||||
@@ -239,7 +243,7 @@ export function useDictationTarget(append: (text: string) => void, claimed: bool
|
||||
}, [append, claimed]);
|
||||
}
|
||||
|
||||
export type DictationPhase = "idle" | "connecting" | "recording" | "stopping";
|
||||
export type DictationPhase = "idle" | "connecting" | "recording" | "stopping" | "transcribing";
|
||||
|
||||
export type Dictation = {
|
||||
phase: DictationPhase;
|
||||
@@ -283,6 +287,14 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
const selectedUrl = useRef("");
|
||||
selectedUrl.current = dictationUrl(sttLanguage, general.sttPreferredLanguages);
|
||||
const recordingUrl = useRef(selectedUrl.current);
|
||||
const selected = useRef({ model: general.sttModel ?? DEFAULT_STT_MODEL, language: sttLanguage });
|
||||
selected.current = { model: general.sttModel ?? DEFAULT_STT_MODEL, language: sttLanguage };
|
||||
const recordingConfig = useRef<{ model: SttModel; language: SttLanguage }>(selected.current);
|
||||
const audioFrames = useRef<Uint8Array[]>([]);
|
||||
const audioBytes = useRef(0);
|
||||
const upload = useRef<AbortController | null>(null);
|
||||
const drain = useRef<(() => void) | null>(null);
|
||||
const stopping = useRef(false);
|
||||
const [phase, setPhase] = useState<DictationPhase>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
|
||||
@@ -303,7 +315,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
/** Frames captured before the socket is OPEN (mitigation A): flushed in
|
||||
* order on open; capped at 256 frames (≈32s) with the oldest dropped;
|
||||
* cleared at teardown so a dead run's audio can't leak into the next. */
|
||||
const pendingRef = useRef<ArrayBuffer[]>([]);
|
||||
const pendingRef = useRef<Array<ArrayBuffer | Int16Array>>([]);
|
||||
const genRef = useRef(0); // stale-handler guard (dictate pattern)
|
||||
const safetyRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectsRef = useRef(0);
|
||||
@@ -327,6 +339,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
|
||||
const cleanup = useCallback(() => {
|
||||
genRef.current++;
|
||||
upload.current?.abort();
|
||||
upload.current = null;
|
||||
drain.current?.();
|
||||
drain.current = null;
|
||||
audioFrames.current = [];
|
||||
audioBytes.current = 0;
|
||||
stopping.current = false;
|
||||
dictationActiveFlag = false;
|
||||
setDictating(false);
|
||||
flush(); // the deferred transcript appears when the run ends (owner round 2)
|
||||
@@ -356,7 +375,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
for (const w of waitersRef.current.splice(0)) w();
|
||||
// Pre-mint for the next run (mitigation D) — best-effort; a keyless
|
||||
// page just doesn't warm.
|
||||
warmTokens.refresh();
|
||||
if (selected.current.model === DEFAULT_STT_MODEL) warmTokens.refresh();
|
||||
}, []);
|
||||
|
||||
/** Open the Deepgram socket and wire its messages. `gen` guards staleness. */
|
||||
@@ -427,6 +446,11 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
const start = useCallback(async () => {
|
||||
// Pin the complete configuration once; reconnects must retain both language and hints.
|
||||
recordingUrl.current = selectedUrl.current;
|
||||
recordingConfig.current = { ...selected.current };
|
||||
const batch = recordingConfig.current.model === OPENROUTER_STT_MODEL;
|
||||
audioFrames.current = [];
|
||||
audioBytes.current = 0;
|
||||
stopping.current = false;
|
||||
setDictating(true);
|
||||
dictationActiveFlag = true;
|
||||
setError(null);
|
||||
@@ -442,7 +466,17 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
// the token runs concurrently with it. 127.0.0.1 is a secure
|
||||
// context, so getUserMedia works without TLS; macOS TCC prompts on
|
||||
// first use.
|
||||
const tokenPromise = warmTokens.mint();
|
||||
const controller = new AbortController();
|
||||
upload.current = controller;
|
||||
const tokenPromise = batch ? fetch("/api/stt/transcribe", { signal: controller.signal }).then(async (response) => {
|
||||
if (!response.ok) throw new Error("OpenRouter transcription is unavailable");
|
||||
const status = await response.json() as { configured?: boolean };
|
||||
if (!status.configured) throw new Error(OPENROUTER_DICTATION_KEYLESS);
|
||||
return "";
|
||||
}).catch((error: unknown) => {
|
||||
if (error instanceof Error && error.message === OPENROUTER_DICTATION_KEYLESS) throw error;
|
||||
throw new Error("OpenRouter transcription is unavailable");
|
||||
}) : warmTokens.mint();
|
||||
tokenPromise.catch(() => {}); // surfaced below; no unhandled rejection
|
||||
const micPromise = navigator.mediaDevices.getUserMedia({
|
||||
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
||||
@@ -466,10 +500,26 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
const worklet = new AudioWorkletNode(ctx, "pcm-capture");
|
||||
workletRef.current = worklet;
|
||||
worklet.port.onmessage = (e) => {
|
||||
const msg = e.data as { type: string; rms?: number; buffer?: ArrayBuffer };
|
||||
if (gen !== genRef.current) return;
|
||||
const msg = e.data as { type: string; rms?: number; buffer?: ArrayBuffer | Int16Array };
|
||||
if (msg.type === "level") {
|
||||
levelRef.current = msg.rms ?? 0; // never reset to zero (dictate pattern)
|
||||
} else if (msg.type === "flushed") {
|
||||
drain.current?.();
|
||||
drain.current = null;
|
||||
} else if (msg.type === "audio") {
|
||||
if (batch) {
|
||||
const raw = msg.buffer!;
|
||||
const bytes = raw instanceof ArrayBuffer ? new Uint8Array(raw) : new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
|
||||
if (audioBytes.current + bytes.byteLength + WAV_HEADER_BYTES > STT_MAX_AUDIO_BYTES) {
|
||||
setError("Recording exceeds OpenRouter's 25 MB audio limit");
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
audioFrames.current.push(bytes);
|
||||
audioBytes.current += bytes.byteLength;
|
||||
return;
|
||||
}
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(msg.buffer!);
|
||||
// Mitigation A: not OPEN yet (handshake or reconnect gap) —
|
||||
@@ -511,12 +561,15 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
if (gen !== genRef.current) return;
|
||||
// Mitigation B: the handshake fires the moment the graph is live,
|
||||
// overlapping any remaining token wait — mic-gated, never before it.
|
||||
openSocket(gen, token);
|
||||
if (batch) { if (!stopping.current) setPhase("recording"); }
|
||||
else openSocket(gen, token);
|
||||
}, [openSocket, cleanup]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
const gen = genRef.current;
|
||||
if (phase !== "recording" && phase !== "connecting") return;
|
||||
if (stopping.current) return;
|
||||
stopping.current = true;
|
||||
setPhase("stopping");
|
||||
|
||||
// Mic dies FIRST — no more audio enqueues (dictate pattern). The
|
||||
@@ -525,6 +578,43 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
streamRef.current = null;
|
||||
sourceRef.current?.disconnect();
|
||||
sourceRef.current = null;
|
||||
if (recordingConfig.current.model === OPENROUTER_STT_MODEL) {
|
||||
const worklet = workletRef.current;
|
||||
if (!worklet) { cleanup(); return; }
|
||||
void (async () => {
|
||||
try {
|
||||
// Source is disconnected; drain its last partial PCM frame before wrapping the WAV.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => { drain.current = null; reject(new Error("Could not finish microphone capture")); }, 2000);
|
||||
drain.current = () => { clearTimeout(timer); resolve(); };
|
||||
worklet.port.postMessage({ type: "flush" });
|
||||
});
|
||||
if (gen !== genRef.current) return;
|
||||
worklet.disconnect();
|
||||
workletRef.current = null;
|
||||
if (audioBytes.current === 0) { cleanup(); return; }
|
||||
setPhase("transcribing");
|
||||
const wav = dictationWav(audioFrames.current);
|
||||
audioFrames.current = [];
|
||||
const params = new URLSearchParams(recordingConfig.current);
|
||||
const response = await fetch(`/api/stt/transcribe?${params}`, {
|
||||
method: "POST", headers: { "Content-Type": "audio/wav" }, body: wav,
|
||||
signal: AbortSignal.any([upload.current!.signal, AbortSignal.timeout(75_000)]),
|
||||
});
|
||||
const body = await response.json() as { text?: string; message?: string };
|
||||
if (gen !== genRef.current) return;
|
||||
if (!response.ok) throw new Error(body.message ?? "OpenRouter transcription is unavailable");
|
||||
if (typeof body.text !== "string") throw new Error("OpenRouter returned an invalid transcript");
|
||||
if (body.text.trim()) bufferRef.current.push(body.text.trim());
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
if (gen !== genRef.current) return;
|
||||
setError(error instanceof Error && error.name === "TimeoutError" ? "OpenRouter transcription timed out" : error instanceof Error && error.name === "Error" ? error.message : "OpenRouter transcription is unavailable");
|
||||
cleanup();
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
workletRef.current?.disconnect();
|
||||
workletRef.current = null;
|
||||
|
||||
@@ -587,8 +677,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
// page owns ONE hook instance, and only the mounted page's listener is
|
||||
// attached.
|
||||
useEffect(() => {
|
||||
warmTokens.refresh(); // pre-mint at mount (mitigation D) — silent if keyless
|
||||
}, []);
|
||||
if ((general.sttModel ?? DEFAULT_STT_MODEL) === DEFAULT_STT_MODEL) warmTokens.refresh();
|
||||
}, [general.sttModel]);
|
||||
useEffect(() => () => {
|
||||
// Navigation cancels a pending upload and releases the microphone without inserting text.
|
||||
bufferRef.current = [];
|
||||
cleanup();
|
||||
}, [cleanup]);
|
||||
useEffect(() => {
|
||||
window.addEventListener("lh:kbd-toggle-dictation", toggle);
|
||||
return () => window.removeEventListener("lh:kbd-toggle-dictation", toggle);
|
||||
@@ -631,9 +726,8 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
||||
* idle button deep-links Settings → Keys → the Deepgram row and clears
|
||||
* the error — the surface is clean when the user returns. Any other
|
||||
* error keeps the old behavior (click = retry, which clears). */
|
||||
const keyless = error === DICTATION_KEYLESS;
|
||||
const keyless = error === DICTATION_KEYLESS || error === OPENROUTER_DICTATION_KEYLESS;
|
||||
const [levels, setLevels] = useState<number[]>(() => new Array(METER_CELLS).fill(0));
|
||||
const recording = phase === "recording" || phase === "stopping";
|
||||
/** Adaptive cell count (owner round 2): derived from the meter's
|
||||
* measured width — narrow rows show fewer bars instead of
|
||||
* overflowing under neighbors. */
|
||||
@@ -691,12 +785,12 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
||||
onClick={() => {
|
||||
if (keyless) {
|
||||
clearError();
|
||||
openSettings("keys", DEEPGRAM_KEY_ID);
|
||||
openSettings("keys", error === OPENROUTER_DICTATION_KEYLESS ? "OPENROUTER_API_KEY" : DEEPGRAM_KEY_ID);
|
||||
} else {
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
aria-label={error ? t("Dictation failed: {v0}", { v0: error }) : t("Start dictation")}
|
||||
aria-label={error ? t("Dictation failed: {v0}", { v0: t(error) }) : t("Start dictation")}
|
||||
data-testid="dictation-toggle"
|
||||
// Same blurring as the floating chrome (owner, fix wave round 3),
|
||||
// but lower opacity — the mic stays quieter than the model pills.
|
||||
@@ -710,6 +804,10 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (phase === "transcribing") return <div className="flex h-7 w-full min-w-0 items-center gap-2 text-xs text-muted-foreground" role="status" data-testid="dictation-transcribing">
|
||||
<Loader2Icon className="size-3.5 shrink-0 animate-spin" /><span className="min-w-0 flex-1 truncate">{t("Transcribing…")}</span>
|
||||
<button type="button" onClick={dictation.cancel} className="shrink-0 rounded-sm border border-border px-1.5 py-1 hover:bg-muted">{t("Cancel")}</button>
|
||||
</div>;
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
|
||||
|
||||
+60
-27
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { resolveLanguage, resolveSttLanguage, translator, type GeneralSettings, type Language, type SttLanguage, type Translator } from "@/src/core";
|
||||
import { DEFAULT_STT_MODEL, OPENROUTER_STT_MODEL, STT_MODELS, resolveLanguage, resolveSttLanguage, translator, type GeneralSettings, type Language, type SttLanguage, type SttModel, type Translator } from "@/src/core";
|
||||
import { notifySettingsSaved, useSettingsSaved } from "./settings-signal";
|
||||
import { MenuShell } from "./menu-shell";
|
||||
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT, MenuShell } from "./menu-shell";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { DropdownMenu } from "radix-ui";
|
||||
|
||||
type LanguageField = "language" | "sttLanguage";
|
||||
type I18nValue = {
|
||||
@@ -58,7 +59,7 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: {
|
||||
if (!current.ok) throw new Error("Could not load language settings");
|
||||
const body = await current.json() as { settings?: { general?: GeneralSettings } };
|
||||
const next = { ...(body.settings?.general ?? latest.current) };
|
||||
for (const field of ["language", "sttLanguage", "sttPreferredLanguages", "autoUpdate"] as const) {
|
||||
for (const field of ["language", "sttLanguage", "sttModel", "sttPreferredLanguages", "autoUpdate"] as const) {
|
||||
if (!Object.hasOwn(patch, field)) continue;
|
||||
if (patch[field] === undefined) delete next[field];
|
||||
else Object.assign(next, { [field]: patch[field] });
|
||||
@@ -77,21 +78,27 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: {
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function LanguageSelect({ field, disabled = false, compact = false }: { field: "language" | "sttLanguage"; disabled?: boolean; compact?: boolean }) {
|
||||
const { general, t, saveLanguage, sttLanguage, dictating } = useI18n();
|
||||
export function LanguageSelect(props: { field: LanguageField; disabled?: boolean; compact?: boolean }) {
|
||||
return <GeneralSelect {...props} />;
|
||||
}
|
||||
|
||||
export function SttModelSelect() { return <GeneralSelect field="sttModel" />; }
|
||||
|
||||
function GeneralSelect({ field, disabled = false, compact = false }: { field: LanguageField | "sttModel"; disabled?: boolean; compact?: boolean }) {
|
||||
const { general, t, saveGeneral, sttLanguage, dictating } = useI18n();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const menuId = useId();
|
||||
const locked = disabled || saving || (field === "sttLanguage" && dictating);
|
||||
const locked = disabled || saving || (field !== "language" && dictating);
|
||||
const menuOpen = open && !locked;
|
||||
const compactStt = compact && field === "sttLanguage";
|
||||
const saved = general[field] ?? "auto";
|
||||
const saved = general[field] ?? (field === "sttModel" ? DEFAULT_STT_MODEL : "auto");
|
||||
const selected = compactStt && saved === "auto" ? sttLanguage : saved;
|
||||
const label = t(field === "language" ? "Interface language" : "Speech recognition language");
|
||||
const testid = `language-select-${field}${compact ? "-compact" : ""}`;
|
||||
const options = [
|
||||
const label = t(field === "language" ? "Interface language" : field === "sttModel" ? "Speech recognition model" : "Speech recognition language");
|
||||
const testid = field === "sttModel" ? "stt-model-select" : `language-select-${field}${compact ? "-compact" : ""}`;
|
||||
const options: readonly { value: string; label: string }[] = field === "sttModel" ? STT_MODELS.map((model) => ({ value: model.id, label: model.name })) : [
|
||||
...(!compactStt ? [{ value: "auto", label: t("System default") } as const] : []),
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "ru", label: "Русский" },
|
||||
@@ -119,17 +126,18 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
|
||||
document.removeEventListener("keydown", escape, true);
|
||||
};
|
||||
}, [menuOpen, menuId]);
|
||||
const pick = (value: "auto" | SttLanguage) => {
|
||||
const pick = (value: string) => {
|
||||
if (locked) return;
|
||||
setOpen(false);
|
||||
trigger.current?.focus();
|
||||
if (value === saved) return;
|
||||
setSaving(true); setError(null);
|
||||
void saveLanguage(field, value === "auto" ? undefined : value).catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not save language settings")).finally(() => setSaving(false));
|
||||
const patch = field === "sttModel" ? { sttModel: value as SttModel } : { [field]: value === "auto" ? undefined : value };
|
||||
void saveGeneral(patch).catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not save language settings")).finally(() => setSaving(false));
|
||||
};
|
||||
return <span className={compact ? "flex shrink-0 items-center gap-1" : "flex flex-col items-end gap-1"}>
|
||||
return <span className={compact ? "flex shrink-0 items-center gap-1" : "flex max-w-full shrink-0 flex-col items-end gap-1"}>
|
||||
<button ref={trigger} type="button" aria-label={label} title={label} aria-haspopup="menu" aria-expanded={menuOpen} aria-controls={menuOpen ? menuId : undefined} disabled={locked} data-testid={testid}
|
||||
className={`flex h-7 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-50 ${compact ? "w-24" : "w-36"}`}
|
||||
className={`flex h-7 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-50 ${compact ? "w-24" : field === "sttModel" ? "w-64" : "w-36"}`}
|
||||
onClick={() => setOpen(!menuOpen)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||
@@ -138,7 +146,7 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
|
||||
<span className="min-w-0 flex-1 truncate text-left">{compact && selected === "multi" ? t("Multi") : options.find((option) => option.value === selected)?.label}</span>
|
||||
<span aria-hidden className="text-muted-foreground">▾</span>
|
||||
</button>
|
||||
{menuOpen && <MenuShell anchorRef={trigger} testid={`${testid}-menu`} preferredSide={compact ? "up" : "down"} menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl">
|
||||
{menuOpen && <MenuShell anchorRef={trigger} testid={`${testid}-menu`} preferredSide={compact ? "up" : "down"} menuClass={`flex ${field === "sttModel" ? "w-72" : "w-44"} flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl`}>
|
||||
<div id={menuId} role="menu" aria-label={label} className="flex flex-col gap-0.5">
|
||||
{options.map((option) => <button key={option.value} type="button" role="menuitemradio" aria-checked={selected === option.value} onClick={() => pick(option.value)}
|
||||
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${selected === option.value ? "bg-muted/60 text-card-foreground" : "text-card-foreground"}`}>
|
||||
@@ -155,25 +163,50 @@ export function SttPreferredLanguages() {
|
||||
const { general, sttLanguage, dictating, saveGeneral, t } = useI18n();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const busy = useRef(false);
|
||||
const preferred = general.sttPreferredLanguages ?? [];
|
||||
const locked = sttLanguage !== "multi" || dictating || saving;
|
||||
const unavailable = sttLanguage !== "multi" || general.sttModel === OPENROUTER_STT_MODEL || dictating;
|
||||
const menuOpen = open && !unavailable;
|
||||
const summary = (["en", "ru"] as const).filter((language) => preferred.includes(language)).map((language) => language === "en" ? "English" : "Русский").join(", ") || t("No preference");
|
||||
useEffect(() => { if (unavailable) setOpen(false); }, [unavailable]);
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
window.dispatchEvent(new CustomEvent(MENU_OPEN_EVENT));
|
||||
return () => { window.dispatchEvent(new CustomEvent(MENU_CLOSE_EVENT)); };
|
||||
}, [menuOpen]);
|
||||
const toggle = (language: Language) => {
|
||||
if (locked) return;
|
||||
if (unavailable || busy.current) return;
|
||||
const next = (["en", "ru"] as const).filter((code) => code === language ? !preferred.includes(code) : preferred.includes(code));
|
||||
busy.current = true;
|
||||
setSaving(true); setError(null);
|
||||
void saveGeneral({ sttPreferredLanguages: next })
|
||||
.catch(() => setError("Could not save preferred languages"))
|
||||
.finally(() => setSaving(false));
|
||||
.finally(() => { busy.current = false; setSaving(false); });
|
||||
};
|
||||
return <span className="flex max-w-full flex-col items-start gap-1">
|
||||
<span role="group" aria-label={t("Preferred languages")} className="flex max-w-full flex-wrap gap-2">
|
||||
{(["en", "ru"] as const).map((language) => <button key={language} type="button" role="checkbox" aria-checked={preferred.includes(language)}
|
||||
data-testid={`stt-preferred-${language}`} disabled={locked} onClick={() => toggle(language)}
|
||||
className={`flex h-7 items-center gap-1.5 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${preferred.includes(language) ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50"}`}>
|
||||
<span aria-hidden className="flex size-3.5 shrink-0 items-center justify-center rounded-xs border border-border">{preferred.includes(language) && <CheckIcon className="size-3" />}</span>
|
||||
{language === "en" ? "English" : "Русский"}
|
||||
</button>)}
|
||||
</span>
|
||||
return <span className="flex max-w-full shrink-0 flex-col items-end gap-1">
|
||||
<DropdownMenu.Root open={menuOpen} onOpenChange={setOpen} modal={false}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button type="button" data-testid="stt-preferred-select" aria-label={t("Preferred languages")} title={summary} disabled={unavailable || saving}
|
||||
className={`flex h-7 w-44 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 ${unavailable ? "disabled:cursor-not-allowed disabled:opacity-50" : "disabled:cursor-default"}`}
|
||||
onKeyDown={(event) => { if (event.key === "ArrowUp" && !unavailable && !saving) { event.preventDefault(); event.stopPropagation(); setOpen(true); } }}>
|
||||
<span className="min-w-0 flex-1 truncate text-left">{summary}</span><span aria-hidden className="text-muted-foreground">▾</span>
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content data-testid="stt-preferred-menu" data-lh-menu="" aria-label={t("Preferred languages")} side="bottom" align="end" sideOffset={4} collisionPadding={8} loop
|
||||
className="z-60 flex w-44 max-h-[min(60vh,var(--radix-dropdown-menu-content-available-height))] flex-col gap-0.5 overflow-y-auto rounded-xl border border-border bg-card p-2 text-xs shadow-xl outline-none"
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
onEscapeKeyDown={(event) => event.stopPropagation()}>
|
||||
{(["en", "ru"] as const).map((language) => <DropdownMenu.CheckboxItem key={language} data-testid={`stt-preferred-${language}`} checked={preferred.includes(language)} disabled={saving}
|
||||
onCheckedChange={() => toggle(language)} onSelect={(event) => event.preventDefault()}
|
||||
className={`flex cursor-default select-none items-center gap-2 rounded-lg px-2 py-1.5 text-left text-card-foreground outline-none transition-colors data-highlighted:bg-muted ${preferred.includes(language) ? "bg-muted/60" : ""}`}>
|
||||
<span aria-hidden className="flex size-3.5 shrink-0 items-center justify-center rounded-xs border border-border"><DropdownMenu.ItemIndicator><CheckIcon className="size-3" /></DropdownMenu.ItemIndicator></span>
|
||||
{language === "en" ? "English" : "Русский"}
|
||||
</DropdownMenu.CheckboxItem>)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
|
||||
</span>;
|
||||
}
|
||||
|
||||
@@ -703,7 +703,8 @@ export function AgentSection({
|
||||
<button type="button" data-testid={`slot-fast-${slot}`} aria-label={t("{v0} fast mode", { v0: t(SLOT_META[slot].label) })}
|
||||
aria-pressed={provider === "chatgpt" && override?.fastMode === true} disabled={provider !== "chatgpt" || !chosen || savingModels}
|
||||
onClick={() => { setOpenMenu(null); void updateModels(composeSlotFast(models, slot, !override?.fastMode)); }}
|
||||
className={cn("flex h-7 items-center gap-1 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-40",
|
||||
className={cn("flex h-7 items-center gap-1 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:opacity-40",
|
||||
provider === "chatgpt" && chosen ? "disabled:cursor-default" : "disabled:cursor-not-allowed",
|
||||
provider === "chatgpt" && override?.fastMode ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50")}>
|
||||
<ZapIcon className="size-3.5" aria-hidden /><span>{t("Fast")}</span>
|
||||
</button>
|
||||
|
||||
@@ -335,6 +335,7 @@ export function KeysSection({
|
||||
/** The paste editor for a freshly chosen Add-provider target. */
|
||||
const [adding, setAdding] = useState<ProviderKeyInfo | null>(null);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const followedProvider = useRef<string | null>(null);
|
||||
|
||||
// Escape folds any open editor (unfolded row or the Add-provider paste)
|
||||
// WITHOUT closing the dialog. Claimed at document CAPTURE phase — the
|
||||
@@ -372,7 +373,17 @@ export function KeysSection({
|
||||
// named row; a fresh mount seeds it via the useState initializer.
|
||||
useEffect(() => {
|
||||
if (initialRow !== undefined) setOpenRow(initialRow);
|
||||
followedProvider.current = null;
|
||||
}, [initialRow]);
|
||||
// A missing model-provider key has no table row yet. Deep-link into the
|
||||
// existing Add-provider editor instead (for example, OpenRouter STT setup).
|
||||
useEffect(() => {
|
||||
if (!initialRow || !statuses || !providers || followedProvider.current === initialRow) return;
|
||||
followedProvider.current = initialRow;
|
||||
if (statuses.some((status) => status.id === initialRow)) return;
|
||||
const provider = providers.find((provider) => provider.envVar === initialRow);
|
||||
if (provider) { setAdding(provider); setAddError(null); setOpenRow(null); }
|
||||
}, [initialRow, statuses, providers]);
|
||||
|
||||
async function saveKey(id: string, value: string): Promise<string | null> {
|
||||
const res = await fetch(`/api/keys/${id}`, {
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
type GraphSettings,
|
||||
type UserSettings,
|
||||
} from "@/src/core";
|
||||
import { LanguageSelect, SttPreferredLanguages, useI18n } from "@/components/i18n";
|
||||
import { LanguageSelect, SttModelSelect, SttPreferredLanguages, useI18n } from "@/components/i18n";
|
||||
import { useAppearance } from "./appearance.tsx";
|
||||
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
|
||||
import { AgentSection } from "./agent-section.tsx";
|
||||
@@ -62,6 +62,7 @@ const SECTIONS = [
|
||||
{ id: "appearance", label: "Appearance" },
|
||||
{ id: "graph", label: "Graph" },
|
||||
{ id: "keyboard", label: "Keyboard", hint: "Stored on this device" },
|
||||
{ id: "stt", label: "Speech recognition" },
|
||||
{ id: "general", label: "General" },
|
||||
] as const;
|
||||
type SectionId = (typeof SECTIONS)[number]["id"];
|
||||
@@ -367,10 +368,6 @@ export function SettingsDialogBody({
|
||||
testid="section-general"
|
||||
>
|
||||
<SettingsRow label={t("Interface language")} testid="row-language"><LanguageSelect field="language" /></SettingsRow>
|
||||
<SettingsRow label={t("Speech recognition language")} testid="row-stt-language"><LanguageSelect field="sttLanguage" /></SettingsRow>
|
||||
<SettingsRow label={t("Preferred languages")} hint={t("Used with Multilingual. Favors selected languages; other languages may still be recognized.")} testid="row-stt-preferred-languages" className="flex-col items-start gap-2">
|
||||
<SttPreferredLanguages />
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t("Auto-update")}
|
||||
hint={AUTO_UPDATE_HINT}
|
||||
@@ -386,6 +383,15 @@ export function SettingsDialogBody({
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
)}
|
||||
{active === "stt" && (
|
||||
<SettingsSection title={t("Speech recognition")} testid="section-stt">
|
||||
<SettingsRow label={t("Speech recognition model")} testid="row-stt-model" className="flex-wrap items-start gap-y-2 [&>span:first-child]:flex-[1_1_12rem]"><SttModelSelect /></SettingsRow>
|
||||
<SettingsRow label={t("Speech recognition language")} testid="row-stt-language"><LanguageSelect field="sttLanguage" /></SettingsRow>
|
||||
<SettingsRow label={t("Preferred languages")} testid="row-stt-preferred-languages">
|
||||
<SttPreferredLanguages />
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
)}
|
||||
{active === "keys" && (
|
||||
<SettingsSection title={t("Providers")} testid="section-keys">
|
||||
<KeysSection initialRow={initialRow ?? undefined} />
|
||||
|
||||
Reference in New Issue
Block a user