diff --git a/components/dictation.tsx b/components/dictation.tsx index 7d2b4a9..e1da5cc 100644 --- a/components/dictation.tsx +++ b/components/dictation.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Loader2Icon, MicIcon } from "lucide-react"; import { useSettingsDialog } from "@/components/settings/settings-dialog"; -import { dictationUrl, type SttLanguage } from "@/src/core"; +import { dictationUrl } from "@/src/core"; import { LanguageSelect, useI18n } from "@/components/i18n"; import { Hint } from "@/components/ui/hint"; @@ -279,10 +279,10 @@ export type Dictation = { * question waits. */ export function useDictation({ append }: { append: (text: string) => void }): Dictation { - const { sttLanguage, setDictating } = useI18n(); - const selectedLanguage = useRef(sttLanguage); - selectedLanguage.current = sttLanguage; - const recordingLanguage = useRef(sttLanguage); + const { sttLanguage, general, setDictating } = useI18n(); + const selectedUrl = useRef(""); + selectedUrl.current = dictationUrl(sttLanguage, general.sttPreferredLanguages); + const recordingUrl = useRef(selectedUrl.current); const [phase, setPhase] = useState("idle"); const [error, setError] = useState(null); /** Latest RMS from the worklet — the meter samples this (dictate pattern). */ @@ -365,7 +365,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di // Minted JWTs authenticate via the "bearer" subprotocol — ["token", // jwt] is rejected at the handshake (verified live; "token" is for // raw API keys only, as in the owner's pi dictate extension). - const ws = new WebSocket(dictationUrl(recordingLanguage.current), ["bearer", token]); + const ws = new WebSocket(recordingUrl.current, ["bearer", token]); wsRef.current = ws; ws.addEventListener("open", () => { @@ -425,7 +425,8 @@ export function useDictation({ append }: { append: (text: string) => void }): Di ); const start = useCallback(async () => { - recordingLanguage.current = selectedLanguage.current; + // Pin the complete configuration once; reconnects must retain both language and hints. + recordingUrl.current = selectedUrl.current; setDictating(true); dictationActiveFlag = true; setError(null); diff --git a/components/i18n.tsx b/components/i18n.tsx index 9a043bd..b9df373 100644 --- a/components/i18n.tsx +++ b/components/i18n.tsx @@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useR import { resolveLanguage, resolveSttLanguage, translator, type GeneralSettings, type Language, type SttLanguage, type Translator } from "@/src/core"; import { notifySettingsSaved, useSettingsSaved } from "./settings-signal"; import { MenuShell } from "./menu-shell"; +import { CheckIcon } from "lucide-react"; type LanguageField = "language" | "sttLanguage"; type I18nValue = { @@ -57,7 +58,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", "autoUpdate"] as const) { + for (const field of ["language", "sttLanguage", "sttPreferredLanguages", "autoUpdate"] as const) { if (!Object.hasOwn(patch, field)) continue; if (patch[field] === undefined) delete next[field]; else Object.assign(next, { [field]: patch[field] }); @@ -148,3 +149,31 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f {error && {t(error)}} ; } + +/** Preferred languages guide multilingual recognition; they are saved independently of its mode. */ +export function SttPreferredLanguages() { + const { general, sttLanguage, dictating, saveGeneral, t } = useI18n(); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const preferred = general.sttPreferredLanguages ?? []; + const locked = sttLanguage !== "multi" || dictating || saving; + const toggle = (language: Language) => { + if (locked) return; + const next = (["en", "ru"] as const).filter((code) => code === language ? !preferred.includes(code) : preferred.includes(code)); + setSaving(true); setError(null); + void saveGeneral({ sttPreferredLanguages: next }) + .catch(() => setError("Could not save preferred languages")) + .finally(() => setSaving(false)); + }; + return + + {(["en", "ru"] as const).map((language) => )} + + {error && {t(error)}} + ; +} diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index f08bd45..eafdb13 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -666,49 +666,51 @@ export function AgentSection({ label={SLOT_META[slot].label} hint={SLOT_META[slot].roster} testid={`slot-row-${slot}`} - className="flex-wrap gap-y-2" + className="flex-wrap items-start gap-y-2 [&>span:first-child]:flex-[1_1_12rem]" > {/* The anchor for the outside-click contract is the - span holding BOTH triggers of the open menu. */} + span holding the tier's controls. */} + + setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })} + widthClass="w-full min-w-0" + disabled={savingModels} + /> + setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })} + widthClass="w-20" + disabled={savingModels} + /> + + + + + + {slot !== "strong" && <>} - setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })} - widthClass="min-w-20" - disabled={savingModels} - /> - setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })} - widthClass="w-20" - disabled={savingModels} - /> - - - - - {thisMenuOpen === "model" && ( + + + ): GeneralSettings { if (autoUpdate !== undefined) out.autoUpdate = autoUpdate; if (isLanguage(raw.language)) out.language = raw.language; if (isSttLanguage(raw.sttLanguage)) out.sttLanguage = raw.sttLanguage; + if (Array.isArray(raw.sttPreferredLanguages)) out.sttPreferredLanguages = [...new Set(raw.sttPreferredLanguages.filter(isLanguage))]; return out; } diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index 1aff88a..820cde4 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -94,6 +94,10 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult { }); } }); + it("validates and resolves preferred speech languages without changing the recognition mode", () => { + const general = { language: "ru", sttLanguage: "multi", sttPreferredLanguages: ["ru", "en", "ru"], autoUpdate: false }; + const expected = { ...general, sttPreferredLanguages: ["ru", "en"] }; + const parsed = parseGeneralSettingsBody(general); + assert.deepEqual(parsed, { ok: true, settings: expected }); + const record = sanitizeUserSettings({ version: 1, general }); + assert.deepEqual(record?.general, expected); + assert.deepEqual(resolveSettings(record).general, expected); + assert.deepEqual(parseGeneralSettingsBody({ sttPreferredLanguages: [] }), { ok: true, settings: { sttPreferredLanguages: [] } }); + for (const invalid of [null, "en", ["de"], ["multi"], ["en", 1], {}]) { + const result = parseGeneralSettingsBody({ sttPreferredLanguages: invalid }); + assert(!result.ok); + assert.equal(translate("ru", result.error), "Предпочитаемые языки должны быть списком кодов «en» и «ru»"); + } + assert.deepEqual(sanitizeUserSettings({ version: 1, general: { sttPreferredLanguages: ["ru", "de", "en", null, "ru"] } })?.general, { sttPreferredLanguages: ["ru", "en"] }); + assert.deepEqual(sanitizeUserSettings({ version: 1, general: { sttPreferredLanguages: "ru" } })?.general, {}); + }); + it("sends repeated language hints only for multilingual recognition, with no hints by default", () => { + assert.deepEqual(new URL(dictationUrl("multi")).searchParams.getAll("language_hint"), []); + assert.deepEqual(new URL(dictationUrl("multi", [])).searchParams.getAll("language_hint"), []); + const url = new URL(dictationUrl("multi", ["en", "ru", "en"])); + assert.deepEqual(url.searchParams.getAll("language_hint"), ["en", "ru"]); + assert.equal(url.searchParams.get("language"), "multi"); + url.searchParams.delete("language_hint"); + assert.equal(url.toString(), dictationUrl("multi"), "hinting must preserve every existing streaming parameter"); + for (const language of ["en", "ru"] as const) { + assert.equal(dictationUrl(language, ["en", "ru"]), dictationUrl(language)); + } + }); }); describe("translation quality and rendering", () => { diff --git a/tests/language-select-interaction.test.tsx b/tests/language-select-interaction.test.tsx index e05d175..0d08e4d 100644 --- a/tests/language-select-interaction.test.tsx +++ b/tests/language-select-interaction.test.tsx @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import { afterEach, it } from "node:test"; import { createRoot, type Root } from "react-dom/client"; import { parseHTML } from "linkedom"; -import { I18nProvider, LanguageSelect, useI18n } from "../components/i18n.tsx"; +import { I18nProvider, LanguageSelect, SttPreferredLanguages, useI18n } from "../components/i18n.tsx"; import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "../components/menu-shell.tsx"; import { dictationUrl, translate, type GeneralSettings, type SttLanguage } from "../src/core/index.ts"; @@ -67,7 +67,7 @@ async function mount(initialGeneral: GeneralSettings = { language: "ru", sttLang function Controls() { recording = useI18n().setDictating; resolvedSttLanguage = useI18n().sttLanguage; - return <>; + return <>; } await act(async () => { root!.render(); }); } @@ -201,3 +201,64 @@ it("serializes rapid picks in independent selectors without losing either prefer assert.deepEqual(general, { language: "en", sttLanguage: "ru", autoUpdate: false }); assert.equal(writes.length, 2); }); + +function preferred(language: "en" | "ru") { return element(`[data-testid="stt-preferred-${language}"]`) as HTMLButtonElement; } + +it("saves RU + EN hints, restores them after reload, and retains the choices in single-language mode", async () => { + await mount(); + assert(preferred("en").disabled); assert(preferred("ru").disabled); + assert.equal(writes.length, 0); + await click(trigger("sttLanguage")); await pick("Несколько языков"); + assert(!preferred("en").disabled); + await click(preferred("en")); await click(preferred("ru")); + assert.deepEqual(general, { language: "ru", sttLanguage: "multi", sttPreferredLanguages: ["en", "ru"], autoUpdate: false }); + assert.deepEqual(new URL(dictationUrl(resolvedSttLanguage, general.sttPreferredLanguages)).searchParams.getAll("language_hint"), ["en", "ru"]); + const saved = general; + await act(async () => root!.unmount()); root = null; host.remove(); + await mount(saved); + assert.equal(preferred("en").getAttribute("aria-checked"), "true"); + assert.equal(preferred("ru").getAttribute("aria-checked"), "true"); + await click(trigger("sttLanguage", true)); await pick("English"); + assert(preferred("en").disabled); assert(preferred("ru").disabled); + assert.deepEqual(general.sttPreferredLanguages, ["en", "ru"]); + assert.deepEqual(new URL(dictationUrl(resolvedSttLanguage, general.sttPreferredLanguages)).searchParams.getAll("language_hint"), []); + await click(trigger("sttLanguage")); await pick("Несколько языков"); + assert.equal(preferred("en").getAttribute("aria-checked"), "true"); + await click(preferred("en")); await click(preferred("ru")); + assert.deepEqual(general.sttPreferredLanguages, []); +}); + +it("locks preference edits while saving and recording, and reports a failed save without changing the choice", async () => { + await mount({ language: "ru", sttLanguage: "multi", autoUpdate: false }); + failSave = true; + await click(preferred("en")); + assert.equal(preferred("en").getAttribute("aria-checked"), "false"); + assert.equal(general.sttPreferredLanguages, undefined); + assert.equal(element('[role="alert"]').textContent, "Не удалось сохранить предпочитаемые языки"); + failSave = false; + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); holdSave = () => pending; + await click(preferred("en")); + assert(preferred("en").disabled); assert(preferred("ru").disabled); + await click(preferred("ru")); + assert.equal(writes.length, 2); + await act(async () => release()); holdSave = undefined; + assert.equal(element('[data-testid="stt-preferred-en"]').getAttribute("aria-checked"), "true"); + assert.equal(document.querySelector('[role="alert"]'), null); + await act(async () => recording(true)); + assert(preferred("en").disabled); assert(preferred("ru").disabled); + await click(preferred("en")); + assert.deepEqual(general.sttPreferredLanguages, ["en"]); + await act(async () => recording(false)); + assert(!preferred("en").disabled); +}); + +it("serializes hint and interface-language saves without losing either preference", async () => { + await mount({ language: "ru", sttLanguage: "multi", autoUpdate: false }); + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); holdSave = () => pending; + await click(preferred("ru")); + await click(trigger("language")); await pick("English"); + await act(async () => release()); + assert.deepEqual(general, { language: "en", sttLanguage: "multi", sttPreferredLanguages: ["ru"], autoUpdate: false }); +}); diff --git a/tests/settings-store.test.ts b/tests/settings-store.test.ts index 225de08..3c7162f 100644 --- a/tests/settings-store.test.ts +++ b/tests/settings-store.test.ts @@ -49,7 +49,7 @@ describe("the settings store", () => { assert.deepEqual(out.graph, { enabled: false }); }); it("persists multilingual STT independently of the interface language and update preference", () => { - const general = { language: "ru", sttLanguage: "multi", autoUpdate: false } as const; + const general = { language: "ru" as const, sttLanguage: "multi" as const, sttPreferredLanguages: ["en" as const, "ru" as const], autoUpdate: false }; writeSettings({ version: 1, general }); assert.deepEqual(JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8")).general, general); assert.deepEqual(readSettings()?.general, general);