feat: add OpenRouter dictation and speech recognition settings
This commit is contained in:
@@ -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>/);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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"]'));
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user