feat: add multilingual dictation and simplify chat STT picker
This commit is contained in:
+25
-6
@@ -7,7 +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 { countLabel, dictationUrl, resolveLanguage, systemLanguage, translate } from "../src/core/i18n.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";
|
||||
import { I18nProvider, LanguageSelect } from "../components/i18n.tsx";
|
||||
@@ -29,6 +29,15 @@ describe("language preferences", () => {
|
||||
assert.equal(resolveLanguage("en", "ru-RU"), "en");
|
||||
assert.equal(resolveLanguage("ru", "en-US"), "ru");
|
||||
});
|
||||
it("resolves multilingual only for STT and keeps system defaults independent", () => {
|
||||
assert.equal(resolveSttLanguage("multi", "ru-RU"), "multi");
|
||||
assert.equal(resolveSttLanguage("multi", "en-US"), "multi");
|
||||
assert.equal(resolveLanguage("multi", "ru-RU"), "ru");
|
||||
assert.equal(resolveSttLanguage(undefined, "ru-RU"), "ru");
|
||||
assert.equal(resolveSttLanguage(undefined, "de-DE"), "en");
|
||||
assert.equal(resolveSttLanguage("en", "ru-RU"), "en");
|
||||
assert.equal(resolveSttLanguage("ru", "en-US"), "ru");
|
||||
});
|
||||
it("validates both independent preferences without changing existing general options", () => {
|
||||
const general = { autoUpdate: false, language: "ru", sttLanguage: "en" };
|
||||
assert.deepEqual(parseGeneralSettingsBody(general), { ok: true, settings: general });
|
||||
@@ -38,15 +47,25 @@ describe("language preferences", () => {
|
||||
assert.equal(parseGeneralSettingsBody({ sttLanguage: "fr" }).ok, false);
|
||||
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { language: null, sttLanguage: "fr", autoUpdate: false } })?.general, { autoUpdate: false });
|
||||
});
|
||||
it("accepts a multilingual STT preference without allowing a multilingual interface", () => {
|
||||
const general = { autoUpdate: false, language: "ru", sttLanguage: "multi" };
|
||||
assert.deepEqual(parseGeneralSettingsBody(general), { ok: true, settings: general });
|
||||
assert.deepEqual(sanitizeUserSettings({ version: 1, general })?.general, general);
|
||||
assert.equal(parseGeneralSettingsBody({ language: "multi" }).ok, false);
|
||||
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { language: "multi", sttLanguage: "multi" } })?.general, { sttLanguage: "multi" });
|
||||
const invalid = parseGeneralSettingsBody({ sttLanguage: "auto" });
|
||||
assert(!invalid.ok);
|
||||
assert.equal(translate("ru", invalid.error), "Параметр sttLanguage должен иметь значение «en», «ru» или «multi»");
|
||||
});
|
||||
it("pins an explicit supported language in the STT provider request", () => {
|
||||
for (const language of ["en", "ru"] as const) {
|
||||
for (const language of ["en", "ru", "multi"] as const) {
|
||||
const url = new URL(dictationUrl(language));
|
||||
assert.equal(url.protocol, "wss:");
|
||||
assert.equal(url.hostname, "api.deepgram.com");
|
||||
assert.equal(url.searchParams.get("language"), language);
|
||||
assert.equal(url.searchParams.get("model"), "nova-3");
|
||||
assert.equal(url.searchParams.get("encoding"), "linear16");
|
||||
assert.equal(url.searchParams.get("sample_rate"), "16000");
|
||||
assert.deepEqual(Object.fromEntries(url.searchParams), {
|
||||
model: "nova-3", language, encoding: "linear16", sample_rate: "16000",
|
||||
channels: "1", smart_format: "true", punctuate: "true", endpointing: "300",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { I18nProvider, LanguageSelect, useI18n } from "../components/i18n.tsx";
|
||||
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "../components/menu-shell.tsx";
|
||||
import { translate, type GeneralSettings } from "../src/core/index.ts";
|
||||
import { dictationUrl, translate, type GeneralSettings, type SttLanguage } from "../src/core/index.ts";
|
||||
|
||||
const dom = parseHTML("<html><body></body></html>");
|
||||
Object.assign(globalThis, {
|
||||
@@ -27,6 +27,7 @@ let writes: GeneralSettings[];
|
||||
let failSave = false;
|
||||
let holdSave: (() => Promise<void>) | undefined;
|
||||
let recording: (active: boolean) => void;
|
||||
let resolvedSttLanguage: SttLanguage;
|
||||
|
||||
function element(selector: string): HTMLElement {
|
||||
const found = document.querySelector<HTMLElement>(selector);
|
||||
@@ -45,8 +46,8 @@ async function pick(text: string) {
|
||||
assert(option, `expected option ${text}`);
|
||||
await click(option);
|
||||
}
|
||||
async function mount() {
|
||||
general = { language: "ru", sttLanguage: "en", autoUpdate: false };
|
||||
async function mount(initialGeneral: GeneralSettings = { language: "ru", sttLanguage: "en", autoUpdate: false }) {
|
||||
general = initialGeneral;
|
||||
writes = []; failSave = false; holdSave = undefined;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = String(input);
|
||||
@@ -65,6 +66,7 @@ async function mount() {
|
||||
host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host);
|
||||
function Controls() {
|
||||
recording = useI18n().setDictating;
|
||||
resolvedSttLanguage = useI18n().sttLanguage;
|
||||
return <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /></>;
|
||||
}
|
||||
await act(async () => { root!.render(<I18nProvider initialGeneral={general}><Controls /></I18nProvider>); });
|
||||
@@ -77,7 +79,7 @@ it("picks languages through the shared menu and preserves the other language and
|
||||
await mount();
|
||||
assert.equal(document.querySelector("select"), null);
|
||||
await click(trigger("sttLanguage"));
|
||||
assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3);
|
||||
assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 4);
|
||||
assert(element('[role="menuitemradio"][aria-checked="true"]').textContent?.includes("English"));
|
||||
assert(document.querySelector('[role="menu"]')?.textContent?.includes("Язык системы"));
|
||||
await pick("Русский");
|
||||
@@ -88,6 +90,61 @@ it("picks languages through the shared menu and preserves the other language and
|
||||
assert.deepEqual(general, { sttLanguage: "ru", autoUpdate: false });
|
||||
});
|
||||
|
||||
for (const language of ["en", "ru"] as const) it(`saves multilingual STT from both selectors and restores it on reload (${language})`, async () => {
|
||||
const label = translate(language, "Multilingual");
|
||||
const shortLabel = translate(language, "Multi");
|
||||
await mount({ language, sttLanguage: "en", autoUpdate: false });
|
||||
await click(trigger("language"));
|
||||
assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3);
|
||||
assert(!document.querySelector('[role="menu"]')?.textContent?.includes(label));
|
||||
await click(trigger("sttLanguage", true)); await pick(label);
|
||||
await click(trigger("sttLanguage", true));
|
||||
assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3);
|
||||
assert(!document.querySelector('[role="menu"]')?.textContent?.includes(translate(language, "System default")));
|
||||
await click(document.body);
|
||||
assert.deepEqual(general, { language, sttLanguage: "multi", autoUpdate: false });
|
||||
assert.equal(resolvedSttLanguage, "multi");
|
||||
assert.equal(new URL(dictationUrl(resolvedSttLanguage)).searchParams.get("language"), "multi");
|
||||
assert(trigger("sttLanguage").textContent?.includes(label));
|
||||
assert(trigger("sttLanguage", true).textContent?.includes(shortLabel));
|
||||
const saved = general;
|
||||
await act(async () => root!.unmount()); root = null; host.remove();
|
||||
await mount(saved);
|
||||
assert.equal(resolvedSttLanguage, "multi");
|
||||
await click(trigger("sttLanguage"));
|
||||
assert(element('[role="menuitemradio"][aria-checked="true"]').textContent?.includes(label));
|
||||
await pick(translate(language, "System default"));
|
||||
assert.equal(resolvedSttLanguage, "en", "system default follows the browser, independently of the interface");
|
||||
assert.deepEqual(general, { language, autoUpdate: false });
|
||||
await click(trigger("sttLanguage")); await pick(label);
|
||||
assert.deepEqual(general, { language, sttLanguage: "multi", autoUpdate: false });
|
||||
});
|
||||
|
||||
for (const system of ["en", "ru"] as const) it(`shows the resolved system language in chat without an Auto option or an implicit save (${system})`, async () => {
|
||||
const browserLanguages = navigator.languages;
|
||||
Object.assign(navigator, { languages: [system === "ru" ? "ru-RU" : "en-US"] });
|
||||
try {
|
||||
const label = system === "ru" ? "Русский" : "English";
|
||||
await mount({ language: "ru", autoUpdate: false });
|
||||
assert(trigger("sttLanguage", true).textContent?.includes(label));
|
||||
assert(!trigger("sttLanguage", true).textContent?.includes("Авто"));
|
||||
await click(trigger("sttLanguage", true));
|
||||
assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3);
|
||||
assert(!document.querySelector('[role="menu"]')?.textContent?.includes("Авто"));
|
||||
assert(element('[role="menuitemradio"][aria-checked="true"]').textContent?.includes(label));
|
||||
assert.equal(writes.length, 0);
|
||||
assert.deepEqual(general, { language: "ru", autoUpdate: false });
|
||||
await click(trigger("sttLanguage"));
|
||||
assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 4);
|
||||
assert(element('[role="menuitemradio"][aria-checked="true"]').textContent?.includes("Язык системы"));
|
||||
await click(trigger("sttLanguage", true)); await pick(label);
|
||||
assert.deepEqual(general, { language: "ru", sttLanguage: system, autoUpdate: false });
|
||||
assert.equal(writes.length, 1, "an explicit pick pins the language even when it matches the system");
|
||||
} finally {
|
||||
Object.assign(navigator, { languages: browserLanguages });
|
||||
}
|
||||
});
|
||||
|
||||
it("dismisses outside clicks and Escape, and opening another selector balances menu scroll locks", async () => {
|
||||
await mount();
|
||||
let opened = 0;
|
||||
@@ -108,7 +165,7 @@ it("dismisses outside clicks and Escape, and opening another selector balances m
|
||||
});
|
||||
|
||||
it("locks both STT selectors and closes an open menu when recording begins", async () => {
|
||||
await mount(); await click(trigger("sttLanguage", true));
|
||||
await mount({ language: "ru", sttLanguage: "multi", autoUpdate: false }); await click(trigger("sttLanguage", true));
|
||||
await act(async () => recording(true));
|
||||
assert.equal(document.querySelector('[role="menu"]'), null);
|
||||
assert((trigger("sttLanguage") as HTMLButtonElement).disabled);
|
||||
@@ -119,16 +176,17 @@ it("locks both STT selectors and closes an open menu when recording begins", asy
|
||||
assert(!(trigger("sttLanguage", true) as HTMLButtonElement).disabled);
|
||||
assert.equal(trigger("sttLanguage", true).getAttribute("aria-expanded"), "false");
|
||||
assert.equal(writes.length, 0);
|
||||
assert.equal(resolvedSttLanguage, "multi");
|
||||
});
|
||||
|
||||
it("shows a localized save failure without changing the selected language and allows retry", async () => {
|
||||
await mount(); failSave = true;
|
||||
await click(trigger("sttLanguage")); await pick("Русский");
|
||||
await click(trigger("sttLanguage")); await pick("Несколько языков");
|
||||
assert.equal(element('[role="alert"]').textContent, translate("ru", "Could not save language settings"));
|
||||
assert.equal(general.sttLanguage, "en"); assert(trigger("sttLanguage").textContent?.includes("English"));
|
||||
failSave = false;
|
||||
await click(trigger("sttLanguage")); await pick("Русский");
|
||||
assert.equal(general.sttLanguage, "ru"); assert.equal(document.querySelector('[role="alert"]'), null);
|
||||
await click(trigger("sttLanguage")); await pick("Несколько языков");
|
||||
assert.equal(general.sttLanguage, "multi"); assert.equal(document.querySelector('[role="alert"]'), null);
|
||||
});
|
||||
|
||||
it("serializes rapid picks in independent selectors without losing either preference", async () => {
|
||||
|
||||
@@ -48,6 +48,12 @@ describe("the settings store", () => {
|
||||
assert.ok(out);
|
||||
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;
|
||||
writeSettings({ version: 1, general });
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8")).general, general);
|
||||
assert.deepEqual(readSettings()?.general, general);
|
||||
});
|
||||
|
||||
it("a chat section on disk is IGNORED — the field died with per-session models (owner, 2026-09-28)", () => {
|
||||
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
|
||||
|
||||
Reference in New Issue
Block a user