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
+1 -1
View File
@@ -71,7 +71,7 @@ afterEach(async () => { await act(async () => root?.unmount()); host?.remove();
test("Providers is the first/default page; General is last and other pages keep relative order", () => {
const html = renderToStaticMarkup(<SettingsDialogBody onClose={() => {}} />);
const ids = [...html.matchAll(/data-testid="settings-nav-([^"]+)"/g)].map((m) => m[1]);
assert.deepEqual(ids, ["keys", "agent", "appearance", "graph", "keyboard", "general"]);
assert.deepEqual(ids, ["keys", "agent", "appearance", "graph", "keyboard", "stt", "general"]);
assert.match(html, /settings-nav-keys[^>]*aria-current="true"/); assert.match(html, />Providers<\/button>/);
assert.doesNotMatch(html, />Model providers<\/button>/);
});
+256
View File
@@ -0,0 +1,256 @@
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
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, useI18n } from "../components/i18n.tsx";
import { KeysSection } from "../components/settings/keys-section.tsx";
import { DictationBar, finishDictation, isDictationActive, useDictation, useDictationTarget, type Dictation } from "../components/dictation.tsx";
import type { GeneralSettings } from "../src/core/settings.ts";
import { STT_MAX_AUDIO_BYTES } from "../src/core/stt.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, {
window: dom.window, document: dom.document, HTMLElement: dom.window.HTMLElement, Element: dom.window.Element,
CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true,
ResizeObserver: class { observe() {} disconnect() {} },
});
const originalFetch = globalThis.fetch;
let root: Root | null = null;
let host: HTMLDivElement;
let dictation: Dictation;
let setGeneral: (patch: Partial<GeneralSettings>) => Promise<void>;
let saved: GeneralSettings;
let transcripts: string[];
let questionTranscripts: string[];
let uploads: { url: URL; init: RequestInit; audio: Buffer }[];
let heldResponse: ((response: Response) => void) | undefined;
let trackStops = 0;
let configured = true;
let micGate: Promise<void> | undefined;
class MockWorklet {
static latest: MockWorklet;
disconnected = false;
port = {
onmessage: (_event: { data: unknown }) => {},
postMessage: (_message: unknown) => {
queueMicrotask(() => {
this.audio([3, 0]); // final partial frame, available only at Stop
this.port.onmessage({ data: { type: "flushed" } });
});
},
};
constructor() { MockWorklet.latest = this; }
connect() {}
disconnect() { this.disconnected = true; }
audio(samples: number[]) { this.port.onmessage({ data: { type: "audio", buffer: new Uint8Array(samples).buffer } }); }
}
class MockSocket {
static OPEN = 1; static CONNECTING = 0;
static all: MockSocket[] = [];
readyState = 0;
sent: unknown[] = [];
handlers = new Map<string, ((event: any) => void)[]>();
constructor(public url: string) {
MockSocket.all.push(this);
queueMicrotask(() => { this.readyState = 1; this.emit("open", {}); });
}
addEventListener(type: string, callback: (event: any) => void) { this.handlers.set(type, [...(this.handlers.get(type) ?? []), callback]); }
emit(type: string, event: unknown) { for (const handler of this.handlers.get(type) ?? []) handler(event); }
send(value: unknown) {
this.sent.push(value);
if (value === '{"type":"CloseStream"}') queueMicrotask(() => {
this.emit("message", { data: JSON.stringify({ type: "Results", is_final: true, channel: { alternatives: [{ transcript: "Deepgram final" }] } }) });
this.readyState = 3; this.emit("close", { code: 1000 });
});
}
close() { this.readyState = 3; this.emit("close", { code: 1000 }); }
}
Object.assign(globalThis, {
AudioWorkletNode: MockWorklet,
WebSocket: MockSocket,
AudioContext: class {
state = "running";
audioWorklet = { addModule: async () => {} };
destination = {};
createMediaStreamSource() { return { connect() {}, disconnect() {} }; }
},
});
Object.defineProperty(globalThis, "navigator", { configurable: true, value: {
languages: ["ru-RU"],
mediaDevices: { getUserMedia: async () => {
await micGate;
return { getTracks: () => [{ stop: () => { trackStops++; } }] };
} },
} });
async function mount(general: GeneralSettings = { language: "ru", sttModel: "microsoft/mai-transcribe-2", sttLanguage: "multi", sttPreferredLanguages: ["ru", "en"] }, question = false) {
saved = general; transcripts = []; questionTranscripts = []; uploads = []; heldResponse = undefined;
trackStops = 0; configured = true; micGate = undefined; MockSocket.all = [];
globalThis.fetch = async (input, init) => {
const url = String(input);
if (url === "/api/settings") return Response.json({ settings: { general: saved } });
if (url === "/api/settings/general") { saved = JSON.parse(String(init?.body)); return Response.json({ settings: { general: saved } }); }
if (url === "/api/stt/transcribe") return Response.json({ configured });
if (url === "/api/stt/token") return Response.json({ token: "test-deepgram-token" });
assert.equal(init?.method, "POST");
const parsed = new URL(url, "http://localhost");
assert.equal(parsed.pathname, "/api/stt/transcribe");
uploads.push({ url: parsed, init, audio: Buffer.from(init.body as Uint8Array) });
return new Promise<Response>((resolve, reject) => {
heldResponse = resolve;
init.signal?.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError")), { once: true });
});
};
host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host);
function Harness() {
setGeneral = useI18n().saveGeneral;
dictation = useDictation({ append: (text) => transcripts.push(text) });
useDictationTarget((text) => questionTranscripts.push(text), question);
return <DictationBar dictation={dictation} />;
}
await act(async () => root!.render(<I18nProvider initialGeneral={general}><Harness /></I18nProvider>));
}
async function start() { await act(async () => dictation.toggle()); assert.equal(dictation.phase, "recording"); }
async function audio(bytes = [1, 0, 2, 0]) { await act(async () => MockWorklet.latest.audio(bytes)); }
async function stop() { await act(async () => dictation.stop()); }
async function respond(response = Response.json({ text: "Привет, hello!" })) { await act(async () => heldResponse!(response)); }
afterEach(async () => {
await act(async () => root?.unmount()); root = null; host?.remove();
globalThis.fetch = originalFetch;
});
it("uploads exactly one entire recording after Stop, includes its tail and inserts text only after completion", async () => {
await mount(); await start(); await audio(); await audio([4, 0]);
assert.equal(uploads.length, 0); assert.deepEqual(transcripts, []);
assert.equal(MockSocket.all.length, 0, "OpenRouter never opens a Deepgram socket");
await stop();
assert.equal(dictation.phase, "transcribing");
assert.equal(trackStops, 1, "microphone is off before uploading");
assert.equal(uploads.length, 1);
assert.deepEqual([...uploads[0]!.audio.subarray(44)], [1, 0, 2, 0, 4, 0, 3, 0]);
assert.equal(uploads[0]!.url.searchParams.get("model"), "microsoft/mai-transcribe-2");
assert.equal(uploads[0]!.url.searchParams.get("language"), "multi");
assert.equal(uploads[0]!.init.headers && new Headers(uploads[0]!.init.headers).get("Authorization"), null);
assert(document.querySelector('[data-testid="dictation-transcribing"]')?.textContent?.includes("Распознавание…"));
assert.deepEqual(transcripts, []);
await respond();
assert.equal(dictation.phase, "idle"); assert.deepEqual(transcripts, ["Привет, hello!"]);
assert.equal(isDictationActive(), false);
});
it("pins model and language at start and uses changed settings only on the next recording", async () => {
await mount(); await start(); await audio();
await act(async () => setGeneral({ sttLanguage: "en", sttModel: "deepgram/nova-3" }));
await stop();
assert.equal(uploads[0]!.url.searchParams.get("language"), "multi");
assert.equal(uploads[0]!.url.searchParams.get("model"), "microsoft/mai-transcribe-2");
await respond(); await start();
assert.equal(MockSocket.all.length, 1);
assert.equal(new URL(MockSocket.all[0]!.url).searchParams.get("language"), "en");
});
it("waits for the complete transcript before finishing a submit and targets a pending question", async () => {
await mount(undefined, true); await start(); await audio();
let finished = false;
let completion: Promise<void>;
await act(async () => { completion = finishDictation().then(() => { finished = true; }); });
assert.equal(finished, false); assert.deepEqual(questionTranscripts, []);
await respond(); await completion!;
assert.equal(finished, true); assert.deepEqual(transcripts, []);
assert.deepEqual(questionTranscripts, ["Привет, hello!"]);
});
it("cancel during recording discards audio without sending it", async () => {
await mount(); await start(); await audio();
await act(async () => dictation.cancel());
assert.equal(trackStops, 1); assert.equal(uploads.length, 0);
assert.equal(dictation.phase, "idle"); assert.deepEqual(transcripts, []);
});
it("cancel during transcription aborts the upload and ignores a late result", async () => {
await mount(); await start(); await audio(); await stop();
const signal = uploads[0]!.init.signal!;
await act(async () => dictation.cancel());
assert.equal(signal.aborted, true); await respond();
assert.deepEqual(transcripts, []); assert.equal(dictation.phase, "idle");
});
it("navigation during transcription aborts the upload without inserting text", async () => {
await mount(); await start(); await audio(); await stop();
await act(async () => root!.unmount()); root = null;
assert.equal(uploads[0]!.init.signal!.aborted, true);
assert.deepEqual(transcripts, []); assert.equal(isDictationActive(), false);
});
it("shows provider failures without inserting a false transcript, then allows another recording", async () => {
await mount(); await start(); await audio(); await stop();
await respond(Response.json({ message: "OpenRouter credits are insufficient" }, { status: 502 }));
assert.equal(dictation.error, "OpenRouter credits are insufficient");
assert(document.querySelector('[data-testid="dictation-toggle"]')?.textContent?.includes("Недостаточно средств"));
assert.deepEqual(transcripts, []);
await start(); assert.equal(dictation.error, null);
});
it("reports a missing OpenRouter key and shuts the microphone down", async () => {
await mount(); configured = false;
await act(async () => dictation.toggle());
assert.equal(dictation.phase, "idle");
assert.equal(dictation.error, "Dictation needs an OpenRouter key");
assert.equal(trackStops, 1); assert.equal(uploads.length, 0);
});
it("opens the existing Add-provider editor for a missing OpenRouter key and respects Cancel", async () => {
await mount();
const appFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => String(input) === "/api/keys" ? Response.json({ keys: [], providers: [{ id: "openrouter", envVar: "OPENROUTER_API_KEY", label: "OpenRouter" }] }) : appFetch(input, init);
const render = async (initialRow?: string) => act(async () => root!.render(<I18nProvider initialGeneral={saved}><KeysSection initialRow={initialRow} /></I18nProvider>));
await render("OPENROUTER_API_KEY");
assert(document.querySelector('[data-testid="key-openrouter-input"]'));
await act(async () => document.querySelector<HTMLElement>('[data-testid="key-openrouter-cancel"]')!.click());
assert.equal(document.querySelector('[data-testid="key-openrouter-input"]'), null);
await render("OPENROUTER_API_KEY");
assert.equal(document.querySelector('[data-testid="key-openrouter-input"]'), null, "ordinary renders must not reopen a cancelled editor");
await render(undefined); await render("OPENROUTER_API_KEY");
assert(document.querySelector('[data-testid="key-openrouter-input"]'), "a new deep-link opens the editor again");
});
it("does not duplicate uploads when Stop is pressed twice before rendering", async () => {
await mount(); await start(); await audio();
await act(async () => { dictation.stop(); dictation.stop(); });
assert.equal(uploads.length, 1); await respond();
assert.deepEqual(transcripts, ["Привет, hello!"]);
});
it("stops oversized capture with an explicit error and never uploads a partial recording", async () => {
await mount(); await start();
await act(async () => MockWorklet.latest.port.onmessage({ data: { type: "audio", buffer: new Uint8Array(STT_MAX_AUDIO_BYTES).buffer } }));
assert.equal(dictation.phase, "idle");
assert.match(dictation.error!, /25 MB/);
assert.equal(trackStops, 1); assert.equal(uploads.length, 0);
});
it("cancels safely while permission is pending and stops a late microphone stream", async () => {
await mount(); let allowMic: () => void;
micGate = new Promise<void>((resolve) => { allowMic = resolve; });
await act(async () => dictation.toggle());
assert.equal(dictation.phase, "connecting");
await stop(); await act(async () => allowMic!());
assert.equal(trackStops, 1); assert.equal(uploads.length, 0); assert.equal(dictation.phase, "idle");
});
it("keeps Deepgram streaming audio during recording and appending its finals after Stop", async () => {
await mount({ sttModel: "deepgram/nova-3", sttLanguage: "multi", sttPreferredLanguages: ["ru", "en"] });
await start(); await audio();
const socket = MockSocket.all[0]!;
assert.equal(socket.sent.length, 1);
assert.deepEqual(new URL(socket.url).searchParams.getAll("language_hint"), ["ru", "en"]);
assert.equal(uploads.length, 0); assert.deepEqual(transcripts, []);
await stop(); assert.deepEqual(transcripts, ["Deepgram final"]);
assert.equal(dictation.phase, "idle");
});
+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"]'));
});
+3 -3
View File
@@ -41,12 +41,12 @@ describe("SettingsDialog (the designed surface, SSR)", () => {
assert.ok(!html.includes("settings-dialog"));
});
it("the dialog frame: role=dialog + the ✕ + the six-section nav, Providers first and General last", () => {
it("the dialog frame: role=dialog + the ✕ + the seven-section nav, Providers first and General last", () => {
const html = renderToStaticMarkup(<SettingsDialogBody onClose={() => {}} />);
assert.ok(html.includes('role="dialog"'));
assert.ok(html.includes('aria-modal="true"'));
assert.ok(html.includes('aria-label="Close settings"'));
for (const id of ["general", "keys", "agent", "appearance", "graph", "keyboard"]) {
for (const id of ["general", "keys", "agent", "appearance", "graph", "keyboard", "stt"]) {
assert.ok(html.includes(`data-testid="settings-nav-${id}"`), id);
}
// Providers is the first landing section; internal keys deep links survive.
@@ -281,4 +281,4 @@ describe("KeyboardSection (thin SSR)", () => {
assert.ok(!KEYBOARD_ACTIONS.some((a) => a.id as string === "model-menu-walk"));
assert.ok(!KEYBOARD_ACTIONS.some((a) => a.id as string === "model-menu-pick"));
});
});
});
+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" as const, sttLanguage: "multi" as const, sttPreferredLanguages: ["en" as const, "ru" as const], autoUpdate: false };
const general = { language: "ru" as const, sttLanguage: "multi" as const, sttModel: "microsoft/mai-transcribe-2" 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);
+132
View File
@@ -0,0 +1,132 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { afterEach, describe, it } from "node:test";
import { runInNewContext } from "node:vm";
import { DEFAULT_STT_MODEL, OPENROUTER_STT_MODEL, STT_MAX_AUDIO_BYTES, dictationWav } from "../src/core/stt.ts";
import { sanitizeUserSettings } from "../src/core/settings.ts";
import { resolveSettings } from "../src/core/settings-resolution.ts";
import { parseGeneralSettingsBody } from "../src/server/settings-body.ts";
import { openRouterTranscription } from "../src/server/stt.ts";
const originalFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = originalFetch; });
const frames = [new Uint8Array([0, 128, 255, 127]), new Uint8Array([1, 0, 255, 255])];
function request(language = "multi", extra: RequestInit = {}) {
return new Request(`http://localhost/api/stt/transcribe?model=${OPENROUTER_STT_MODEL}&language=${language}`, {
method: "POST", headers: { "Content-Type": "audio/wav" }, body: dictationWav(frames), ...extra,
});
}
describe("STT configuration and lossless capture", () => {
it("round-trips both STT models without changing languages, hints or update preferences", () => {
for (const sttModel of [DEFAULT_STT_MODEL, OPENROUTER_STT_MODEL]) {
const general = { sttModel, sttLanguage: "multi" as const, sttPreferredLanguages: ["en" as const, "ru" as const], language: "ru" as const, autoUpdate: false };
assert.deepEqual(parseGeneralSettingsBody(general), { ok: true, settings: general });
const saved = sanitizeUserSettings({ version: 1, general });
assert.deepEqual(saved?.general, general);
assert.deepEqual(resolveSettings(saved).general, general);
}
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { sttModel: "other" } })?.general, {});
assert.equal(parseGeneralSettingsBody({ sttModel: "other" }).ok, false);
assert.equal(resolveSettings(null).general.sttModel, undefined, "old records retain the Deepgram default");
});
it("keeps every PCM sample in order, including short final frames", () => {
const wav = Buffer.from(dictationWav(frames));
assert.equal(wav.toString("ascii", 0, 4), "RIFF");
assert.equal(wav.readUInt32LE(4), wav.length - 8);
assert.equal(wav.readUInt32LE(24), 16000);
assert.equal(wav.readUInt16LE(22), 1);
assert.equal(wav.readUInt16LE(34), 16);
assert.equal(wav.readUInt32LE(40), 8);
assert.deepEqual([...wav.subarray(44)], frames.flatMap((frame) => [...frame]));
assert.throws(() => dictationWav([new Uint8Array(1)]), /Invalid dictation audio/);
assert.throws(() => dictationWav([new Uint8Array(STT_MAX_AUDIO_BYTES)]), /25 MB/);
});
it("drains the worklet's incomplete last frame exactly once", () => {
let Processor: new () => { process: (inputs: Float32Array[][]) => void; port: { onmessage: (event: unknown) => void } };
const messages: { type: string; buffer?: Int16Array }[] = [];
runInNewContext(readFileSync(new URL("../public/worklets/pcm-capture.js", import.meta.url), "utf8"), {
AudioWorkletProcessor: class { port = { postMessage: (message: { type: string; buffer?: Int16Array }) => messages.push(message) }; },
registerProcessor: (_name: string, processor: typeof Processor) => { Processor = processor; },
});
const processor = new Processor!();
processor.process([[new Float32Array([0, 1, -1])]]);
assert.equal(messages.filter((message) => message.type === "audio").length, 0);
processor.port.onmessage({ data: { type: "flush" } });
assert.deepEqual(Array.from(messages.find((message) => message.type === "audio")!.buffer!), [0, 32767, -32768]);
assert.equal(messages.at(-1)?.type, "flushed");
processor.port.onmessage({ data: { type: "flush" } });
assert.equal(messages.filter((message) => message.type === "audio").length, 1);
});
});
describe("OpenRouter whole-recording transcription", () => {
for (const language of ["en", "ru", "multi"]) it(`sends one complete WAV using server auth and the correct language (${language})`, async () => {
let calls = 0;
globalThis.fetch = async (input, init) => {
calls++;
assert.equal(String(input), "https://openrouter.ai/api/v1/audio/transcriptions");
assert.equal(new Headers(init?.headers).get("Authorization"), "Bearer test-only-key");
const body = JSON.parse(String(init?.body));
assert.equal(body.model, OPENROUTER_STT_MODEL);
assert.equal(body.response_format, "json");
assert.equal(body.input_audio.format, "wav");
assert.deepEqual(Buffer.from(body.input_audio.data, "base64"), Buffer.from(dictationWav(frames)));
assert.deepEqual(Object.keys(body).sort(), (language === "multi" ? ["model", "input_audio", "response_format"] : ["model", "input_audio", "response_format", "language"]).sort());
assert.equal(body.language, language === "multi" ? undefined : language);
return Response.json({ text: "Привет, hello!", usage: { cost: 0.01 } });
};
const response = await openRouterTranscription(request(language), "test-only-key");
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { text: "Привет, hello!" });
assert.equal(calls, 1);
});
it("refuses missing keys, unknown models and invalid languages without any provider request", async () => {
globalThis.fetch = async () => { throw new Error("must not reach provider"); };
assert.equal((await openRouterTranscription(request(), "")).status, 503);
assert.equal((await openRouterTranscription(new Request("http://localhost?model=other&language=en", { method: "POST" }), "test")).status, 400);
assert.equal((await openRouterTranscription(request("en,ru"), "test")).status, 400);
});
it("rejects invalid audio and enforces the upload limit before contacting OpenRouter", async () => {
globalThis.fetch = async () => { throw new Error("must not reach provider"); };
assert.equal((await openRouterTranscription(request("multi", { body: "not wav" }), "test")).status, 400);
assert.equal((await openRouterTranscription(request("multi", { headers: { "Content-Type": "audio/wav", "Content-Length": String(STT_MAX_AUDIO_BYTES + 1) } }), "test")).status, 413);
const damaged = dictationWav(frames); damaged[24] = 0;
assert.equal((await openRouterTranscription(request("multi", { body: damaged }), "test")).status, 400);
const stream = new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(STT_MAX_AUDIO_BYTES + 1)); controller.close(); } });
const streamingRequest = request("multi", { body: stream, duplex: "half" } as RequestInit);
assert.equal((await openRouterTranscription(streamingRequest, "test")).status, 413);
});
for (const status of [401, 403, 402, 429, 500]) it(`handles provider status ${status} without leaking diagnostics or retrying`, async () => {
let calls = 0;
globalThis.fetch = async () => { calls++; return new Response("test-only-key PRIVATE_AUDIO", { status }); };
const response = await openRouterTranscription(request(), "test-only-key");
assert.equal(response.status, status === 429 ? 429 : 502);
assert.doesNotMatch(await response.text(), /test-only-key|PRIVATE_AUDIO/);
assert.equal(calls, 1);
});
it("accepts an empty transcript for silence, but rejects malformed provider output", async () => {
globalThis.fetch = async () => Response.json({ text: "" });
assert.deepEqual(await (await openRouterTranscription(request(), "test")).json(), { text: "" });
globalThis.fetch = async () => Response.json({ text: null });
assert.equal((await openRouterTranscription(request(), "test")).status, 502);
});
it("reports timeouts and respects cancellation", async () => {
globalThis.fetch = async () => { throw new DOMException("timeout", "TimeoutError"); };
assert.equal((await openRouterTranscription(request(), "test")).status, 504);
const controller = new AbortController(); controller.abort();
assert.equal((await openRouterTranscription(request("multi", { signal: controller.signal }), "test")).status, 499);
});
it("propagates cancellation to an in-flight provider request", async () => {
let started: () => void;
const ready = new Promise<void>((resolve) => { started = resolve; });
globalThis.fetch = async (_input, init) => new Promise<Response>((_resolve, reject) => {
init!.signal!.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError")), { once: true });
started();
});
const controller = new AbortController();
const pending = openRouterTranscription(request("multi", { signal: controller.signal }), "test");
await ready; controller.abort();
assert.equal((await pending).status, 499);
});
});