feat: add OpenRouter dictation and speech recognition settings

This commit is contained in:
2026-10-26 23:42:44 +03:00
parent 168cad7470
commit ecbd2b0bb9
24 changed files with 867 additions and 72 deletions
+73 -13
View File
@@ -4,21 +4,26 @@ 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, 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";
import { useEnterSubmits } from "../components/enter-submits.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, {
window: dom.window, document: dom.document, Element: dom.window.Element,
HTMLElement: dom.window.HTMLElement, HTMLInputElement: dom.window.HTMLInputElement,
HTMLTextAreaElement: dom.window.HTMLTextAreaElement, CustomEvent: dom.window.CustomEvent,
Node: dom.window.Node, MutationObserver: dom.window.MutationObserver,
NodeFilter: { SHOW_ELEMENT: 1, FILTER_ACCEPT: 1, FILTER_SKIP: 3 },
ResizeObserver: class { observe() {} unobserve() {} disconnect() {} },
getComputedStyle: () => ({ display: "block", visibility: "visible", direction: "ltr", getPropertyValue: () => "" }),
IS_REACT_ACT_ENVIRONMENT: true,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(performance.now()), 0),
cancelAnimationFrame: (id: number) => clearTimeout(id),
});
Object.defineProperty(globalThis, "navigator", { configurable: true, value: { languages: ["en-US"] } });
Object.assign(dom.window, { innerWidth: 1280, innerHeight: 800 });
const { I18nProvider, LanguageSelect, SttModelSelect, SttPreferredLanguages, useI18n } = await import("../components/i18n.tsx");
const originalFetch = globalThis.fetch;
let root: Root | null = null;
let host: HTMLDivElement;
@@ -28,6 +33,7 @@ let failSave = false;
let holdSave: (() => Promise<void>) | undefined;
let recording: (active: boolean) => void;
let resolvedSttLanguage: SttLanguage;
let submits = 0;
function element(selector: string): HTMLElement {
const found = document.querySelector<HTMLElement>(selector);
@@ -37,7 +43,7 @@ function element(selector: string): HTMLElement {
function trigger(field: string, compact = false) { return element(`[data-testid="language-select-${field}${compact ? "-compact" : ""}"]`); }
async function click(target: HTMLElement) {
await act(async () => {
target.dispatchEvent(new dom.window.Event("pointerdown", { bubbles: true }));
target.dispatchEvent(Object.assign(new dom.window.Event("pointerdown", { bubbles: true }), { button: 0, ctrlKey: false, pointerType: "mouse", isPrimary: true }));
target.dispatchEvent(new dom.window.Event("click", { bubbles: true }));
});
}
@@ -48,7 +54,7 @@ async function pick(text: string) {
}
async function mount(initialGeneral: GeneralSettings = { language: "ru", sttLanguage: "en", autoUpdate: false }) {
general = initialGeneral;
writes = []; failSave = false; holdSave = undefined;
writes = []; failSave = false; holdSave = undefined; submits = 0;
globalThis.fetch = async (input, init) => {
const url = String(input);
if ((init?.method ?? "GET") === "GET") {
@@ -65,9 +71,10 @@ async function mount(initialGeneral: GeneralSettings = { language: "ru", sttLang
};
host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host);
function Controls() {
useEnterSubmits(() => { submits++; });
recording = useI18n().setDictating;
resolvedSttLanguage = useI18n().sttLanguage;
return <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /><SttPreferredLanguages /></>;
return <><LanguageSelect field="language" /><SttModelSelect /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /><SttPreferredLanguages /></>;
}
await act(async () => { root!.render(<I18nProvider initialGeneral={general}><Controls /></I18nProvider>); });
}
@@ -75,6 +82,40 @@ afterEach(async () => {
await act(async () => { root?.unmount(); }); root = null; host?.remove(); globalThis.fetch = originalFetch;
});
it("selects only MAI or direct Deepgram, saves independently, and restores hints when returning to Deepgram", async () => {
await mount({ language: "ru", sttLanguage: "multi", sttPreferredLanguages: ["ru", "en"], autoUpdate: false });
const model = () => element('[data-testid="stt-model-select"]');
assert(model().textContent?.includes("Nova-3 · Deepgram"));
await click(model());
assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 2);
await pick("MAI-Transcribe-2");
assert.deepEqual(general, { language: "ru", sttLanguage: "multi", sttPreferredLanguages: ["ru", "en"], autoUpdate: false, sttModel: "microsoft/mai-transcribe-2" });
assert.equal(preferredTrigger().textContent, "English, Русский▾");
assert(preferredTrigger().disabled, "unsupported hints cannot be changed for MAI");
await click(trigger("language")); await pick("English");
assert.equal(general.sttModel, "microsoft/mai-transcribe-2");
assert.deepEqual(general.sttPreferredLanguages, ["ru", "en"]);
await click(model()); await pick("Nova-3 · Deepgram");
assert.equal(general.sttModel, "deepgram/nova-3");
assert(!preferredTrigger().disabled);
assert.deepEqual(general.sttPreferredLanguages, ["ru", "en"]);
});
it("locks model changes during recording and leaves the selected model unchanged if saving fails", async () => {
await mount();
const model = () => element('[data-testid="stt-model-select"]');
await click(model());
await act(async () => recording(true));
assert(model().hasAttribute("disabled"));
assert.equal(document.querySelector('[role="menu"]'), null);
await act(async () => recording(false));
failSave = true;
await click(model()); await pick("MAI-Transcribe-2");
assert(model().textContent?.includes("Nova-3 · Deepgram"));
assert.equal(general.sttModel, undefined);
assert(document.querySelector('[role="alert"]'));
});
it("picks languages through the shared menu and preserves the other language and update preference", async () => {
await mount();
assert.equal(document.querySelector("select"), null);
@@ -202,34 +243,42 @@ it("serializes rapid picks in independent selectors without losing either prefer
assert.equal(writes.length, 2);
});
function preferred(language: "en" | "ru") { return element(`[data-testid="stt-preferred-${language}"]`) as HTMLButtonElement; }
function preferred(language: "en" | "ru") { return element(`[data-testid="stt-preferred-${language}"]`); }
function preferredTrigger() { return element('[data-testid="stt-preferred-select"]') as HTMLButtonElement; }
async function openPreferred() { await click(preferredTrigger()); assert(document.querySelector('[data-testid="stt-preferred-menu"]')); }
async function closePreferred() { await click(preferredTrigger()); assert.equal(document.querySelector('[data-testid="stt-preferred-menu"]'), null); }
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(preferredTrigger().disabled);
assert.equal(writes.length, 0);
await click(trigger("sttLanguage")); await pick("Несколько языков");
assert(!preferred("en").disabled);
assert(!preferredTrigger().disabled); await openPreferred();
await click(preferred("en")); await click(preferred("ru"));
assert(document.querySelector('[data-testid="stt-preferred-menu"]'), "checkbox picks keep the dropdown open");
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(preferredTrigger().textContent?.includes("English, Русский")); await openPreferred();
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);
await closePreferred(); await click(trigger("sttLanguage", true)); await pick("English");
assert(preferredTrigger().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("Несколько языков");
await openPreferred();
assert.equal(preferred("en").getAttribute("aria-checked"), "true");
await click(preferred("en")); await click(preferred("ru"));
assert.deepEqual(general.sttPreferredLanguages, []);
assert(preferredTrigger().textContent?.includes("Без предпочтений"));
});
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 });
await openPreferred();
failSave = true;
await click(preferred("en"));
assert.equal(preferred("en").getAttribute("aria-checked"), "false");
@@ -239,22 +288,23 @@ it("locks preference edits while saving and recording, and reports a failed save
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);
assert.equal(preferred("en").getAttribute("aria-disabled"), "true"); assert.equal(preferred("ru").getAttribute("aria-disabled"), "true");
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(preferredTrigger().disabled); assert.equal(document.querySelector('[data-testid="stt-preferred-menu"]'), null);
await click(preferredTrigger());
assert.deepEqual(general.sttPreferredLanguages, ["en"]);
await act(async () => recording(false));
assert(!preferred("en").disabled);
assert(!preferredTrigger().disabled);
});
it("serializes hint and interface-language saves without losing either preference", async () => {
await mount({ language: "ru", sttLanguage: "multi", autoUpdate: false });
await openPreferred();
let release!: () => void;
const pending = new Promise<void>((resolve) => { release = resolve; }); holdSave = () => pending;
await click(preferred("ru"));
@@ -262,3 +312,13 @@ it("serializes hint and interface-language saves without losing either preferenc
await act(async () => release());
assert.deepEqual(general, { language: "en", sttLanguage: "multi", sttPreferredLanguages: ["ru"], autoUpdate: false });
});
it("keeps keyboard checkbox selection inside the menu without submitting the underlying composer", async () => {
await mount({ language: "en", sttLanguage: "multi" });
assert(preferredTrigger().textContent?.includes("No preference"));
await openPreferred();
await act(async () => preferred("en").dispatchEvent(Object.assign(new dom.window.Event("keydown", { bubbles: true, cancelable: true }), { key: "Enter" })));
assert.deepEqual(general.sttPreferredLanguages, ["en"]);
assert.equal(submits, 0);
assert(document.querySelector('[data-testid="stt-preferred-menu"]'));
});