feat: add preferred STT languages and fix model settings layout

This commit is contained in:
2026-10-26 02:14:27 +03:00
parent 70977bad84
commit ce28d95bcf
13 changed files with 195 additions and 50 deletions
+30
View File
@@ -7,6 +7,7 @@ import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
import { createPracticeAgent } from "../src/agent/teacher-agent.ts";
import { sanitizeUserSettings } from "../src/core/settings.ts";
import { parseGeneralSettingsBody } from "../src/server/settings-body.ts";
import { resolveSettings } from "../src/core/settings-resolution.ts";
import { countLabel, dictationUrl, resolveLanguage, resolveSttLanguage, systemLanguage, translate } from "../src/core/i18n.ts";
import { sessionMarkdown } from "../src/core/session-markdown.ts";
import type { LhMessage } from "../src/core/native-messages.ts";
@@ -68,6 +69,35 @@ describe("language preferences", () => {
});
}
});
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", () => {
+63 -2
View File
@@ -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 <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /></>;
return <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /><SttPreferredLanguages /></>;
}
await act(async () => { root!.render(<I18nProvider initialGeneral={general}><Controls /></I18nProvider>); });
}
@@ -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<void>((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<void>((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 });
});
+1 -1
View File
@@ -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);