Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d43ffe4f8a | ||
|
|
ecbd2b0bb9 | ||
|
|
168cad7470 | ||
|
|
ce28d95bcf | ||
|
|
70977bad84 | ||
|
|
fdd63b27a9 | ||
|
|
bdd7d5e866 | ||
|
|
1c6ae6b2e2 | ||
|
|
d2d9fcd09e | ||
|
|
a65867fc39 | ||
|
|
8c564946ef | ||
|
|
f8ce0f21ef |
@@ -1,12 +1,12 @@
|
||||
name: Linux desktop release
|
||||
|
||||
# Manual until a native Linux runner is registered. The current 0.4.0
|
||||
# Manual until a native Linux runner is registered. The current 0.6.0
|
||||
# packages are built locally using the identical gates documented below.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing stable version tag to package (for example v0.4.0)
|
||||
description: Existing stable version tag to package (for example v0.6.0)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ Future teaching sessions can read the graph instead of starting from scratch. Pr
|
||||
- **Windows (x64):** run the installer. It is currently unsigned; Windows may show an “unknown publisher” or SmartScreen warning.
|
||||
- **Linux (x64):** make the AppImage executable and open it, or install the Debian/Ubuntu `.deb`.
|
||||
|
||||
Add your provider API key and choose your models, then start a session with what you want to understand. LearnHarness supports 30+ provider options, including OpenAI, Anthropic, Google, DeepSeek, and OpenRouter. Optional dictation and web search use separate service keys.
|
||||
Add your provider API key and choose your models, then start a session with what you want to understand. LearnHarness supports 30+ provider options, including OpenAI, Anthropic, Google, DeepSeek, and OpenRouter. Optional web search uses a separate service key. Dictation supports direct Deepgram Nova-3 streaming or MAI-Transcribe-2 using your OpenRouter API key; choose the speech recognition model in Settings → Speech recognition.
|
||||
|
||||
All three platforms support in-app updates.
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { keyValue, openRouterTranscription } from "@/src/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/** Check availability before recording; the token stays on the server. */
|
||||
export function GET() {
|
||||
return Response.json({ configured: Boolean(keyValue("OPENROUTER_API_KEY")) }, { headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return openRouterTranscription(request);
|
||||
}
|
||||
+120
-21
@@ -3,14 +3,17 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Loader2Icon, MicIcon } from "lucide-react";
|
||||
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
||||
import { dictationUrl, type Language } from "@/src/core";
|
||||
import { DEFAULT_STT_MODEL, OPENROUTER_STT_MODEL, STT_MAX_AUDIO_BYTES, WAV_HEADER_BYTES, dictationUrl, dictationWav, type SttModel, type SttLanguage } from "@/src/core";
|
||||
import { LanguageSelect, useI18n } from "@/components/i18n";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
|
||||
/**
|
||||
* Voice dictation — the cloud engine (STT wave 1, 2026-09-17).
|
||||
* Voice dictation — direct Deepgram streaming or whole-recording OpenRouter STT.
|
||||
* MAI-Transcribe-2 records locally, drains the final PCM frame on Stop, then
|
||||
* sends one WAV through /api/stt/transcribe. That route holds the API key.
|
||||
* Both engines buffer text and append once when dictation finishes.
|
||||
*
|
||||
* Architecture (docs/research/stt.md): the renderer captures mic audio in
|
||||
* Deepgram architecture: the renderer captures mic audio in
|
||||
* an AudioWorklet (public/worklets/pcm-capture.js → linear16 16kHz mono),
|
||||
* opens the Deepgram streaming WebSocket DIRECTLY (audio never loops
|
||||
* through our server) with a short-lived token minted by /api/stt/token
|
||||
@@ -107,6 +110,7 @@ export const DEEPGRAM_KEY_ID = "DEEPGRAM_API_KEY";
|
||||
* instead. Exported for pins; the errored idle button's click recognizes
|
||||
* it and deep-links Settings → Keys → the Deepgram row. */
|
||||
export const DICTATION_KEYLESS = "Dictation needs a Deepgram key";
|
||||
export const OPENROUTER_DICTATION_KEYLESS = "Dictation needs an OpenRouter key";
|
||||
|
||||
/** Mint a fresh short-lived token (the route holds the real key
|
||||
* server-side). Module-level: the start path's warm cache and the
|
||||
@@ -239,7 +243,7 @@ export function useDictationTarget(append: (text: string) => void, claimed: bool
|
||||
}, [append, claimed]);
|
||||
}
|
||||
|
||||
export type DictationPhase = "idle" | "connecting" | "recording" | "stopping";
|
||||
export type DictationPhase = "idle" | "connecting" | "recording" | "stopping" | "transcribing";
|
||||
|
||||
export type Dictation = {
|
||||
phase: DictationPhase;
|
||||
@@ -279,10 +283,18 @@ export type Dictation = {
|
||||
* question waits.
|
||||
*/
|
||||
export function useDictation({ append }: { append: (text: string) => void }): Dictation {
|
||||
const { sttLanguage, setDictating } = useI18n();
|
||||
const selectedLanguage = useRef(sttLanguage);
|
||||
selectedLanguage.current = sttLanguage;
|
||||
const recordingLanguage = useRef<Language>(sttLanguage);
|
||||
const { sttLanguage, general, setDictating } = useI18n();
|
||||
const selectedUrl = useRef("");
|
||||
selectedUrl.current = dictationUrl(sttLanguage, general.sttPreferredLanguages);
|
||||
const recordingUrl = useRef(selectedUrl.current);
|
||||
const selected = useRef({ model: general.sttModel ?? DEFAULT_STT_MODEL, language: sttLanguage });
|
||||
selected.current = { model: general.sttModel ?? DEFAULT_STT_MODEL, language: sttLanguage };
|
||||
const recordingConfig = useRef<{ model: SttModel; language: SttLanguage }>(selected.current);
|
||||
const audioFrames = useRef<Uint8Array[]>([]);
|
||||
const audioBytes = useRef(0);
|
||||
const upload = useRef<AbortController | null>(null);
|
||||
const drain = useRef<(() => void) | null>(null);
|
||||
const stopping = useRef(false);
|
||||
const [phase, setPhase] = useState<DictationPhase>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
|
||||
@@ -303,7 +315,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
/** Frames captured before the socket is OPEN (mitigation A): flushed in
|
||||
* order on open; capped at 256 frames (≈32s) with the oldest dropped;
|
||||
* cleared at teardown so a dead run's audio can't leak into the next. */
|
||||
const pendingRef = useRef<ArrayBuffer[]>([]);
|
||||
const pendingRef = useRef<Array<ArrayBuffer | Int16Array>>([]);
|
||||
const genRef = useRef(0); // stale-handler guard (dictate pattern)
|
||||
const safetyRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectsRef = useRef(0);
|
||||
@@ -327,6 +339,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
|
||||
const cleanup = useCallback(() => {
|
||||
genRef.current++;
|
||||
upload.current?.abort();
|
||||
upload.current = null;
|
||||
drain.current?.();
|
||||
drain.current = null;
|
||||
audioFrames.current = [];
|
||||
audioBytes.current = 0;
|
||||
stopping.current = false;
|
||||
dictationActiveFlag = false;
|
||||
setDictating(false);
|
||||
flush(); // the deferred transcript appears when the run ends (owner round 2)
|
||||
@@ -356,7 +375,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
for (const w of waitersRef.current.splice(0)) w();
|
||||
// Pre-mint for the next run (mitigation D) — best-effort; a keyless
|
||||
// page just doesn't warm.
|
||||
warmTokens.refresh();
|
||||
if (selected.current.model === DEFAULT_STT_MODEL) warmTokens.refresh();
|
||||
}, []);
|
||||
|
||||
/** Open the Deepgram socket and wire its messages. `gen` guards staleness. */
|
||||
@@ -365,7 +384,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
// Minted JWTs authenticate via the "bearer" subprotocol — ["token",
|
||||
// jwt] is rejected at the handshake (verified live; "token" is for
|
||||
// raw API keys only, as in the owner's pi dictate extension).
|
||||
const ws = new WebSocket(dictationUrl(recordingLanguage.current), ["bearer", token]);
|
||||
const ws = new WebSocket(recordingUrl.current, ["bearer", token]);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
@@ -425,7 +444,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
recordingLanguage.current = selectedLanguage.current;
|
||||
// Pin the complete configuration once; reconnects must retain both language and hints.
|
||||
recordingUrl.current = selectedUrl.current;
|
||||
recordingConfig.current = { ...selected.current };
|
||||
const batch = recordingConfig.current.model === OPENROUTER_STT_MODEL;
|
||||
audioFrames.current = [];
|
||||
audioBytes.current = 0;
|
||||
stopping.current = false;
|
||||
setDictating(true);
|
||||
dictationActiveFlag = true;
|
||||
setError(null);
|
||||
@@ -441,7 +466,17 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
// the token runs concurrently with it. 127.0.0.1 is a secure
|
||||
// context, so getUserMedia works without TLS; macOS TCC prompts on
|
||||
// first use.
|
||||
const tokenPromise = warmTokens.mint();
|
||||
const controller = new AbortController();
|
||||
upload.current = controller;
|
||||
const tokenPromise = batch ? fetch("/api/stt/transcribe", { signal: controller.signal }).then(async (response) => {
|
||||
if (!response.ok) throw new Error("OpenRouter transcription is unavailable");
|
||||
const status = await response.json() as { configured?: boolean };
|
||||
if (!status.configured) throw new Error(OPENROUTER_DICTATION_KEYLESS);
|
||||
return "";
|
||||
}).catch((error: unknown) => {
|
||||
if (error instanceof Error && error.message === OPENROUTER_DICTATION_KEYLESS) throw error;
|
||||
throw new Error("OpenRouter transcription is unavailable");
|
||||
}) : warmTokens.mint();
|
||||
tokenPromise.catch(() => {}); // surfaced below; no unhandled rejection
|
||||
const micPromise = navigator.mediaDevices.getUserMedia({
|
||||
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
||||
@@ -465,10 +500,26 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
const worklet = new AudioWorkletNode(ctx, "pcm-capture");
|
||||
workletRef.current = worklet;
|
||||
worklet.port.onmessage = (e) => {
|
||||
const msg = e.data as { type: string; rms?: number; buffer?: ArrayBuffer };
|
||||
if (gen !== genRef.current) return;
|
||||
const msg = e.data as { type: string; rms?: number; buffer?: ArrayBuffer | Int16Array };
|
||||
if (msg.type === "level") {
|
||||
levelRef.current = msg.rms ?? 0; // never reset to zero (dictate pattern)
|
||||
} else if (msg.type === "flushed") {
|
||||
drain.current?.();
|
||||
drain.current = null;
|
||||
} else if (msg.type === "audio") {
|
||||
if (batch) {
|
||||
const raw = msg.buffer!;
|
||||
const bytes = raw instanceof ArrayBuffer ? new Uint8Array(raw) : new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
|
||||
if (audioBytes.current + bytes.byteLength + WAV_HEADER_BYTES > STT_MAX_AUDIO_BYTES) {
|
||||
setError("Recording exceeds OpenRouter's 25 MB audio limit");
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
audioFrames.current.push(bytes);
|
||||
audioBytes.current += bytes.byteLength;
|
||||
return;
|
||||
}
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(msg.buffer!);
|
||||
// Mitigation A: not OPEN yet (handshake or reconnect gap) —
|
||||
@@ -510,12 +561,15 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
if (gen !== genRef.current) return;
|
||||
// Mitigation B: the handshake fires the moment the graph is live,
|
||||
// overlapping any remaining token wait — mic-gated, never before it.
|
||||
openSocket(gen, token);
|
||||
if (batch) { if (!stopping.current) setPhase("recording"); }
|
||||
else openSocket(gen, token);
|
||||
}, [openSocket, cleanup]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
const gen = genRef.current;
|
||||
if (phase !== "recording" && phase !== "connecting") return;
|
||||
if (stopping.current) return;
|
||||
stopping.current = true;
|
||||
setPhase("stopping");
|
||||
|
||||
// Mic dies FIRST — no more audio enqueues (dictate pattern). The
|
||||
@@ -524,6 +578,43 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
streamRef.current = null;
|
||||
sourceRef.current?.disconnect();
|
||||
sourceRef.current = null;
|
||||
if (recordingConfig.current.model === OPENROUTER_STT_MODEL) {
|
||||
const worklet = workletRef.current;
|
||||
if (!worklet) { cleanup(); return; }
|
||||
void (async () => {
|
||||
try {
|
||||
// Source is disconnected; drain its last partial PCM frame before wrapping the WAV.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => { drain.current = null; reject(new Error("Could not finish microphone capture")); }, 2000);
|
||||
drain.current = () => { clearTimeout(timer); resolve(); };
|
||||
worklet.port.postMessage({ type: "flush" });
|
||||
});
|
||||
if (gen !== genRef.current) return;
|
||||
worklet.disconnect();
|
||||
workletRef.current = null;
|
||||
if (audioBytes.current === 0) { cleanup(); return; }
|
||||
setPhase("transcribing");
|
||||
const wav = dictationWav(audioFrames.current);
|
||||
audioFrames.current = [];
|
||||
const params = new URLSearchParams(recordingConfig.current);
|
||||
const response = await fetch(`/api/stt/transcribe?${params}`, {
|
||||
method: "POST", headers: { "Content-Type": "audio/wav" }, body: wav,
|
||||
signal: AbortSignal.any([upload.current!.signal, AbortSignal.timeout(75_000)]),
|
||||
});
|
||||
const body = await response.json() as { text?: string; message?: string };
|
||||
if (gen !== genRef.current) return;
|
||||
if (!response.ok) throw new Error(body.message ?? "OpenRouter transcription is unavailable");
|
||||
if (typeof body.text !== "string") throw new Error("OpenRouter returned an invalid transcript");
|
||||
if (body.text.trim()) bufferRef.current.push(body.text.trim());
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
if (gen !== genRef.current) return;
|
||||
setError(error instanceof Error && error.name === "TimeoutError" ? "OpenRouter transcription timed out" : error instanceof Error && error.name === "Error" ? error.message : "OpenRouter transcription is unavailable");
|
||||
cleanup();
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
workletRef.current?.disconnect();
|
||||
workletRef.current = null;
|
||||
|
||||
@@ -586,8 +677,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
// page owns ONE hook instance, and only the mounted page's listener is
|
||||
// attached.
|
||||
useEffect(() => {
|
||||
warmTokens.refresh(); // pre-mint at mount (mitigation D) — silent if keyless
|
||||
}, []);
|
||||
if ((general.sttModel ?? DEFAULT_STT_MODEL) === DEFAULT_STT_MODEL) warmTokens.refresh();
|
||||
}, [general.sttModel]);
|
||||
useEffect(() => () => {
|
||||
// Navigation cancels a pending upload and releases the microphone without inserting text.
|
||||
bufferRef.current = [];
|
||||
cleanup();
|
||||
}, [cleanup]);
|
||||
useEffect(() => {
|
||||
window.addEventListener("lh:kbd-toggle-dictation", toggle);
|
||||
return () => window.removeEventListener("lh:kbd-toggle-dictation", toggle);
|
||||
@@ -630,9 +726,8 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
||||
* idle button deep-links Settings → Keys → the Deepgram row and clears
|
||||
* the error — the surface is clean when the user returns. Any other
|
||||
* error keeps the old behavior (click = retry, which clears). */
|
||||
const keyless = error === DICTATION_KEYLESS;
|
||||
const keyless = error === DICTATION_KEYLESS || error === OPENROUTER_DICTATION_KEYLESS;
|
||||
const [levels, setLevels] = useState<number[]>(() => new Array(METER_CELLS).fill(0));
|
||||
const recording = phase === "recording" || phase === "stopping";
|
||||
/** Adaptive cell count (owner round 2): derived from the meter's
|
||||
* measured width — narrow rows show fewer bars instead of
|
||||
* overflowing under neighbors. */
|
||||
@@ -690,12 +785,12 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
||||
onClick={() => {
|
||||
if (keyless) {
|
||||
clearError();
|
||||
openSettings("keys", DEEPGRAM_KEY_ID);
|
||||
openSettings("keys", error === OPENROUTER_DICTATION_KEYLESS ? "OPENROUTER_API_KEY" : DEEPGRAM_KEY_ID);
|
||||
} else {
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
aria-label={error ? t("Dictation failed: {v0}", { v0: error }) : t("Start dictation")}
|
||||
aria-label={error ? t("Dictation failed: {v0}", { v0: t(error) }) : t("Start dictation")}
|
||||
data-testid="dictation-toggle"
|
||||
// Same blurring as the floating chrome (owner, fix wave round 3),
|
||||
// but lower opacity — the mic stays quieter than the model pills.
|
||||
@@ -709,6 +804,10 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (phase === "transcribing") return <div className="flex h-7 w-full min-w-0 items-center gap-2 text-xs text-muted-foreground" role="status" data-testid="dictation-transcribing">
|
||||
<Loader2Icon className="size-3.5 shrink-0 animate-spin" /><span className="min-w-0 flex-1 truncate">{t("Transcribing…")}</span>
|
||||
<button type="button" onClick={dictation.cancel} className="shrink-0 rounded-sm border border-border px-1.5 py-1 hover:bg-muted">{t("Cancel")}</button>
|
||||
</div>;
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
|
||||
|
||||
+137
-21
@@ -1,18 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { resolveLanguage, translator, type GeneralSettings, type Language, type Translator } from "@/src/core";
|
||||
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { DEFAULT_STT_MODEL, OPENROUTER_STT_MODEL, STT_MODELS, resolveLanguage, resolveSttLanguage, translator, type GeneralSettings, type Language, type SttLanguage, type SttModel, type Translator } from "@/src/core";
|
||||
import { notifySettingsSaved, useSettingsSaved } from "./settings-signal";
|
||||
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT, MenuShell } from "./menu-shell";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { DropdownMenu } from "radix-ui";
|
||||
|
||||
type LanguageField = "language" | "sttLanguage";
|
||||
type I18nValue = {
|
||||
language: Language;
|
||||
sttLanguage: Language;
|
||||
sttLanguage: SttLanguage;
|
||||
general: GeneralSettings;
|
||||
dictating: boolean;
|
||||
setDictating: (active: boolean) => void;
|
||||
t: Translator;
|
||||
saveGeneral: (patch: Partial<GeneralSettings>) => Promise<void>;
|
||||
saveLanguage: (field: "language" | "sttLanguage", value: Language | undefined) => Promise<void>;
|
||||
saveLanguage: <Field extends LanguageField>(field: Field, value: GeneralSettings[Field]) => Promise<void>;
|
||||
};
|
||||
const I18nContext = createContext<I18nValue>({ language: "en", sttLanguage: "en", general: {}, dictating: false, setDictating: () => {}, t: translator("en"), saveGeneral: async () => {}, saveLanguage: async () => {} });
|
||||
export function useI18n(): I18nValue { return useContext(I18nContext); }
|
||||
@@ -27,7 +31,7 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: {
|
||||
const latest = useRef(general);
|
||||
latest.current = general;
|
||||
const language = resolveLanguage(general.language, detected);
|
||||
const sttLanguage = resolveLanguage(general.sttLanguage, detected);
|
||||
const sttLanguage = resolveSttLanguage(general.sttLanguage, detected);
|
||||
const t = useMemo(() => translator(language), [language]);
|
||||
useEffect(() => {
|
||||
const detect = () => setDetected(resolveLanguage(undefined, navigator.languages));
|
||||
@@ -55,7 +59,7 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: {
|
||||
if (!current.ok) throw new Error("Could not load language settings");
|
||||
const body = await current.json() as { settings?: { general?: GeneralSettings } };
|
||||
const next = { ...(body.settings?.general ?? latest.current) };
|
||||
for (const field of ["language", "sttLanguage", "autoUpdate"] as const) {
|
||||
for (const field of ["language", "sttLanguage", "sttModel", "sttPreferredLanguages", "autoUpdate"] as const) {
|
||||
if (!Object.hasOwn(patch, field)) continue;
|
||||
if (patch[field] === undefined) delete next[field];
|
||||
else Object.assign(next, { [field]: patch[field] });
|
||||
@@ -69,28 +73,140 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: {
|
||||
pending.current = write;
|
||||
return write;
|
||||
}, []);
|
||||
const saveLanguage = useCallback((field: "language" | "sttLanguage", value: Language | undefined) => saveGeneral({ [field]: value }), [saveGeneral]);
|
||||
const saveLanguage = useCallback(<Field extends LanguageField,>(field: Field, value: GeneralSettings[Field]) => saveGeneral({ [field]: value }), [saveGeneral]);
|
||||
const value = useMemo(() => ({ language, sttLanguage, general, dictating, setDictating, t, saveGeneral, saveLanguage }), [language, sttLanguage, general, dictating, t, saveGeneral, saveLanguage]);
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function LanguageSelect({ field, disabled = false, compact = false }: { field: "language" | "sttLanguage"; disabled?: boolean; compact?: boolean }) {
|
||||
const { general, t, saveLanguage, sttLanguage, dictating } = useI18n();
|
||||
export function LanguageSelect(props: { field: LanguageField; disabled?: boolean; compact?: boolean }) {
|
||||
return <GeneralSelect {...props} />;
|
||||
}
|
||||
|
||||
export function SttModelSelect() { return <GeneralSelect field="sttModel" />; }
|
||||
|
||||
function GeneralSelect({ field, disabled = false, compact = false }: { field: LanguageField | "sttModel"; disabled?: boolean; compact?: boolean }) {
|
||||
const { general, t, saveGeneral, sttLanguage, dictating } = useI18n();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const label = t(field === "language" ? "Interface language" : "Speech recognition language");
|
||||
return <span className={compact ? "flex shrink-0 items-center gap-1" : "flex flex-col items-end gap-1"}>
|
||||
<select aria-label={label} title={label} value={general[field] ?? "auto"} disabled={disabled || saving || (field === "sttLanguage" && dictating)}
|
||||
className="h-7 max-w-full rounded-md border border-border bg-background px-1.5 text-xs text-foreground disabled:opacity-50"
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
setSaving(true); setError(null);
|
||||
void saveLanguage(field, value === "auto" ? undefined : value as Language).catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not save language settings")).finally(() => setSaving(false));
|
||||
const [open, setOpen] = useState(false);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const menuId = useId();
|
||||
const locked = disabled || saving || (field !== "language" && dictating);
|
||||
const menuOpen = open && !locked;
|
||||
const compactStt = compact && field === "sttLanguage";
|
||||
const saved = general[field] ?? (field === "sttModel" ? DEFAULT_STT_MODEL : "auto");
|
||||
const selected = compactStt && saved === "auto" ? sttLanguage : saved;
|
||||
const label = t(field === "language" ? "Interface language" : field === "sttModel" ? "Speech recognition model" : "Speech recognition language");
|
||||
const testid = field === "sttModel" ? "stt-model-select" : `language-select-${field}${compact ? "-compact" : ""}`;
|
||||
const options: readonly { value: string; label: string }[] = field === "sttModel" ? STT_MODELS.map((model) => ({ value: model.id, label: model.name })) : [
|
||||
...(!compactStt ? [{ value: "auto", label: t("System default") } as const] : []),
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "ru", label: "Русский" },
|
||||
...(field === "sttLanguage" ? [{ value: "multi", label: t("Multilingual") } as const] : []),
|
||||
] as const;
|
||||
useEffect(() => { if (locked) setOpen(false); }, [locked]);
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const outside = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!trigger.current?.contains(target) && !document.getElementById(menuId)?.contains(target)) setOpen(false);
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
trigger.current?.focus();
|
||||
};
|
||||
document.addEventListener("pointerdown", outside);
|
||||
// Claim Escape before the settings dialog or dictation's outer layer.
|
||||
document.addEventListener("keydown", escape, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", outside);
|
||||
document.removeEventListener("keydown", escape, true);
|
||||
};
|
||||
}, [menuOpen, menuId]);
|
||||
const pick = (value: string) => {
|
||||
if (locked) return;
|
||||
setOpen(false);
|
||||
trigger.current?.focus();
|
||||
if (value === saved) return;
|
||||
setSaving(true); setError(null);
|
||||
const patch = field === "sttModel" ? { sttModel: value as SttModel } : { [field]: value === "auto" ? undefined : value };
|
||||
void saveGeneral(patch).catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not save language settings")).finally(() => setSaving(false));
|
||||
};
|
||||
return <span className={compact ? "flex shrink-0 items-center gap-1" : "flex max-w-full shrink-0 flex-col items-end gap-1"}>
|
||||
<button ref={trigger} type="button" aria-label={label} title={label} aria-haspopup="menu" aria-expanded={menuOpen} aria-controls={menuOpen ? menuId : undefined} disabled={locked} data-testid={testid}
|
||||
className={`flex h-7 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-50 ${compact ? "w-24" : field === "sttModel" ? "w-64" : "w-36"}`}
|
||||
onClick={() => setOpen(!menuOpen)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||
event.preventDefault(); event.stopPropagation(); setOpen(true);
|
||||
}}>
|
||||
<option value="auto">{compact ? `${t("Auto")} · ${sttLanguage === "ru" ? "RU" : "EN"}` : t("System default")}</option>
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
<span className="min-w-0 flex-1 truncate text-left">{compact && selected === "multi" ? t("Multi") : options.find((option) => option.value === selected)?.label}</span>
|
||||
<span aria-hidden className="text-muted-foreground">▾</span>
|
||||
</button>
|
||||
{menuOpen && <MenuShell anchorRef={trigger} testid={`${testid}-menu`} preferredSide={compact ? "up" : "down"} menuClass={`flex ${field === "sttModel" ? "w-72" : "w-44"} flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl`}>
|
||||
<div id={menuId} role="menu" aria-label={label} className="flex flex-col gap-0.5">
|
||||
{options.map((option) => <button key={option.value} type="button" role="menuitemradio" aria-checked={selected === option.value} onClick={() => pick(option.value)}
|
||||
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${selected === option.value ? "bg-muted/60 text-card-foreground" : "text-card-foreground"}`}>
|
||||
<span>{option.label}</span>{selected === option.value && <span aria-hidden>✓</span>}
|
||||
</button>)}
|
||||
</div>
|
||||
</MenuShell>}
|
||||
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
|
||||
</span>;
|
||||
}
|
||||
|
||||
/** Preferred languages guide multilingual recognition; they are saved independently of its mode. */
|
||||
export function SttPreferredLanguages() {
|
||||
const { general, sttLanguage, dictating, saveGeneral, t } = useI18n();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const busy = useRef(false);
|
||||
const preferred = general.sttPreferredLanguages ?? [];
|
||||
const unavailable = sttLanguage !== "multi" || general.sttModel === OPENROUTER_STT_MODEL || dictating;
|
||||
const menuOpen = open && !unavailable;
|
||||
const summary = (["en", "ru"] as const).filter((language) => preferred.includes(language)).map((language) => language === "en" ? "English" : "Русский").join(", ") || t("No preference");
|
||||
useEffect(() => { if (unavailable) setOpen(false); }, [unavailable]);
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
window.dispatchEvent(new CustomEvent(MENU_OPEN_EVENT));
|
||||
return () => { window.dispatchEvent(new CustomEvent(MENU_CLOSE_EVENT)); };
|
||||
}, [menuOpen]);
|
||||
const toggle = (language: Language) => {
|
||||
if (unavailable || busy.current) return;
|
||||
const next = (["en", "ru"] as const).filter((code) => code === language ? !preferred.includes(code) : preferred.includes(code));
|
||||
busy.current = true;
|
||||
setSaving(true); setError(null);
|
||||
void saveGeneral({ sttPreferredLanguages: next })
|
||||
.catch(() => setError("Could not save preferred languages"))
|
||||
.finally(() => { busy.current = false; setSaving(false); });
|
||||
};
|
||||
return <span className="flex max-w-full shrink-0 flex-col items-end gap-1">
|
||||
<DropdownMenu.Root open={menuOpen} onOpenChange={setOpen} modal={false}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button type="button" data-testid="stt-preferred-select" aria-label={t("Preferred languages")} title={summary} disabled={unavailable || saving}
|
||||
className={`flex h-7 w-44 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 ${unavailable ? "disabled:cursor-not-allowed disabled:opacity-50" : "disabled:cursor-default"}`}
|
||||
onKeyDown={(event) => { if (event.key === "ArrowUp" && !unavailable && !saving) { event.preventDefault(); event.stopPropagation(); setOpen(true); } }}>
|
||||
<span className="min-w-0 flex-1 truncate text-left">{summary}</span><span aria-hidden className="text-muted-foreground">▾</span>
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content data-testid="stt-preferred-menu" data-lh-menu="" aria-label={t("Preferred languages")} side="bottom" align="end" sideOffset={4} collisionPadding={8} loop
|
||||
className="z-60 flex w-44 max-h-[min(60vh,var(--radix-dropdown-menu-content-available-height))] flex-col gap-0.5 overflow-y-auto rounded-xl border border-border bg-card p-2 text-xs shadow-xl outline-none"
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
onEscapeKeyDown={(event) => event.stopPropagation()}>
|
||||
{(["en", "ru"] as const).map((language) => <DropdownMenu.CheckboxItem key={language} data-testid={`stt-preferred-${language}`} checked={preferred.includes(language)} disabled={saving}
|
||||
onCheckedChange={() => toggle(language)} onSelect={(event) => event.preventDefault()}
|
||||
className={`flex cursor-default select-none items-center gap-2 rounded-lg px-2 py-1.5 text-left text-card-foreground outline-none transition-colors data-highlighted:bg-muted ${preferred.includes(language) ? "bg-muted/60" : ""}`}>
|
||||
<span aria-hidden className="flex size-3.5 shrink-0 items-center justify-center rounded-xs border border-border"><DropdownMenu.ItemIndicator><CheckIcon className="size-3" /></DropdownMenu.ItemIndicator></span>
|
||||
{language === "en" ? "English" : "Русский"}
|
||||
</DropdownMenu.CheckboxItem>)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
|
||||
</span>;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ import {
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { prettyModel } from "@/components/usage-format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ZapIcon } from "lucide-react";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
|
||||
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
||||
import { ChatGPTBackgroundAccess } from "./chatgpt-connection";
|
||||
@@ -152,13 +154,22 @@ export function composeSlotModel(
|
||||
delete next[slot];
|
||||
return next;
|
||||
}
|
||||
const model = provider && id ? { provider, id } : prev?.model;
|
||||
next[slot] = {
|
||||
...(provider && id ? { model: { provider, id } } : prev?.model ? { model: prev.model } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}),
|
||||
...(model?.provider === "chatgpt" && prev?.fastMode !== undefined ? { fastMode: prev.fastMode } : {}),
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Speed belongs to the tier, independently of its reasoning budget. */
|
||||
export function composeSlotFast(models: ModelsSettings | undefined, slot: ModelSlotName, enabled: boolean): ModelsSettings {
|
||||
const current = models?.[slot];
|
||||
if (current?.model?.provider !== "chatgpt") return { ...models };
|
||||
return { ...models, [slot]: { ...current, fastMode: enabled } };
|
||||
}
|
||||
|
||||
/** Compose: the search-engine preference. null = the explicit pin is
|
||||
* REMOVED (auto-activation takes over — the schema's resting state).
|
||||
* Pure — exported for tests. */
|
||||
@@ -258,11 +269,12 @@ function MenuTrigger({
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
data-testid={testid}
|
||||
onClick={() => {
|
||||
if (!disabled) onClick();
|
||||
}}
|
||||
className={`flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${
|
||||
className={`flex h-7 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${
|
||||
disabled ? "cursor-not-allowed" : "hover:border-muted-foreground/50"
|
||||
} ${widthClass}`}
|
||||
>
|
||||
@@ -302,12 +314,21 @@ export function AgentSection({
|
||||
/** Writes the composed WHOLE agent section (replace-PUT discipline). */
|
||||
onSectionChange: (next: AgentSettings) => void;
|
||||
/** Writes the composed models section (slots — replace-PUT discipline). */
|
||||
onModelsChange: (next: ModelsSettings) => void;
|
||||
onModelsChange: (next: ModelsSettings) => void | Promise<void>;
|
||||
/** Writes the whole compaction section (replace-PUT discipline). */
|
||||
onCompactionChange: (next: CompactionSettings) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
||||
const [savingModels, setSavingModels] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const updateModels = async (next: ModelsSettings) => {
|
||||
if (savingModels) return;
|
||||
setSavingModels(true); setModelsError(null);
|
||||
try { await onModelsChange(next); }
|
||||
catch { setModelsError("Could not save model settings"); }
|
||||
finally { setSavingModels(false); }
|
||||
};
|
||||
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
|
||||
* Default hints and thinking lists resolve from it. */
|
||||
const [slots, setSlots] = useState<ModelsBody["slots"] | null>(null);
|
||||
@@ -617,6 +638,7 @@ export function AgentSection({
|
||||
return (
|
||||
<>
|
||||
<SettingsGroup label={t("Models")} testid="group-models">
|
||||
{modelsError && <p role="alert" className="text-xs text-destructive">{t(modelsError)}</p>}
|
||||
{MODEL_SLOTS.map((slot) => {
|
||||
const override = models?.[slot];
|
||||
const modelId = override?.model?.id ?? "";
|
||||
@@ -644,35 +666,52 @@ export function AgentSection({
|
||||
label={SLOT_META[slot].label}
|
||||
hint={SLOT_META[slot].roster}
|
||||
testid={`slot-row-${slot}`}
|
||||
className="flex-wrap items-start gap-y-2 [&>span:first-child]:flex-[1_1_12rem]"
|
||||
>
|
||||
{/* The anchor for the outside-click contract is the
|
||||
span holding BOTH triggers of the open menu. */}
|
||||
span holding the tier's controls. */}
|
||||
<span
|
||||
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
||||
className="relative flex items-center gap-2"
|
||||
className="relative flex w-68 max-w-full shrink-0 flex-col items-start gap-2"
|
||||
>
|
||||
<span data-testid={`slot-controls-${slot}`} className="grid w-full grid-cols-[minmax(0,1fr)_5rem_auto] items-center gap-2">
|
||||
<MenuTrigger
|
||||
value={modelId ? prettyModel(modelId) : ""}
|
||||
emptyLabel="—"
|
||||
ariaLabel={t("{v0} model", { v0: t(SLOT_META[slot].label) })}
|
||||
testid={`slot-model-${slot}`}
|
||||
open={thisMenuOpen === "model"}
|
||||
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
|
||||
widthClass="w-full min-w-0"
|
||||
disabled={savingModels}
|
||||
/>
|
||||
<MenuTrigger
|
||||
// Legacy display (chunk 2 item 4): no stored level + a known
|
||||
// model → the RESOLVED level (the ordering's first supported —
|
||||
// what the engine build runs). Display-only until a pick writes.
|
||||
value={thinking || (chosen ? defaultOrderLevel(levels) : "")}
|
||||
emptyLabel="—"
|
||||
ariaLabel={t("{v0} thinking", { v0: t(SLOT_META[slot].label) })}
|
||||
testid={`slot-thinking-${slot}`}
|
||||
open={thisMenuOpen === "thinking"}
|
||||
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })}
|
||||
widthClass="w-20"
|
||||
disabled={savingModels}
|
||||
/>
|
||||
<Hint label={t(provider === "chatgpt" ? "Fast mode uses more ChatGPT allowance." : "Fast mode is available only for ChatGPT models.")}>
|
||||
<span className="inline-flex">
|
||||
<button type="button" data-testid={`slot-fast-${slot}`} aria-label={t("{v0} fast mode", { v0: t(SLOT_META[slot].label) })}
|
||||
aria-pressed={provider === "chatgpt" && override?.fastMode === true} disabled={provider !== "chatgpt" || !chosen || savingModels}
|
||||
onClick={() => { setOpenMenu(null); void updateModels(composeSlotFast(models, slot, !override?.fastMode)); }}
|
||||
className={cn("flex h-7 items-center gap-1 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:opacity-40",
|
||||
provider === "chatgpt" && chosen ? "disabled:cursor-default" : "disabled:cursor-not-allowed",
|
||||
provider === "chatgpt" && override?.fastMode ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50")}>
|
||||
<ZapIcon className="size-3.5" aria-hidden /><span>{t("Fast")}</span>
|
||||
</button>
|
||||
</span>
|
||||
</Hint>
|
||||
</span>
|
||||
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
|
||||
<MenuTrigger
|
||||
value={modelId ? prettyModel(modelId) : ""}
|
||||
emptyLabel="—"
|
||||
ariaLabel={t("{v0} model", { v0: t(SLOT_META[slot].label) })}
|
||||
testid={`slot-model-${slot}`}
|
||||
open={thisMenuOpen === "model"}
|
||||
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
|
||||
widthClass="min-w-20"
|
||||
/>
|
||||
<MenuTrigger
|
||||
// Legacy display (chunk 2 item 4): no stored level + a known
|
||||
// model → the RESOLVED level (the ordering's first supported —
|
||||
// what the engine build runs). Display-only until a pick writes.
|
||||
value={thinking || (chosen ? defaultOrderLevel(levels) : "")}
|
||||
emptyLabel="—"
|
||||
ariaLabel={t("{v0} thinking", { v0: t(SLOT_META[slot].label) })}
|
||||
testid={`slot-thinking-${slot}`}
|
||||
open={thisMenuOpen === "thinking"}
|
||||
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })}
|
||||
widthClass="w-20"
|
||||
/>
|
||||
{thisMenuOpen === "model" && (
|
||||
<MenuShell
|
||||
preferredSide="down"
|
||||
@@ -689,7 +728,7 @@ export function AgentSection({
|
||||
// fresh pick writes a level — the stored one rides as-is
|
||||
// (the write clamps server-side), else the ordering's level.
|
||||
const next = populateLevel(thinking || undefined, entry.supportedLevels);
|
||||
onModelsChange(composeSlotModel(models, slot, entry.provider, entry.id, next));
|
||||
void updateModels(composeSlotModel(models, slot, entry.provider, entry.id, next));
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
footer={
|
||||
@@ -713,7 +752,7 @@ export function AgentSection({
|
||||
// the same one the trigger shows.
|
||||
effectiveLevel={thinking || (chosen ? defaultOrderLevel(levels) : undefined)}
|
||||
onPick={(level) => {
|
||||
onModelsChange(composeSlotModel(models, slot, provider, modelId, level));
|
||||
void updateModels(composeSlotModel(models, slot, provider, modelId, level));
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
/>
|
||||
@@ -762,11 +801,12 @@ export function AgentSection({
|
||||
label={t("Quizzing")}
|
||||
hint={t("How both tutors check your understanding")}
|
||||
testid="quiz-mechanics-row"
|
||||
className="flex-wrap gap-y-2 [&>span]:flex-[1_1_12rem]"
|
||||
>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t("Quizzing mechanics")}
|
||||
className="flex rounded-lg border border-border/60 bg-muted/30 p-0.5"
|
||||
className="flex max-w-full shrink-0 rounded-lg border border-border/60 bg-muted/30 p-0.5"
|
||||
>
|
||||
{(["choice", "free"] as const).map((m) => {
|
||||
const active = m === "free" ? free : !free;
|
||||
@@ -779,7 +819,7 @@ export function AgentSection({
|
||||
data-testid={`quiz-mechanics-${m}`}
|
||||
onClick={() => onSectionChange(composeQuizMechanics(agent, m))}
|
||||
className={cn(
|
||||
"h-8 rounded-md px-3 text-sm font-medium transition-colors",
|
||||
"min-h-8 min-w-0 rounded-md px-3 py-1 text-sm leading-snug font-medium break-words transition-colors",
|
||||
active ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -65,6 +65,7 @@ export function SettingsRow({
|
||||
children,
|
||||
testid,
|
||||
dimmed,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
@@ -73,10 +74,11 @@ export function SettingsRow({
|
||||
/** Dependency graying, graph-view style: the whole row dims when the
|
||||
* control is inert (parent off, no file yet, …). */
|
||||
dimmed?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div data-testid={testid} className={cn("flex items-center justify-between gap-4 py-1", dimmed && "opacity-40")}>
|
||||
<div data-testid={testid} className={cn("flex items-center justify-between gap-4 py-1", dimmed && "opacity-40", className)}>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-prose text-(length:--lh-prose-size)">{t(label)}</span>
|
||||
{hint && <span className="block text-xs text-muted-foreground/70">{t(hint)}</span>}
|
||||
@@ -237,4 +239,4 @@ export function CommitInput({
|
||||
className="h-7 w-40 rounded-sm border border-border bg-background px-2 font-ui text-xs text-foreground outline-none placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +335,7 @@ export function KeysSection({
|
||||
/** The paste editor for a freshly chosen Add-provider target. */
|
||||
const [adding, setAdding] = useState<ProviderKeyInfo | null>(null);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const followedProvider = useRef<string | null>(null);
|
||||
|
||||
// Escape folds any open editor (unfolded row or the Add-provider paste)
|
||||
// WITHOUT closing the dialog. Claimed at document CAPTURE phase — the
|
||||
@@ -372,7 +373,17 @@ export function KeysSection({
|
||||
// named row; a fresh mount seeds it via the useState initializer.
|
||||
useEffect(() => {
|
||||
if (initialRow !== undefined) setOpenRow(initialRow);
|
||||
followedProvider.current = null;
|
||||
}, [initialRow]);
|
||||
// A missing model-provider key has no table row yet. Deep-link into the
|
||||
// existing Add-provider editor instead (for example, OpenRouter STT setup).
|
||||
useEffect(() => {
|
||||
if (!initialRow || !statuses || !providers || followedProvider.current === initialRow) return;
|
||||
followedProvider.current = initialRow;
|
||||
if (statuses.some((status) => status.id === initialRow)) return;
|
||||
const provider = providers.find((provider) => provider.envVar === initialRow);
|
||||
if (provider) { setAdding(provider); setAddError(null); setOpenRow(null); }
|
||||
}, [initialRow, statuses, providers]);
|
||||
|
||||
async function saveKey(id: string, value: string): Promise<string | null> {
|
||||
const res = await fetch(`/api/keys/${id}`, {
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
type GraphSettings,
|
||||
type UserSettings,
|
||||
} from "@/src/core";
|
||||
import { LanguageSelect, useI18n } from "@/components/i18n";
|
||||
import { LanguageSelect, SttModelSelect, SttPreferredLanguages, useI18n } from "@/components/i18n";
|
||||
import { useAppearance } from "./appearance.tsx";
|
||||
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
|
||||
import { AgentSection } from "./agent-section.tsx";
|
||||
@@ -62,6 +62,7 @@ const SECTIONS = [
|
||||
{ id: "appearance", label: "Appearance" },
|
||||
{ id: "graph", label: "Graph" },
|
||||
{ id: "keyboard", label: "Keyboard", hint: "Stored on this device" },
|
||||
{ id: "stt", label: "Speech recognition" },
|
||||
{ id: "general", label: "General" },
|
||||
] as const;
|
||||
type SectionId = (typeof SECTIONS)[number]["id"];
|
||||
@@ -367,7 +368,6 @@ export function SettingsDialogBody({
|
||||
testid="section-general"
|
||||
>
|
||||
<SettingsRow label={t("Interface language")} testid="row-language"><LanguageSelect field="language" /></SettingsRow>
|
||||
<SettingsRow label={t("Speech recognition language")} testid="row-stt-language"><LanguageSelect field="sttLanguage" /></SettingsRow>
|
||||
<SettingsRow
|
||||
label={t("Auto-update")}
|
||||
hint={AUTO_UPDATE_HINT}
|
||||
@@ -383,6 +383,15 @@ export function SettingsDialogBody({
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
)}
|
||||
{active === "stt" && (
|
||||
<SettingsSection title={t("Speech recognition")} testid="section-stt">
|
||||
<SettingsRow label={t("Speech recognition model")} testid="row-stt-model" className="flex-wrap items-start gap-y-2 [&>span:first-child]:flex-[1_1_12rem]"><SttModelSelect /></SettingsRow>
|
||||
<SettingsRow label={t("Speech recognition language")} testid="row-stt-language"><LanguageSelect field="sttLanguage" /></SettingsRow>
|
||||
<SettingsRow label={t("Preferred languages")} testid="row-stt-preferred-languages">
|
||||
<SttPreferredLanguages />
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
)}
|
||||
{active === "keys" && (
|
||||
<SettingsSection title={t("Providers")} testid="section-keys">
|
||||
<KeysSection initialRow={initialRow ?? undefined} />
|
||||
@@ -399,7 +408,7 @@ export function SettingsDialogBody({
|
||||
graph={record.graph}
|
||||
compaction={record.compaction}
|
||||
onModelsChange={(next: ModelsSettings) =>
|
||||
void write("models", next)
|
||||
write("models", next)
|
||||
}
|
||||
onSectionChange={(next: AgentSettings) =>
|
||||
void write("agent", next)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Desktop builds and releases
|
||||
|
||||
For this fork’s Linux 0.4.0 release on Gitea, see [Gitea releases](gitea-release.md).
|
||||
For this fork’s Linux and Windows 0.6.0 release on Gitea, see [Gitea releases](gitea-release.md).
|
||||
The three-platform GitHub process below documents the upstream reference.
|
||||
|
||||
## Platforms
|
||||
|
||||
+66
-13
@@ -2,8 +2,8 @@
|
||||
|
||||
The release destination is
|
||||
https://server2.sorokin-oleg-edu.xyz/replicated/learnharness.
|
||||
Version **0.4.0 targets Linux x64**. Windows is deferred until a native Windows
|
||||
builder is available. The upstream GitHub workflow and three-platform packaging
|
||||
Version **0.6.0 targets Linux x64 and Windows x64**, with each platform built
|
||||
and verified on its native operating system. The upstream GitHub workflow and three-platform packaging
|
||||
configuration remain available as reference in `.github/workflows/release.yml`
|
||||
and `electron-builder.yml`.
|
||||
|
||||
@@ -48,20 +48,62 @@ node scripts/smoke-provider-runtime.mjs --standalone dist/linux-unpacked/resourc
|
||||
xvfb-run -a node scripts/smoke-demo-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness --browser
|
||||
```
|
||||
|
||||
The Linux 0.4.0 release has exactly four attachments:
|
||||
The Linux portion of 0.6.0 has these attachments:
|
||||
|
||||
- `LearnHarness-0.4.0-x86_64.AppImage`
|
||||
- `LearnHarness-0.4.0-amd64.deb`
|
||||
- `LearnHarness-0.6.0-x86_64.AppImage`
|
||||
- `LearnHarness-0.6.0-amd64.deb`
|
||||
- `latest-linux.yml`
|
||||
- `SHA256SUMS`
|
||||
|
||||
## Preparing the Gitea draft
|
||||
## Preparing Windows packages
|
||||
|
||||
Use a clean checkout of the same `v0.6.0` tag on Windows x64 with Node 26 and Git
|
||||
for Windows. Keep build tools and logs outside the checkout. The Windows
|
||||
release checkout is `~/Documents/Gitea/learnharness-release-0.6.0`, linked to the
|
||||
existing repository in `~/Documents/Gitea/learnharness`.
|
||||
|
||||
In PowerShell, stop after any failed command:
|
||||
|
||||
```powershell
|
||||
npm.cmd ci
|
||||
$env:NODE_ENV = 'test'
|
||||
$env:ELECTRON_RUN_AS_NODE = '1'
|
||||
node node_modules/electron/cli.js --import tsx --test tests/pdf-native-render.test.ts tests/pdf-ops.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts
|
||||
Remove-Item Env:ELECTRON_RUN_AS_NODE
|
||||
node --import tsx --test tests/i18n.test.tsx
|
||||
Remove-Item Env:NODE_ENV
|
||||
node node_modules/next/dist/bin/next build
|
||||
node scripts/smoke-provider-runtime.mjs
|
||||
node scripts/build-server.mjs
|
||||
node scripts/smoke-server.mjs --electron
|
||||
node node_modules/electron-builder/out/cli/cli.js --config electron-builder.gitea.yml --win --x64 --publish never
|
||||
node scripts/gitea-release.mjs verify-package dist/win-unpacked
|
||||
node scripts/smoke-provider-runtime.mjs --standalone dist/win-unpacked/resources/server/standalone
|
||||
node scripts/smoke-provider-runtime.mjs --standalone dist/win-unpacked/resources/server/standalone --runtime dist/win-unpacked/LearnHarness.exe
|
||||
node scripts/smoke-demo-runtime.mjs --standalone dist/win-unpacked/resources/server/standalone --runtime dist/win-unpacked/LearnHarness.exe --browser
|
||||
```
|
||||
|
||||
Windows contributes three attachments:
|
||||
|
||||
- `LearnHarness-0.6.0-x64-setup.exe`
|
||||
- `LearnHarness-0.6.0-x64-setup.exe.blockmap`
|
||||
- `latest.yml`
|
||||
|
||||
The combined release has **seven attachments**, including one `SHA256SUMS`
|
||||
covering all six other files. Verify that `latest.yml` names the Windows
|
||||
installer exactly, reports version `0.6.0`, and matches the installer's actual
|
||||
size and SHA-512. Its gzip blockmap must cover the installer and match every
|
||||
block checksum. Confirm the packaged `resources/app-update.yml` uses the Gitea
|
||||
generic provider. Windows builds follow upstream's unsigned, per-user installer
|
||||
configuration.
|
||||
|
||||
## Preparing a Linux-only Gitea draft
|
||||
|
||||
Commit and push the release source, then push its exact annotated tag:
|
||||
|
||||
```sh
|
||||
git tag -a v0.4.0 -m 'LearnHarness 0.4.0'
|
||||
git push gitea main v0.4.0
|
||||
git tag -a v0.6.0 -m 'LearnHarness 0.6.0'
|
||||
git push gitea main v0.6.0
|
||||
```
|
||||
|
||||
Supply `GITEA_TOKEN` only to the upload command. A token file may remain outside
|
||||
@@ -75,7 +117,18 @@ The tool requires the tag to exist in Gitea, creates or reuses one draft at that
|
||||
tag, uploads missing assets, and downloads every attachment to verify its actual
|
||||
SHA-256 against the local verified build. Existing mismatched attachments and
|
||||
published releases are refused. It never publishes or changes repository
|
||||
visibility. Release notes come from `docs/release-0.4.0.md`.
|
||||
visibility. Release notes come from `docs/release-0.6.0.md`.
|
||||
|
||||
This uploader and its remote verifier enforce a **Linux-only** inventory. They
|
||||
will refuse a combined Linux/Windows draft. For the combined 0.6.0 release, add
|
||||
the verified Windows attachments to the existing draft and replace `SHA256SUMS`
|
||||
with the combined manifest. Keep the Linux installers and feed unchanged. Check
|
||||
all seven attachment names and sizes against the verified local builds. For
|
||||
the owner-approved 0.6.0 release, local installer/feed/blockmap validation and
|
||||
the combined checksum manifest are completed before upload. Confirm successful
|
||||
upload responses and publish immediately afterward; do not download attachments
|
||||
again or run post-upload hash checks. Do not publish until both platforms'
|
||||
local checks pass.
|
||||
|
||||
A separate verification can be repeated without changing the draft:
|
||||
|
||||
@@ -92,12 +145,12 @@ public `latest` feed and in-app automatic updates expose this version.
|
||||
workflow with an existing release tag as input. It requires a native Linux x64
|
||||
runner labeled `ubuntu-24.04`, the label used by upstream's Linux release job,
|
||||
plus the native Electron dependencies and `xvfb-run`. There were no runners
|
||||
registered when 0.4.0 was prepared, so its packages are built locally. A tag push
|
||||
registered when 0.6.0 was prepared, so its packages are built locally. A tag push
|
||||
does not queue a job that cannot run.
|
||||
|
||||
The workflow uses the repository's ephemeral `GITEA_TOKEN` with release-write
|
||||
permission only in the final upload step. It checks out the requested tag without
|
||||
persisting credentials and leaves the result as a verified draft. Public pull
|
||||
requests cannot trigger this publishing workflow. Add Windows only with a native
|
||||
Windows build and its corresponding smoke checks; do not substitute a Linux
|
||||
standalone payload into a Windows installer.
|
||||
requests cannot trigger this publishing workflow. This workflow remains
|
||||
Linux-only; the combined 0.6.0 draft was assembled from local native builds.
|
||||
Windows has its own standalone payload and smoke checks, as described above.
|
||||
|
||||
@@ -21,9 +21,10 @@ for unsupported languages.
|
||||
- Language changes preserve other settings. Existing conversations and imported
|
||||
learning material retain their original text.
|
||||
|
||||
This Gitea release targets **Linux x64**: AppImage and Debian packages. Windows
|
||||
packaging is deferred until a native Windows builder is available. Automatic
|
||||
updates for these packages use this fork's public Gitea releases.
|
||||
This Gitea release includes **Linux x64** AppImage and Debian packages and a
|
||||
**Windows x64** installer. Both platforms were built and checked on their native
|
||||
operating systems. Automatic updates use this fork's public Gitea releases.
|
||||
The Windows installer is unsigned, matching upstream's Windows packaging.
|
||||
|
||||
To use live dictation, configure a Deepgram key under **Settings → Providers**.
|
||||
To start AI sessions, connect an AI provider and select the required model tiers.
|
||||
@@ -49,10 +50,10 @@ LearnHarness теперь поддерживает английский и ру
|
||||
- Смена языка сохраняет остальные настройки. Существующие разговоры и
|
||||
импортированные учебные материалы остаются на исходном языке.
|
||||
|
||||
Этот выпуск в Gitea предназначен для **Linux x64** и доступен в форматах AppImage
|
||||
и Debian. Выпуск для Windows отложен до появления сборочной машины с Windows.
|
||||
Автоматические обновления этих пакетов используют публичные выпуски этого форка
|
||||
в Gitea.
|
||||
Этот выпуск в Gitea включает пакеты AppImage и Debian для **Linux x64** и
|
||||
установщик для **Windows x64**. Сборка и проверка каждой версии выполнены в её
|
||||
операционной системе. Автоматические обновления используют публичные выпуски
|
||||
этого форка в Gitea. Установщик Windows не подписан — как и в исходном проекте.
|
||||
|
||||
Для диктовки добавьте ключ Deepgram в разделе **Настройки → Провайдеры**.
|
||||
Для занятий с ИИ подключите провайдера и выберите модели для всех нужных уровней.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# LearnHarness 0.5.0
|
||||
|
||||
## English
|
||||
|
||||
This release adds ChatGPT Fast mode and improves the language controls and
|
||||
Russian settings layout introduced in 0.4.0.
|
||||
|
||||
- Interface-language and speech-recognition selectors in **Settings → General**,
|
||||
plus the quick selector beside the microphone, now use the app's styled menus.
|
||||
The menus include selected-item checkmarks and keyboard navigation; Escape
|
||||
closes the menu and restores focus to its trigger. Language preferences stay
|
||||
independent, and STT selection remains locked while recording.
|
||||
- **Quizzing** keeps its full Russian labels. The label and segmented control
|
||||
share a row when space permits. Narrower panels stack the control beneath the
|
||||
hint with matching left alignment and enough height for wrapped text.
|
||||
- **Settings → Agent** now has a **⚡ Fast** toggle for each Strong, Cheap, and
|
||||
Vision model tier. Fast mode is **off by default** and initially available only
|
||||
for **ChatGPT** connections. It requests priority processing and may consume
|
||||
more ChatGPT plan allowance; availability depends on the model and account.
|
||||
- Fast preferences take effect on the **next request**, including existing
|
||||
ChatGPT conversations and newly started background work in the corresponding
|
||||
tier. The selected model and reasoning effort are preserved. Selecting another
|
||||
provider turns Fast off for that tier; other providers show it as unavailable.
|
||||
- Unsupported Fast requests produce actionable, localized errors. Connections
|
||||
keep their existing account, background-consent, and no-API-key-fallback rules.
|
||||
|
||||
Packages: **Linux x64** AppImage and Debian, and a **Windows x64** installer.
|
||||
Both platforms use this fork's public Gitea releases for automatic updates.
|
||||
Windows packaging remains unsigned, matching upstream.
|
||||
|
||||
## Русский
|
||||
|
||||
В этом выпуске добавлен быстрый режим ChatGPT и улучшены языковые настройки
|
||||
и их отображение на русском языке, появившиеся в версии 0.4.0.
|
||||
|
||||
- Выбор языка интерфейса и распознавания речи в разделе **Настройки → Общие**,
|
||||
а также быстрый выбор языка рядом с микрофоном теперь используют меню в стиле
|
||||
приложения. Выбранный пункт отмечен галочкой, поддерживается управление
|
||||
клавиатурой. Escape закрывает меню и возвращает фокус на кнопку. Языковые
|
||||
настройки остаются независимыми; язык распознавания нельзя менять во время
|
||||
записи.
|
||||
- В разделе **Проверка понимания** сохранены полные русские подписи. Если места
|
||||
достаточно, заголовок и переключатель расположены в одной строке. В узких
|
||||
панелях переключатель размещается под пояснением с выравниванием по левому
|
||||
краю; высота кнопок учитывает перенос текста.
|
||||
- В разделе **Настройки → Агент** появился переключатель **⚡ Быстро** для каждого
|
||||
уровня моделей: основной, экономичной и с поддержкой изображений. Быстрый
|
||||
режим **отключён по умолчанию** и пока доступен только для подключений
|
||||
**ChatGPT**. Он запрашивает приоритетную обработку и может быстрее расходовать
|
||||
лимит ChatGPT. Доступность зависит от модели и учётной записи.
|
||||
- Настройка применяется к **следующему запросу**, в том числе в существующих
|
||||
занятиях с ChatGPT и в новых фоновых задачах соответствующего уровня.
|
||||
Выбранная модель и уровень рассуждений сохраняются. При выборе другого
|
||||
провайдера быстрый режим для этого уровня отключается; для остальных
|
||||
провайдеров переключатель недоступен.
|
||||
- Если быстрый режим не поддерживается, приложение показывает понятную ошибку
|
||||
на выбранном языке. Сохраняются выбранная учётная запись, разрешения на
|
||||
фоновую работу и запрет автоматического перехода на API-ключ.
|
||||
|
||||
Пакеты: AppImage и Debian для **Linux x64**, установщик для **Windows x64**.
|
||||
Обе версии используют публичные выпуски этого форка в Gitea для автоматических
|
||||
обновлений. Установщик Windows не подписан, как и в исходном проекте.
|
||||
@@ -0,0 +1,53 @@
|
||||
# LearnHarness 0.5.1
|
||||
|
||||
## English
|
||||
|
||||
This release adds multilingual speech recognition and simplifies the STT picker
|
||||
beside the microphone.
|
||||
|
||||
- **Settings → General → Speech recognition language** now includes
|
||||
**Multilingual**. It uses Deepgram Nova-3's `language=multi` mode for speech
|
||||
that switches between supported languages, including Russian and English.
|
||||
The preference is saved and applies to the next recording, including any
|
||||
reconnects during that recording.
|
||||
- The chat STT picker now offers **English**, **Русский**, and **Multilingual**.
|
||||
The compact button displays **Multi** when multilingual recognition is selected.
|
||||
The **Auto · LANG** entry has been removed from this picker.
|
||||
- **System default** remains available in General settings. When it is active,
|
||||
the chat picker displays the resolved English or Russian language. Opening
|
||||
the picker preserves System default; choosing a language saves that explicit
|
||||
preference, even when it matches the current system language.
|
||||
- Interface and STT languages remain independently selectable. Existing
|
||||
preferences and system defaults are preserved, and both STT controls remain
|
||||
locked while recording. The menus retain keyboard navigation and selected-item
|
||||
checkmarks.
|
||||
|
||||
Packages: **Linux x64** AppImage and Debian, and a **Windows x64** installer.
|
||||
Both platforms use this fork's public Gitea releases for automatic updates.
|
||||
Windows packaging remains unsigned, matching the previous release.
|
||||
|
||||
## Русский
|
||||
|
||||
В этом выпуске добавлено многоязычное распознавание речи и упрощён выбор языка
|
||||
рядом с микрофоном.
|
||||
|
||||
- В разделе **Настройки → Общие → Язык распознавания речи** появился вариант
|
||||
**Несколько языков**. Он включает режим `language=multi` в Deepgram Nova-3
|
||||
для речи с переключением между поддерживаемыми языками, в том числе русским
|
||||
и английским. Выбор сохраняется и применяется к следующей записи, включая
|
||||
повторные подключения во время этой записи.
|
||||
- В меню языка распознавания рядом с микрофоном теперь доступны **English**,
|
||||
**Русский** и **Несколько языков**. При многоязычном распознавании компактная
|
||||
кнопка показывает **Мульти**. Пункт **Авто · LANG** из этого меню удалён.
|
||||
- В общих настройках по-прежнему доступен вариант **Язык системы**. Если он
|
||||
выбран, кнопка рядом с микрофоном показывает фактически используемый язык:
|
||||
английский или русский. Открытие меню не меняет настройку. Выбор языка
|
||||
сохраняет его явно, даже если он совпадает с текущим языком системы.
|
||||
- Язык интерфейса и язык распознавания остаются независимыми. Сохраняются
|
||||
прежние предпочтения и выбор языка системы. Оба переключателя языка
|
||||
распознавания заблокированы во время записи. Меню поддерживают управление
|
||||
клавиатурой и отмечают выбранный пункт галочкой.
|
||||
|
||||
Пакеты: AppImage и Debian для **Linux x64**, установщик для **Windows x64**.
|
||||
Обе версии используют публичные выпуски этого форка в Gitea для автоматических
|
||||
обновлений. Установщик Windows не подписан, как и в предыдущем выпуске.
|
||||
@@ -0,0 +1,56 @@
|
||||
# LearnHarness 0.5.2
|
||||
|
||||
## English
|
||||
|
||||
This release adds preferred speech-recognition languages and fixes the model
|
||||
settings layout.
|
||||
|
||||
- **Settings → General → Preferred languages** offers **English** and **Русский**
|
||||
checkboxes below the label and explanation. Select both with **Multilingual**
|
||||
to guide recognition toward Russian and English in the same recording.
|
||||
Preferred languages are hints, so other supported languages may still be
|
||||
recognized. Leaving both unchecked keeps automatic detection.
|
||||
- Preferences are saved independently of interface language and STT mode.
|
||||
They remain saved when a single-language mode is selected, but are sent only
|
||||
with Multilingual. The controls lock while saving or recording, and a failed
|
||||
save preserves the previous choice and shows a localized error.
|
||||
- Each recording keeps its language and preferred-language hints through any
|
||||
reconnects. Later preference changes apply to the next recording.
|
||||
- **Settings → Agent** now gives model-tier descriptions a flexible text column.
|
||||
Model, reasoning, and Fast controls stay together and align across tiers.
|
||||
Narrow panels place the complete control group below the description,
|
||||
aligned left. Long model names keep their existing ellipsis, and background-use
|
||||
permission buttons occupy a separate line.
|
||||
|
||||
Packages: **Linux x64** AppImage and Debian, and a **Windows x64** installer.
|
||||
Both platforms use this fork's public Gitea releases for automatic updates.
|
||||
Windows packaging remains unsigned, matching previous releases.
|
||||
|
||||
## Русский
|
||||
|
||||
В этом выпуске добавлен выбор предпочитаемых языков распознавания речи
|
||||
и исправлено расположение элементов в настройках моделей.
|
||||
|
||||
- В разделе **Настройки → Общие → Предпочитаемые языки** появились флажки
|
||||
**English** и **Русский**, расположенные под заголовком и пояснением.
|
||||
Выберите оба в режиме **Несколько языков**, чтобы отдавать приоритет русскому
|
||||
и английскому в одной записи. Это подсказки для распознавания: другие
|
||||
поддерживаемые языки тоже могут быть распознаны. Если оба флажка сняты,
|
||||
используется автоматическое определение языка.
|
||||
- Предпочтения сохраняются независимо от языка интерфейса и режима распознавания.
|
||||
При выборе одного языка они сохраняются, но отправляются только в режиме
|
||||
«Несколько языков». Флажки заблокированы во время сохранения и записи.
|
||||
Если сохранить настройку не удалось, прежний выбор сохраняется, а приложение
|
||||
показывает ошибку на выбранном языке.
|
||||
- Язык и подсказки фиксируются на время записи, включая повторные подключения.
|
||||
Последующие изменения применяются к следующей записи.
|
||||
- В разделе **Настройки → Агент** описания уровней моделей занимают гибкую
|
||||
текстовую колонку. Выбор модели, уровня рассуждения и быстрый режим остаются
|
||||
вместе и выровнены между уровнями. В узких панелях вся группа располагается
|
||||
под описанием с выравниванием по левому краю. Длинные названия моделей
|
||||
по-прежнему сокращаются многоточием, а кнопки разрешения фоновой работы
|
||||
размещаются на отдельной строке.
|
||||
|
||||
Пакеты: AppImage и Debian для **Linux x64**, установщик для **Windows x64**.
|
||||
Обе версии используют публичные выпуски этого форка в Gitea для автоматических
|
||||
обновлений. Установщик Windows не подписан, как и в предыдущих выпусках.
|
||||
@@ -0,0 +1,63 @@
|
||||
# LearnHarness 0.6.0
|
||||
|
||||
## English
|
||||
|
||||
This release adds OpenRouter speech recognition and a dedicated speech-recognition
|
||||
settings page, with cleaner controls in English and Russian.
|
||||
|
||||
- **OpenRouter STT:** choose **MAI-Transcribe-2 · OpenRouter** and add your
|
||||
OpenRouter API key under **Settings → Providers**. The app records locally,
|
||||
sends the whole recording after **Stop**, and shows **Transcribing…** until
|
||||
the complete transcript is ready. Audio is not split into speech segments.
|
||||
Cancelling aborts the request; submitting an answer or message waits for
|
||||
transcription. The permanent API key stays on the server and audio is not
|
||||
saved to disk. Recordings are limited to 25 MB, about 13 minutes.
|
||||
- **Settings → Speech recognition** now contains the model, recognition language,
|
||||
and preferred languages. The two model choices use the same format:
|
||||
**Nova-3 · Deepgram** and **MAI-Transcribe-2 · OpenRouter**. General settings
|
||||
contains only interface language and automatic updates.
|
||||
- **Preferred languages** uses a styled checkbox dropdown aligned on the right.
|
||||
It stays open while selecting English and Russian, shows **No preference**
|
||||
when neither is selected, and no longer dims or flashes while saving.
|
||||
These hints remain available for Deepgram Multilingual. MAI uses automatic
|
||||
language detection for Multilingual; its API does not expose a RU + EN hint
|
||||
list. Saved hints are retained when switching models.
|
||||
- On Linux, clicking an available **Fast** button keeps the normal cursor while
|
||||
the setting is saved.
|
||||
|
||||
Existing settings remain compatible and direct Deepgram Nova-3 remains the
|
||||
default. Linux x64 packages include AppImage and Debian; Windows x64 has an
|
||||
installer. Both platforms use this fork's public Gitea releases for automatic
|
||||
updates. The Windows installer remains unsigned, as in previous releases.
|
||||
|
||||
## Русский
|
||||
|
||||
В этом выпуске добавлено распознавание речи через OpenRouter и отдельный раздел
|
||||
его настроек. Элементы управления обновлены на русском и английском языках.
|
||||
|
||||
- **Распознавание через OpenRouter:** выберите **MAI-Transcribe-2 · OpenRouter**
|
||||
и добавьте API-ключ OpenRouter в разделе **Настройки → Провайдеры**. Приложение
|
||||
записывает речь локально, отправляет всю запись после остановки и показывает
|
||||
**Распознавание…** до получения полного текста. Речь не разбивается на
|
||||
отдельные фрагменты. Отмена прерывает запрос, а отправка ответа или сообщения
|
||||
дожидается завершения распознавания. Постоянный API-ключ остаётся на сервере,
|
||||
аудио не сохраняется на диск. Максимальный размер записи — 25 МБ, примерно
|
||||
13 минут.
|
||||
- Новый раздел **Настройки → Распознавание речи** содержит выбор модели, языка
|
||||
распознавания и предпочитаемых языков. Названия моделей оформлены одинаково:
|
||||
**Nova-3 · Deepgram** и **MAI-Transcribe-2 · OpenRouter**. В разделе «Общие»
|
||||
остались только язык интерфейса и автоматические обновления.
|
||||
- **Предпочитаемые языки** выбираются в оформленном в стиле приложения
|
||||
выпадающем меню с флажками справа. Меню остаётся открытым при выборе русского
|
||||
и английского. Если оба флажка сняты, отображается **Без предпочтений**.
|
||||
Во время сохранения меню больше не тускнеет и не мигает. Подсказки работают
|
||||
в многоязычном режиме Deepgram. MAI определяет языки автоматически: его API
|
||||
не предоставляет списка подсказок RU + EN. Сохранённые предпочтения остаются
|
||||
при переключении моделей.
|
||||
- В Linux нажатие доступной кнопки **Быстро** больше не меняет курсор на время
|
||||
сохранения настройки.
|
||||
|
||||
Прежние настройки совместимы; по умолчанию по-прежнему используется Deepgram
|
||||
Nova-3 напрямую. Для Linux x64 доступны AppImage и Debian, для Windows x64 —
|
||||
установщик. Обе версии получают автоматические обновления из публичных выпусков
|
||||
этого форка в Gitea. Установщик Windows не подписан, как и в предыдущих выпусках.
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
# Speech recognition
|
||||
|
||||
Settings → Speech recognition contains the model, recognition language and preferred-language controls. The model picker offers two choices:
|
||||
|
||||
- **Nova-3 · Deepgram** (the default): streams microphone audio directly to Deepgram using a short-lived token. Add a Deepgram API key under Providers.
|
||||
- **MAI-Transcribe-2 · OpenRouter**: records locally in memory and sends the complete recording in one request after Stop. Add an OpenRouter API key under Providers; the existing key also works if OpenRouter is already configured for chat. The permanent key stays on the server.
|
||||
|
||||
Both insert the transcript after recording finishes. OpenRouter displays **Transcribing…** while waiting. Sending a chat message or answer during recording waits for transcription before continuing. Cancel or Escape discards dictation; cancelling during transcription aborts the request and ignores late results. Navigating away releases the microphone and cancels pending transcription.
|
||||
|
||||
The interface language, recognition language, and STT model are saved independently. Model and language changes apply to the next recording, and their controls lock during recording/transcription. Existing settings continue to use Deepgram until another model is selected.
|
||||
|
||||
English and Russian select a single recognition language. **Multilingual** uses automatic language detection in MAI, including mixed-language speech. Its documented API accepts only one forced language and has no preferred-language list; saved RU/EN hints remain available for Deepgram and are disabled when MAI is selected. They are not sent as keyword hints or an ignored prompt.
|
||||
|
||||
OpenRouter audio is a lossless WAV container around the same 16 kHz mono PCM capture used for Deepgram. Stop drains the microphone's final partial frame before sending it. There is no speech splitting, silence removal, automatic provider fallback, or automatic transcription retry. The inline audio limit is 25 MB (about 13 minutes at this format); exceeding it stops recording with an error. Provider processing can time out for long recordings even within the size limit. Audio is not saved to disk.
|
||||
|
||||
The renderer sends WAV bytes to `POST /api/stt/transcribe?model=microsoft/mai-transcribe-2&language=en|ru|multi`. The server validates the model, language, WAV format and body size, reads `OPENROUTER_API_KEY` through the normal key store, and posts base64 audio to OpenRouter's dedicated transcription endpoint. Provider diagnostics are replaced with localized app errors, without returning credentials or audio. Offline tests mock all provider calls.
|
||||
|
||||
References: [OpenRouter transcription API](https://openrouter.ai/docs/guides/overview/multimodal/stt), [MAI-Transcribe-2](https://openrouter.ai/microsoft/mai-transcribe-2), [Microsoft language and model options](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/mai-transcribe).
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "learnharness",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "learnharness",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "1.4.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "learnharness",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"author": {
|
||||
"name": "Amos Blomqvist",
|
||||
|
||||
@@ -27,6 +27,16 @@ class PcmCaptureProcessor extends AudioWorkletProcessor {
|
||||
super();
|
||||
this.frame = new Int16Array(2048);
|
||||
this.count = 0;
|
||||
// Whole-recording STT must include the final, partially filled frame.
|
||||
this.port.onmessage = (event) => {
|
||||
if (event.data?.type !== "flush") return;
|
||||
if (this.count > 0) {
|
||||
const tail = this.frame.slice(0, this.count);
|
||||
this.port.postMessage({ type: "audio", buffer: tail }, [tail.buffer]);
|
||||
this.count = 0;
|
||||
}
|
||||
this.port.postMessage({ type: "flushed" });
|
||||
};
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function verifyLocal(directory, version) {
|
||||
/** The downloaded bytes, not only API-reported sizes, must agree with the local verified package. */
|
||||
export async function verifyRemote(release, localInventory, headers = {}, request = fetch) {
|
||||
assert.equal(release.draft, true, "only a draft may be prepared or verified by this tool");
|
||||
assert.equal(release.prerelease, false, "0.4.0 is a stable release");
|
||||
assert.equal(release.prerelease, false, "release must be stable");
|
||||
assert.deepEqual(release.assets.map((asset) => asset.name).sort(), localInventory.map((asset) => asset.name).sort(), "remote draft has missing, duplicate, or unexpected assets");
|
||||
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), "lh-release-verify-"));
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,7 @@ export function chatGPTStreamError(message: string): string {
|
||||
if (/expired|invalid_grant|401|invalid_user/.test(message)) return "Your ChatGPT session expired. Reconnect in Providers. No API-key fallback was used.";
|
||||
if (/background|Background/.test(message)) return "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers to run this work. No API-key fallback was used.";
|
||||
if (/eligible|403/.test(message)) return "This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility.";
|
||||
if (/service[_ -]?tier|priority processing|fast mode/i.test(message)) return "Fast mode is unavailable for this ChatGPT model or account. Turn off Fast mode or choose another model.";
|
||||
if (/cancel|abort|closed|disconnect/i.test(message)) return "ChatGPT request cancelled or connection closed. No API-key fallback was used.";
|
||||
if (/catalog|model/.test(message)) return "The selected model is unavailable for this ChatGPT account. Refresh Providers and explicitly choose an available model. No API-key fallback was used.";
|
||||
if (/incomplete|terminal|interrupted|stop reason/.test(message)) return "ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used.";
|
||||
@@ -19,9 +20,10 @@ export function chatGPTStreamError(message: string): string {
|
||||
}
|
||||
|
||||
/** Restrict the body at the final payload seam, even if caller samplingParams add fields. */
|
||||
export function chatGPTPayload(raw: unknown, selectedModelId: string): Record<string, unknown> {
|
||||
export function chatGPTPayload(raw: unknown, selectedModelId: string, fastMode = false): Record<string, unknown> {
|
||||
const payload = raw as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = { model: selectedModelId, store: false, stream: true };
|
||||
if (fastMode) out.service_tier = "priority";
|
||||
for (const key of ["instructions", "reasoning", "include", "text", "tool_choice", "parallel_tool_calls", "prompt_cache_key"]) {
|
||||
if (payload[key] !== undefined) out[key] = payload[key];
|
||||
}
|
||||
@@ -44,7 +46,7 @@ export function chatGPTHistory(context: TranscriptContext, logicalModel: Model<A
|
||||
function empty(model: Model<Api>): AssistantMessage {
|
||||
return { role: "assistant", api: model.api, provider: "chatgpt", model: model.id, content: [], stopReason: "error", timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
|
||||
}
|
||||
export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDispatchLifecycle): StreamFunction<Api, SimpleStreamOptions> {
|
||||
export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDispatchLifecycle, fastMode = false): StreamFunction<Api, SimpleStreamOptions> {
|
||||
return (logicalModel, context, options) => {
|
||||
const output = new AssistantMessageEventStream();
|
||||
const purpose = background();
|
||||
@@ -83,14 +85,18 @@ export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDisp
|
||||
},
|
||||
onPayload: async (raw, model) => {
|
||||
const replacement = await options?.onPayload?.(raw, model);
|
||||
const payload = chatGPTPayload(replacement === undefined ? raw : replacement, logicalModel.id);
|
||||
const payload = chatGPTPayload(replacement === undefined ? raw : replacement, logicalModel.id, fastMode);
|
||||
check();
|
||||
return payload;
|
||||
},
|
||||
onProviderStreamEvent: async (raw, model) => {
|
||||
const event = raw as { type?: string; code?: string; error?: { code?: string }; response?: { error?: { code?: string }; status?: string } };
|
||||
const event = raw as { type?: string; code?: string; param?: string; error?: { code?: string; param?: string }; response?: { error?: { code?: string; param?: string }; status?: string } };
|
||||
if (event.type === "response.completed" && event.response?.status === "completed" && !event.response?.error) completed = true;
|
||||
if (["response.incomplete", "response.failed", "error"].includes(event.type ?? "") || event.response?.error) failure = event.code || event.error?.code || event.response?.error?.code || "incomplete terminal response";
|
||||
if (["response.incomplete", "response.failed", "error"].includes(event.type ?? "") || event.response?.error) {
|
||||
const code = event.code || event.error?.code || event.response?.error?.code || "incomplete terminal response";
|
||||
failure = [event.param, event.error?.param, event.response?.error?.param].includes("service_tier")
|
||||
? `${code} service_tier` : code;
|
||||
}
|
||||
await options?.onProviderStreamEvent?.(raw, model);
|
||||
},
|
||||
});
|
||||
|
||||
+2
-1
@@ -80,6 +80,7 @@ export interface ResolvedModelSpec {
|
||||
provider: string;
|
||||
id: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
fastMode?: boolean;
|
||||
}
|
||||
|
||||
/** The first entry of the baked default order the model supports (owner,
|
||||
@@ -119,7 +120,7 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (
|
||||
// the pin honest even when config or settings drift off the map.
|
||||
streamFn: (requestModel, context, options) => {
|
||||
const purpose = typeof background === "function" ? background : () => background;
|
||||
const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models);
|
||||
const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle, spec.fastMode === true) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models);
|
||||
return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level });
|
||||
},
|
||||
};
|
||||
|
||||
+9
-1
@@ -2,9 +2,11 @@
|
||||
import en from "./locales/en.json";
|
||||
import ru from "./locales/ru.json";
|
||||
export type Language = "en" | "ru";
|
||||
export type SttLanguage = Language | "multi";
|
||||
export type TranslationParams = Record<string, string | number | null | undefined>;
|
||||
export type Translator = (message: string | null | undefined, params?: TranslationParams) => string;
|
||||
export function isLanguage(value: unknown): value is Language { return value === "en" || value === "ru"; }
|
||||
export function isSttLanguage(value: unknown): value is SttLanguage { return isLanguage(value) || value === "multi"; }
|
||||
|
||||
/** The preferred system language wins; unsupported preferences resolve to English. */
|
||||
export function systemLanguage(languages: string | readonly string[] | null | undefined): Language {
|
||||
@@ -20,6 +22,9 @@ export function systemLanguage(languages: string | readonly string[] | null | un
|
||||
export function resolveLanguage(preference: unknown, system?: string | readonly string[] | null): Language {
|
||||
return isLanguage(preference) ? preference : systemLanguage(system);
|
||||
}
|
||||
export function resolveSttLanguage(preference: unknown, system?: string | readonly string[] | null): SttLanguage {
|
||||
return isSttLanguage(preference) ? preference : systemLanguage(system);
|
||||
}
|
||||
export function localeTag(language: Language): string { return language === "ru" ? "ru-RU" : "en-US"; }
|
||||
|
||||
type Entry = string | Partial<Record<Intl.LDMLPluralRule, string>>;
|
||||
@@ -58,8 +63,11 @@ export function learningLanguageInstruction(language: Language): string {
|
||||
: "LANGUAGE: Use English for all learner-facing content, including teaching, questions and choices, plans, titles, practice, research summaries, knowledge-graph propositions, and text in generated diagrams, figures and demos. Preserve code, notation, identifiers, proper names and quoted source text. Follow an explicit learner request for a different language or a language-learning exercise when necessary.";
|
||||
}
|
||||
/** Streaming configuration: the language is pinned once at recording start, including reconnects. */
|
||||
export function dictationUrl(language: Language): string {
|
||||
export function dictationUrl(language: SttLanguage, preferredLanguages: readonly Language[] = []): string {
|
||||
const params = new URLSearchParams({ model: "nova-3", language, encoding: "linear16", sample_rate: "16000", channels: "1", smart_format: "true", punctuate: "true", endpointing: "300" });
|
||||
if (language === "multi") {
|
||||
for (const preferred of new Set(preferredLanguages)) if (isLanguage(preferred)) params.append("language_hint", preferred);
|
||||
}
|
||||
return `wss://api.deepgram.com/v1/listen?${params}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -81,3 +81,4 @@ export * from "./coverage.ts";
|
||||
export * from "./session-markdown.ts";
|
||||
|
||||
export * from "./i18n.ts";
|
||||
export * from "./stt.ts";
|
||||
|
||||
@@ -249,6 +249,11 @@
|
||||
"Speech recognition language": "Speech recognition language",
|
||||
"Auto": "Auto",
|
||||
"System default": "System default",
|
||||
"Multilingual": "Multilingual",
|
||||
"Multi": "Multi",
|
||||
"Preferred languages": "Preferred languages",
|
||||
"Could not save preferred languages": "Could not save preferred languages",
|
||||
"sttPreferredLanguages must be an array of \"en\" and \"ru\"": "sttPreferredLanguages must be an array of \"en\" and \"ru\"",
|
||||
"Global": "Global",
|
||||
"Chat": "Chat",
|
||||
"Graph view": "Graph view",
|
||||
@@ -723,6 +728,7 @@
|
||||
"thinkingLevel must be a string": "thinkingLevel must be a string",
|
||||
"autoUpdate must be a boolean": "autoUpdate must be a boolean",
|
||||
"{v0} must be \"en\" or \"ru\"": "{v0} must be \"en\" or \"ru\"",
|
||||
"sttLanguage must be \"en\", \"ru\", or \"multi\"": "sttLanguage must be \"en\", \"ru\", or \"multi\"",
|
||||
"{v0} must be an object": "{v0} must be an object",
|
||||
"{v0}.model must be {provider, id}": "{v0}.model must be {provider, id}",
|
||||
"{v0}.thinkingLevel must be a string": "{v0}.thinkingLevel must be a string",
|
||||
@@ -835,5 +841,30 @@
|
||||
"search engine is set to \"{v0}\" but its API key is missing — set the {v1} key": "search engine is set to \"{v0}\" but its API key is missing — set the {v1} key",
|
||||
"search engine: {v0} (chosen)": "search engine: {v0} (chosen)",
|
||||
"search engine: {v0} (auto)": "search engine: {v0} (auto)",
|
||||
"update": "update"
|
||||
"update": "update",
|
||||
"Fast": "Fast",
|
||||
"{v0} fast mode": "{v0} fast mode",
|
||||
"Fast mode uses more ChatGPT allowance.": "Fast mode uses more ChatGPT allowance.",
|
||||
"Fast mode is available only for ChatGPT models.": "Fast mode is available only for ChatGPT models.",
|
||||
"Could not save model settings": "Could not save model settings",
|
||||
"Fast mode is unavailable for this ChatGPT model or account. Turn off Fast mode or choose another model.": "Fast mode is unavailable for this ChatGPT model or account. Turn off Fast mode or choose another model.",
|
||||
"Speech recognition model": "Speech recognition model",
|
||||
"Unknown speech recognition model": "Unknown speech recognition model",
|
||||
"Invalid speech recognition language": "Invalid speech recognition language",
|
||||
"Invalid dictation audio": "Invalid dictation audio",
|
||||
"Recording exceeds OpenRouter's 25 MB audio limit": "Recording exceeds OpenRouter's 25 MB audio limit",
|
||||
"Could not read dictation audio": "Could not read dictation audio",
|
||||
"Transcription cancelled": "Transcription cancelled",
|
||||
"Dictation needs an OpenRouter key": "Dictation needs an OpenRouter key",
|
||||
"OpenRouter rejected the API key": "OpenRouter rejected the API key",
|
||||
"OpenRouter credits are insufficient": "OpenRouter credits are insufficient",
|
||||
"OpenRouter rate limit reached; try again later": "OpenRouter rate limit reached; try again later",
|
||||
"OpenRouter transcription failed ({v0})": "OpenRouter transcription failed ({v0})",
|
||||
"OpenRouter returned an invalid transcript": "OpenRouter returned an invalid transcript",
|
||||
"OpenRouter transcription timed out": "OpenRouter transcription timed out",
|
||||
"OpenRouter transcription is unavailable": "OpenRouter transcription is unavailable",
|
||||
"Could not finish microphone capture": "Could not finish microphone capture",
|
||||
"Transcribing…": "Transcribing…",
|
||||
"Speech recognition": "Speech recognition",
|
||||
"No preference": "No preference"
|
||||
}
|
||||
|
||||
@@ -249,6 +249,11 @@
|
||||
"Speech recognition language": "Язык распознавания речи",
|
||||
"Auto": "Авто",
|
||||
"System default": "Язык системы",
|
||||
"Multilingual": "Несколько языков",
|
||||
"Multi": "Мульти",
|
||||
"Preferred languages": "Предпочитаемые языки",
|
||||
"Could not save preferred languages": "Не удалось сохранить предпочитаемые языки",
|
||||
"sttPreferredLanguages must be an array of \"en\" and \"ru\"": "Предпочитаемые языки должны быть списком кодов «en» и «ru»",
|
||||
"Global": "Общие",
|
||||
"Chat": "Занятие",
|
||||
"Graph view": "Граф",
|
||||
@@ -754,6 +759,7 @@
|
||||
"thinkingLevel must be a string": "Уровень рассуждения должен быть строкой",
|
||||
"autoUpdate must be a boolean": "Параметр автообновления должен быть логическим значением",
|
||||
"{v0} must be \"en\" or \"ru\"": "Параметр {v0} должен иметь значение «en» или «ru»",
|
||||
"sttLanguage must be \"en\", \"ru\", or \"multi\"": "Параметр sttLanguage должен иметь значение «en», «ru» или «multi»",
|
||||
"{v0} must be an object": "Параметр {v0} должен быть объектом",
|
||||
"{v0}.model must be {provider, id}": "Поле {v0}.model должно содержать provider и id",
|
||||
"{v0}.thinkingLevel must be a string": "Поле {v0}.thinkingLevel должно быть строкой",
|
||||
@@ -871,5 +877,30 @@
|
||||
"search engine is set to \"{v0}\" but its API key is missing — set the {v1} key": "Выбрана поисковая система «{v0}», но её API-ключ отсутствует — добавьте ключ {v1}",
|
||||
"search engine: {v0} (chosen)": "Поисковая система: {v0} (выбрана вручную)",
|
||||
"search engine: {v0} (auto)": "Поисковая система: {v0} (автоматически)",
|
||||
"update": "обновление"
|
||||
"update": "обновление",
|
||||
"Fast": "Быстро",
|
||||
"{v0} fast mode": "Быстрый режим: {v0}",
|
||||
"Fast mode uses more ChatGPT allowance.": "Быстрый режим быстрее расходует лимит ChatGPT.",
|
||||
"Fast mode is available only for ChatGPT models.": "Быстрый режим доступен только для моделей ChatGPT.",
|
||||
"Could not save model settings": "Не удалось сохранить настройки моделей",
|
||||
"Fast mode is unavailable for this ChatGPT model or account. Turn off Fast mode or choose another model.": "Быстрый режим недоступен для этой модели или учётной записи ChatGPT. Отключите быстрый режим или выберите другую модель.",
|
||||
"Speech recognition model": "Модель распознавания речи",
|
||||
"Unknown speech recognition model": "Неизвестная модель распознавания речи",
|
||||
"Invalid speech recognition language": "Недопустимый язык распознавания речи",
|
||||
"Invalid dictation audio": "Некорректная аудиозапись",
|
||||
"Recording exceeds OpenRouter's 25 MB audio limit": "Запись превышает ограничение OpenRouter в 25 МБ",
|
||||
"Could not read dictation audio": "Не удалось прочитать аудиозапись",
|
||||
"Transcription cancelled": "Распознавание отменено",
|
||||
"Dictation needs an OpenRouter key": "Для диктовки нужен ключ OpenRouter",
|
||||
"OpenRouter rejected the API key": "OpenRouter отклонил API-ключ",
|
||||
"OpenRouter credits are insufficient": "Недостаточно средств на балансе OpenRouter",
|
||||
"OpenRouter rate limit reached; try again later": "Превышен лимит запросов OpenRouter. Попробуйте позже",
|
||||
"OpenRouter transcription failed ({v0})": "Ошибка распознавания OpenRouter ({v0})",
|
||||
"OpenRouter returned an invalid transcript": "OpenRouter вернул некорректный результат распознавания",
|
||||
"OpenRouter transcription timed out": "Время ожидания распознавания OpenRouter истекло",
|
||||
"OpenRouter transcription is unavailable": "Распознавание через OpenRouter недоступно",
|
||||
"Could not finish microphone capture": "Не удалось завершить запись с микрофона",
|
||||
"Transcribing…": "Распознавание…",
|
||||
"Speech recognition": "Распознавание речи",
|
||||
"No preference": "Без предпочтений"
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface EffectiveSlot {
|
||||
provider: string;
|
||||
id: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
fastMode?: boolean;
|
||||
}
|
||||
|
||||
/** A fully-resolved model pin for one role — same shape/level semantics
|
||||
@@ -61,6 +62,7 @@ export interface EffectiveModelSpec {
|
||||
provider: string;
|
||||
id: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
fastMode?: boolean;
|
||||
}
|
||||
|
||||
/** The concrete appearance view — same shape as the factory's. */
|
||||
@@ -110,6 +112,8 @@ export function resolveConversationSpec(
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
thinkingLevel: chat?.thinkingLevel ?? strong?.thinkingLevel,
|
||||
// Speed is a live tier preference, including for existing conversations.
|
||||
...(model.provider === "chatgpt" && strong?.provider === "chatgpt" && strong.fastMode === true ? { fastMode: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,7 +135,9 @@ export function resolveRoleSpec(role: ModelRole, record: UserSettings | null): E
|
||||
* to ride without the factory floor). */
|
||||
export function resolveSlot(slot: ModelSlot | undefined): EffectiveSlot | null {
|
||||
if (!slot?.model) return null;
|
||||
return { provider: slot.model.provider, id: slot.model.id, thinkingLevel: slot.thinkingLevel };
|
||||
return { provider: slot.model.provider, id: slot.model.id, thinkingLevel: slot.thinkingLevel,
|
||||
...(slot.model.provider === "chatgpt" && slot.fastMode === true ? { fastMode: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve one role's spec — the BYOK chain (owner 2026-09-22): role
|
||||
@@ -153,6 +159,7 @@ export function resolveModelSpec(
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
thinkingLevel: override?.thinkingLevel ?? slot?.thinkingLevel,
|
||||
...(model.provider === "chatgpt" && slot?.model?.provider === "chatgpt" && slot.fastMode === true ? { fastMode: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,6 +184,8 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
|
||||
autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate,
|
||||
...(record?.general?.language ? { language: record.general.language } : {}),
|
||||
...(record?.general?.sttLanguage ? { sttLanguage: record.general.sttLanguage } : {}),
|
||||
...(record?.general?.sttModel ? { sttModel: record.general.sttModel } : {}),
|
||||
...(record?.general?.sttPreferredLanguages ? { sttPreferredLanguages: [...record.general.sttPreferredLanguages] } : {}),
|
||||
},
|
||||
agent: {
|
||||
models,
|
||||
|
||||
+22
-7
@@ -32,6 +32,8 @@ import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts";
|
||||
import type { ModelRole } from "../../config.ts";
|
||||
import type { ChatSettings } from "./contracts/chat.ts";
|
||||
import type { SearchEngineId } from "./search.ts";
|
||||
import { isLanguage, isSttLanguage, type Language, type SttLanguage } from "./i18n.ts";
|
||||
import { isSttModel, type SttModel } from "./stt.ts";
|
||||
|
||||
/** The schema version this code understands. Bump ONLY for breaking shape
|
||||
* changes (a field renamed/meaning changed); pure additions never bump. */
|
||||
@@ -193,6 +195,8 @@ export interface ModelSlot {
|
||||
model?: { provider: string; id: string };
|
||||
/** The thinking budget swap. */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
/** Opt-in ChatGPT priority processing for this tier. Absent = off. */
|
||||
fastMode?: boolean;
|
||||
}
|
||||
|
||||
/** The `models` settings section (owner decision 2026-09-20: a dedicated
|
||||
@@ -234,9 +238,13 @@ export interface GeneralSettings {
|
||||
/** Auto-update the app (default ON). Mechanism ships in a later wave. */
|
||||
autoUpdate?: boolean;
|
||||
/** Absent = browser/system language (English for unsupported languages). */
|
||||
language?: "en" | "ru";
|
||||
/** Independent dictation language; absent = browser/system language. */
|
||||
sttLanguage?: "en" | "ru";
|
||||
language?: Language;
|
||||
/** Independent dictation language; multi = multilingual, absent = browser/system language. */
|
||||
sttLanguage?: SttLanguage;
|
||||
/** Absent preserves direct Deepgram Nova-3 streaming. */
|
||||
sttModel?: SttModel;
|
||||
/** Optional recognition hints for multilingual dictation; absent/empty = automatic detection. */
|
||||
sttPreferredLanguages?: Language[];
|
||||
}
|
||||
|
||||
/** Agent settings — everything about the non-conversation machinery. */
|
||||
@@ -445,9 +453,10 @@ function sanitizeGeneral(raw: Record<string, unknown>): GeneralSettings {
|
||||
const out: GeneralSettings = {};
|
||||
const autoUpdate = boolOrDrop(raw.autoUpdate);
|
||||
if (autoUpdate !== undefined) out.autoUpdate = autoUpdate;
|
||||
for (const key of ["language", "sttLanguage"] as const) {
|
||||
if (raw[key] === "en" || raw[key] === "ru") out[key] = raw[key];
|
||||
}
|
||||
if (isLanguage(raw.language)) out.language = raw.language;
|
||||
if (isSttLanguage(raw.sttLanguage)) out.sttLanguage = raw.sttLanguage;
|
||||
if (isSttModel(raw.sttModel)) out.sttModel = raw.sttModel;
|
||||
if (Array.isArray(raw.sttPreferredLanguages)) out.sttPreferredLanguages = [...new Set(raw.sttPreferredLanguages.filter(isLanguage))];
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -490,7 +499,13 @@ function sanitizeModels(raw: Record<string, unknown>): ModelsSettings {
|
||||
const out: ModelsSettings = {};
|
||||
for (const slot of MODEL_SLOTS) {
|
||||
const cleaned = sanitizeRoleOverride(raw[slot]);
|
||||
if (cleaned) out[slot] = cleaned;
|
||||
if (cleaned) {
|
||||
const value = raw[slot] as Record<string, unknown>;
|
||||
out[slot] = {
|
||||
...cleaned,
|
||||
...(cleaned.model?.provider === "chatgpt" && typeof value.fastMode === "boolean" ? { fastMode: value.fastMode } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Dictation model choices are independent of the conversational model tiers. */
|
||||
export const STT_MODELS = [
|
||||
{ id: "deepgram/nova-3", name: "Nova-3 · Deepgram" },
|
||||
{ id: "microsoft/mai-transcribe-2", name: "MAI-Transcribe-2 · OpenRouter" },
|
||||
] as const;
|
||||
export type SttModel = (typeof STT_MODELS)[number]["id"];
|
||||
export const DEFAULT_STT_MODEL: SttModel = "deepgram/nova-3";
|
||||
export const OPENROUTER_STT_MODEL: SttModel = "microsoft/mai-transcribe-2";
|
||||
export function isSttModel(value: unknown): value is SttModel {
|
||||
return STT_MODELS.some((model) => model.id === value);
|
||||
}
|
||||
|
||||
/** OpenRouter's inline audio limit. WAV uses the existing 16 kHz mono PCM capture. */
|
||||
export const STT_MAX_AUDIO_BYTES = 25 * 1024 * 1024;
|
||||
export const STT_SAMPLE_RATE = 16000;
|
||||
export const WAV_HEADER_BYTES = 44;
|
||||
|
||||
/** Wrap captured PCM in a WAV container without resampling or encoding loss. */
|
||||
export function dictationWav(frames: readonly Uint8Array[]): Uint8Array<ArrayBuffer> {
|
||||
const size = frames.reduce((total, frame) => total + frame.byteLength, 0);
|
||||
if (size % 2 !== 0) throw new Error("Invalid dictation audio");
|
||||
if (size + WAV_HEADER_BYTES > STT_MAX_AUDIO_BYTES) throw new Error("Recording exceeds OpenRouter's 25 MB audio limit");
|
||||
const wav = new Uint8Array(WAV_HEADER_BYTES + size);
|
||||
const view = new DataView(wav.buffer);
|
||||
const text = (offset: number, value: string) => {
|
||||
for (let i = 0; i < value.length; i++) view.setUint8(offset + i, value.charCodeAt(i));
|
||||
};
|
||||
text(0, "RIFF"); view.setUint32(4, 36 + size, true); text(8, "WAVE");
|
||||
text(12, "fmt "); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true); view.setUint32(24, STT_SAMPLE_RATE, true);
|
||||
view.setUint32(28, STT_SAMPLE_RATE * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true);
|
||||
text(36, "data"); view.setUint32(40, size, true);
|
||||
let offset = WAV_HEADER_BYTES;
|
||||
for (const frame of frames) { wav.set(frame, offset); offset += frame.byteLength; }
|
||||
return wav;
|
||||
}
|
||||
@@ -46,3 +46,4 @@ export * from "./model-catalog.ts";
|
||||
export { sessionCost } from "./pricing.ts";
|
||||
|
||||
export { applicationLanguage, chatLanguage } from "./language.ts";
|
||||
export * from "./stt.ts";
|
||||
|
||||
@@ -300,7 +300,7 @@ export function clampModelsLevels(settings: ModelsSettings): ModelsSettings {
|
||||
out[name] =
|
||||
slot.thinkingLevel !== undefined
|
||||
? {
|
||||
model: slot.model,
|
||||
...slot,
|
||||
thinkingLevel: clampLevelFor(slot.model.provider, slot.model.id, slot.thinkingLevel),
|
||||
}
|
||||
: slot;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { isHexColor, type ChatSettings } from "../core";
|
||||
import { isHexColor, isLanguage, isSttLanguage, isSttModel, type ChatSettings } from "../core";
|
||||
import type {
|
||||
AgentSettings,
|
||||
AppearanceSettings,
|
||||
@@ -75,7 +75,7 @@ function asRecord(body: unknown): Record<string, unknown> | null {
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** general: { autoUpdate?: boolean } */
|
||||
/** Independent interface/STT language choices and automatic updates. */
|
||||
export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<GeneralSettings> {
|
||||
const raw = asRecord(body);
|
||||
if (!raw) return { ok: false, error: "Invalid JSON body" };
|
||||
@@ -86,11 +86,21 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<Gene
|
||||
}
|
||||
settings.autoUpdate = raw.autoUpdate;
|
||||
}
|
||||
for (const key of ["language", "sttLanguage"] as const) {
|
||||
if (raw[key] !== undefined) {
|
||||
if (raw[key] !== "en" && raw[key] !== "ru") return { ok: false, error: `${key} must be "en" or "ru"` };
|
||||
settings[key] = raw[key];
|
||||
}
|
||||
if (raw.language !== undefined) {
|
||||
if (!isLanguage(raw.language)) return { ok: false, error: 'language must be "en" or "ru"' };
|
||||
settings.language = raw.language;
|
||||
}
|
||||
if (raw.sttLanguage !== undefined) {
|
||||
if (!isSttLanguage(raw.sttLanguage)) return { ok: false, error: 'sttLanguage must be "en", "ru", or "multi"' };
|
||||
settings.sttLanguage = raw.sttLanguage;
|
||||
}
|
||||
if (raw.sttPreferredLanguages !== undefined) {
|
||||
if (!Array.isArray(raw.sttPreferredLanguages) || !raw.sttPreferredLanguages.every(isLanguage)) return { ok: false, error: 'sttPreferredLanguages must be an array of "en" and "ru"' };
|
||||
settings.sttPreferredLanguages = [...new Set(raw.sttPreferredLanguages)];
|
||||
}
|
||||
if (raw.sttModel !== undefined) {
|
||||
if (!isSttModel(raw.sttModel)) return { ok: false, error: "Unknown speech recognition model" };
|
||||
settings.sttModel = raw.sttModel;
|
||||
}
|
||||
return { ok: true, settings };
|
||||
}
|
||||
@@ -348,7 +358,17 @@ export function parseModelsSettingsBody(body: unknown): SettingsBodyResult<Model
|
||||
if (!parsed.override.model) {
|
||||
return { ok: false, error: `slot "${key}" must set a model (thinkingLevel alone cannot run — absent slot = unset)` };
|
||||
}
|
||||
settings[key as (typeof MODEL_SLOTS)[number]] = parsed.override;
|
||||
const value = asRecord(raw[key])!;
|
||||
if (value.fastMode !== undefined && typeof value.fastMode !== "boolean") {
|
||||
return { ok: false, error: `${key}.fastMode must be a boolean` };
|
||||
}
|
||||
if (value.fastMode === true && parsed.override.model.provider !== "chatgpt") {
|
||||
return { ok: false, error: "Fast mode is available only for ChatGPT models." };
|
||||
}
|
||||
settings[key as (typeof MODEL_SLOTS)[number]] = {
|
||||
...parsed.override,
|
||||
...(typeof value.fastMode === "boolean" ? { fastMode: value.fastMode } : {}),
|
||||
};
|
||||
}
|
||||
return { ok: true, settings };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { OPENROUTER_STT_MODEL, STT_MAX_AUDIO_BYTES, STT_SAMPLE_RATE, WAV_HEADER_BYTES } from "../core/stt.ts";
|
||||
import { isSttLanguage } from "../core/i18n.ts";
|
||||
import { keyValue } from "./keys.ts";
|
||||
|
||||
const fail = (status: number, message: string) => Response.json({ message }, { status });
|
||||
|
||||
/** One complete recording per request. Credentials and audio never go to disk. */
|
||||
export async function openRouterTranscription(request: Request, key = keyValue("OPENROUTER_API_KEY")): Promise<Response> {
|
||||
if (!key) return fail(503, "Dictation needs an OpenRouter key");
|
||||
const params = new URL(request.url).searchParams;
|
||||
if (params.get("model") !== OPENROUTER_STT_MODEL) return fail(400, "Unknown speech recognition model");
|
||||
const language = params.get("language");
|
||||
if (!isSttLanguage(language)) return fail(400, "Invalid speech recognition language");
|
||||
if (request.headers.get("content-type") !== "audio/wav") return fail(415, "Invalid dictation audio");
|
||||
if (Number(request.headers.get("content-length")) > STT_MAX_AUDIO_BYTES) return fail(413, "Recording exceeds OpenRouter's 25 MB audio limit");
|
||||
if (!request.body) return fail(400, "Invalid dictation audio");
|
||||
const reader = request.body.getReader();
|
||||
const frames: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > STT_MAX_AUDIO_BYTES) {
|
||||
await reader.cancel();
|
||||
return fail(413, "Recording exceeds OpenRouter's 25 MB audio limit");
|
||||
}
|
||||
frames.push(value);
|
||||
}
|
||||
} catch {
|
||||
return fail(request.signal.aborted ? 499 : 400, "Could not read dictation audio");
|
||||
} finally { reader.releaseLock(); }
|
||||
if (request.signal.aborted) return fail(499, "Transcription cancelled");
|
||||
const audio = Buffer.concat(frames, size);
|
||||
// Accept exactly the lossless PCM container produced by our capture graph.
|
||||
if (size <= WAV_HEADER_BYTES || size % 2 !== 0 || audio.toString("ascii", 0, 4) !== "RIFF"
|
||||
|| audio.toString("ascii", 8, 12) !== "WAVE" || audio.toString("ascii", 12, 16) !== "fmt "
|
||||
|| audio.readUInt32LE(4) !== size - 8 || audio.readUInt32LE(16) !== 16 || audio.readUInt16LE(20) !== 1
|
||||
|| audio.readUInt16LE(22) !== 1 || audio.readUInt32LE(24) !== STT_SAMPLE_RATE
|
||||
|| audio.readUInt32LE(28) !== STT_SAMPLE_RATE * 2 || audio.readUInt16LE(32) !== 2 || audio.readUInt16LE(34) !== 16
|
||||
|| audio.toString("ascii", 36, 40) !== "data" || audio.readUInt32LE(40) !== size - WAV_HEADER_BYTES) {
|
||||
return fail(400, "Invalid dictation audio");
|
||||
}
|
||||
const signal = AbortSignal.any([request.signal, AbortSignal.timeout(65_000)]);
|
||||
try {
|
||||
const response = await fetch("https://openrouter.ai/api/v1/audio/transcriptions", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: OPENROUTER_STT_MODEL,
|
||||
input_audio: { data: audio.toString("base64"), format: "wav" },
|
||||
// MAI allows one forced language; automatic detection supports mixed speech.
|
||||
...(language === "multi" ? {} : { language }),
|
||||
response_format: "json",
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Provider diagnostics can contain credentials or echoed audio. Return only app-authored errors.
|
||||
await response.body?.cancel();
|
||||
if (response.status === 401 || response.status === 403) return fail(502, "OpenRouter rejected the API key");
|
||||
if (response.status === 402) return fail(502, "OpenRouter credits are insufficient");
|
||||
if (response.status === 429) return fail(429, "OpenRouter rate limit reached; try again later");
|
||||
return fail(502, `OpenRouter transcription failed (${response.status})`);
|
||||
}
|
||||
const result = await response.json() as { text?: unknown };
|
||||
if (typeof result.text !== "string") return fail(502, "OpenRouter returned an invalid transcript");
|
||||
return Response.json({ text: result.text });
|
||||
} catch (error) {
|
||||
if (request.signal.aborted) return fail(499, "Transcription cancelled");
|
||||
if (signal.aborted || (error instanceof Error && error.name === "TimeoutError")) return fail(504, "OpenRouter transcription timed out");
|
||||
return fail(502, "OpenRouter transcription is unavailable");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createChatGPT, fileStore, allowedAuthEndpoint, SCOPES } = require("../electron/chatgpt.cjs");
|
||||
@@ -55,6 +56,14 @@ function fixture(options: { state?: any; expires?: number; timeoutMs?: number; t
|
||||
return fetch(callback);
|
||||
} };
|
||||
}
|
||||
// Callback HTTP success precedes asynchronous token verification and persistence.
|
||||
async function waitForSignIn(service: { status(): Promise<{ pending: boolean }> }) {
|
||||
const deadline = Date.now() + 5000;
|
||||
while ((await service.status()).pending) {
|
||||
assert(Date.now() < deadline, "sign-in did not settle within the fixture timeout");
|
||||
await delay(5);
|
||||
}
|
||||
}
|
||||
async function loggedIn(options: Parameters<typeof fixture>[0] = {}) {
|
||||
const f = fixture(options);
|
||||
await f.service.login();
|
||||
@@ -450,7 +459,7 @@ test("repeated real verified sign-in creates one visible account while preservin
|
||||
try {
|
||||
const original = f.saved.registrations[0];
|
||||
await f.service.login(); await f.callback({ client_id: "oaiapp_repeat" });
|
||||
for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r));
|
||||
await waitForSignIn(f.service);
|
||||
const status = await f.service.status();
|
||||
assert.equal(f.saved.registrations.length, 2); assert.equal(status.accounts.length, 1);
|
||||
assert.equal(status.activeId, status.accounts[0].id); assert.notEqual(status.activeId, original.id);
|
||||
@@ -595,7 +604,7 @@ test("one app-wide background preference defaults ON over legacy OFF, persists w
|
||||
const id = f.saved.activeId;
|
||||
await f.service.select(id); assert.equal((await f.service.status()).allowBackgroundUse, false);
|
||||
await f.service.login(id); await f.callback();
|
||||
for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r));
|
||||
await waitForSignIn(f.service);
|
||||
assert.equal(f.saved.allowBackgroundUse, false, "reconnecting cannot overwrite app preference");
|
||||
assert.equal(f.saved.registrations[0].background, undefined, "new verified registrations omit obsolete per-account preference");
|
||||
await f.service.disconnect(f.saved.activeId);
|
||||
@@ -604,7 +613,7 @@ test("one app-wide background preference defaults ON over legacy OFF, persists w
|
||||
try {
|
||||
assert.equal((await restarted.service.status()).allowBackgroundUse, false, "zero-account restart preserves global OFF");
|
||||
await restarted.service.login(); await restarted.callback();
|
||||
for (let i = 0; i < 100 && (await restarted.service.status()).pending; i++) await new Promise((r) => setImmediate(r));
|
||||
await waitForSignIn(restarted.service);
|
||||
assert.equal(restarted.saved.allowBackgroundUse, false);
|
||||
await restarted.service.consent(true); assert.equal(restarted.saved.allowBackgroundUse, true);
|
||||
} finally { restarted.service.close(); }
|
||||
|
||||
@@ -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>/);
|
||||
});
|
||||
|
||||
@@ -170,6 +170,41 @@ test("case-insensitive Authorization overrides including null are refused before
|
||||
assert.equal(requests.length, before);
|
||||
});
|
||||
|
||||
test("Fast opt-in pins priority on the actual OAuth request without altering reasoning or accepting caller tier overrides", async () => {
|
||||
for (const fastMode of [undefined, false, true]) {
|
||||
requests = []; responseEvents = [complete()];
|
||||
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id, thinkingLevel: "high", fastMode }, false);
|
||||
const result = await engine.streamFn(engine.model, context(), {
|
||||
samplingParams: { service_tier: "flex" },
|
||||
onPayload: (raw) => ({ ...raw as object, service_tier: "default" }),
|
||||
}).result();
|
||||
assert.equal(result.stopReason, "stop"); assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0]!.body.service_tier, fastMode ? "priority" : undefined);
|
||||
assert.equal(requests[0]!.body.reasoning.effort, "high");
|
||||
assert.equal(requests[0]!.authorization, "Bearer scratch-oauth-only");
|
||||
}
|
||||
});
|
||||
|
||||
test("the Fast preference never injects priority into an API-key provider request", async () => {
|
||||
requests = []; responseEvents = [complete()];
|
||||
const engine = piEngineForSpec({ provider: "openai", id: model.id, fastMode: true }, false);
|
||||
const result = await engine.streamFn(engine.model, context(), { apiKey: "sk-offline-api-key-fixture" }).result();
|
||||
assert.equal(result.stopReason, "stop"); assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0]!.body.service_tier, undefined);
|
||||
assert.equal(requests[0]!.authorization, "Bearer sk-offline-api-key-fixture");
|
||||
});
|
||||
|
||||
test("an unsupported priority tier gives actionable feedback without retrying or using an API key", async () => {
|
||||
requests = [];
|
||||
responseEvents = [{ type: "error", code: "unsupported_parameter", param: "service_tier", message: "private provider diagnostic" }];
|
||||
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id, fastMode: true }, false);
|
||||
const result = await engine.streamFn(engine.model, context()).result();
|
||||
assert.equal(result.stopReason, "error"); assert.equal(requests.length, 1);
|
||||
assert(result.errorMessage?.includes("Turn off Fast mode"));
|
||||
assert(!result.errorMessage?.includes("private provider diagnostic"));
|
||||
assert.equal(requests[0]!.authorization, "Bearer scratch-oauth-only");
|
||||
});
|
||||
|
||||
test("full credential-storage invalidation aborts foreground and background controllers and clears capabilities", () => {
|
||||
const foreground = chatGPTAbortController(false), background = chatGPTAbortController(true);
|
||||
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 2, authorityRevision: 2, revision: 3 }, status: { ...status, available: false }, invalidate: "all" });
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
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 { AgentSection } from "../components/settings/agent-section.tsx";
|
||||
import { I18nProvider } from "../components/i18n.tsx";
|
||||
import type { ModelCatalogEntry, ModelsSettings } from "../src/core/index.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, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const originalFetch = globalThis.fetch;
|
||||
const chatgpt = { provider: "chatgpt", id: "gpt-5" };
|
||||
const api = { provider: "openai", id: "gpt-5" };
|
||||
const fixture: ModelCatalogEntry = { ...chatgpt, name: "Offline model", reasoning: true, input: ["text", "image"], supportedLevels: ["low", "high"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 2048 };
|
||||
let root: Root | null = null;
|
||||
let host: HTMLDivElement;
|
||||
let writes: ModelsSettings[];
|
||||
let saved: ModelsSettings;
|
||||
let waitForSave: (() => Promise<void>) | undefined;
|
||||
let failSave = false;
|
||||
function button(slot: string, kind = "fast"): HTMLButtonElement {
|
||||
const found = document.querySelector<HTMLButtonElement>(`[data-testid="slot-${kind}-${slot}"]`);
|
||||
assert(found); return found;
|
||||
}
|
||||
async function click(target: HTMLButtonElement) { await act(async () => target.click()); }
|
||||
async function mount() {
|
||||
writes = []; waitForSave = undefined; failSave = false;
|
||||
saved = { strong: { model: chatgpt, thinkingLevel: "high" }, cheap: { model: chatgpt, thinkingLevel: "low" }, vision: { model: api } };
|
||||
const responses: Record<string, unknown> = {
|
||||
"/api/models": { models: [fixture, { ...fixture, provider: "openai" }], slots: saved },
|
||||
"/api/search": { available: false, engine: null, reason: "No search engine is configured." },
|
||||
"/api/keys": { keys: [] }, "/api/settings/prompts": { prober: [], teacher: [] },
|
||||
};
|
||||
globalThis.fetch = async input => { const name = String(input); assert(name in responses); return Response.json(responses[name]); };
|
||||
host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host);
|
||||
function Harness() {
|
||||
const [models, setModels] = React.useState(saved);
|
||||
return <I18nProvider initialGeneral={{ language: "ru" }}><AgentSection agent={undefined} models={models} graph={undefined} compaction={undefined} onSectionChange={() => {}} onCompactionChange={() => {}} onModelsChange={async next => {
|
||||
writes.push(next); await waitForSave?.();
|
||||
if (failSave) throw new Error("offline save failure");
|
||||
saved = next; setModels(next);
|
||||
}} /></I18nProvider>;
|
||||
}
|
||||
await act(async () => { root!.render(<Harness />); });
|
||||
}
|
||||
afterEach(async () => { await act(async () => root?.unmount()); root = null; host?.remove(); globalThis.fetch = originalFetch; });
|
||||
|
||||
it("Fast controls wait for stored truth, lock related edits while saving, and preserve other tiers", async () => {
|
||||
await mount();
|
||||
assert.equal(button("strong").getAttribute("aria-pressed"), "false");
|
||||
assert.equal(button("vision").disabled, true);
|
||||
assert.equal(button("strong").textContent, "Быстро");
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>(resolve => { release = resolve; }); waitForSave = () => pending;
|
||||
await click(button("strong"));
|
||||
assert.equal(writes.length, 1);
|
||||
for (const slot of ["strong", "cheap"]) for (const kind of ["fast", "model", "thinking"]) assert.equal(button(slot, kind).disabled, true);
|
||||
assert.equal(button("strong").getAttribute("aria-pressed"), "false", "unconfirmed billing choices must not appear saved");
|
||||
await act(async () => { release(); }); waitForSave = undefined;
|
||||
assert.equal(button("strong").getAttribute("aria-pressed"), "true");
|
||||
await click(button("cheap"));
|
||||
assert.equal(saved.strong?.fastMode, true); assert.equal(saved.cheap?.fastMode, true);
|
||||
assert.equal(saved.strong?.thinkingLevel, "high"); assert.equal(saved.vision?.fastMode, undefined);
|
||||
});
|
||||
it("failed Fast saves retain the previous choice, show a Russian error, and allow retry", async () => {
|
||||
await mount(); failSave = true;
|
||||
await click(button("strong"));
|
||||
assert.equal(button("strong").getAttribute("aria-pressed"), "false"); assert.equal(button("strong").disabled, false);
|
||||
assert.equal(document.querySelector('[role="alert"]')?.textContent, "Не удалось сохранить настройки моделей");
|
||||
failSave = false; await click(button("strong"));
|
||||
assert.equal(button("strong").getAttribute("aria-pressed"), "true"); assert.equal(document.querySelector('[role="alert"]'), null);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { it } from "node:test";
|
||||
import { sanitizeUserSettings, type ModelsSettings, type UserSettings } from "../src/core/settings.ts";
|
||||
import { resolveConversationSpec, resolveModelSpec, resolveRoleSpec, resolveSettings } from "../src/core/settings-resolution.ts";
|
||||
import { parseModelsSettingsBody } from "../src/server/settings-body.ts";
|
||||
import { clampModelsLevels } from "../src/server/model-catalog.ts";
|
||||
import { composeSlotFast, composeSlotModel } from "../components/settings/agent-section.tsx";
|
||||
import { chatGPTPayload, chatGPTStreamError } from "../src/agent/chatgpt-stream.ts";
|
||||
import { translate } from "../src/core/i18n.ts";
|
||||
|
||||
const chatgpt = { provider: "chatgpt", id: "gpt-5" };
|
||||
const api = { provider: "openai", id: "gpt-5" };
|
||||
it("validates and saves explicit per-tier Fast choices, dropping invalid and unsupported saved flags", () => {
|
||||
const models = { strong: { model: chatgpt, thinkingLevel: "high", fastMode: true }, cheap: { model: chatgpt, fastMode: false }, vision: { model: api } };
|
||||
assert.deepEqual(parseModelsSettingsBody(models), { ok: true, settings: models });
|
||||
assert.deepEqual(sanitizeUserSettings({ version: 1, models })?.models, models);
|
||||
for (const fastMode of ["true", 1, null]) assert.equal(parseModelsSettingsBody({ strong: { model: chatgpt, fastMode } }).ok, false);
|
||||
assert.equal(parseModelsSettingsBody({ strong: { model: api, fastMode: true } }).ok, false);
|
||||
assert.deepEqual(sanitizeUserSettings({ version: 1, models: { strong: { model: api, fastMode: true }, cheap: { model: chatgpt, fastMode: "true" } } })?.models, { strong: { model: api }, cheap: { model: chatgpt } });
|
||||
});
|
||||
it("toggles one tier without altering its model, reasoning, or siblings; a provider swap clears Fast", () => {
|
||||
const models: ModelsSettings = { strong: { model: chatgpt, thinkingLevel: "high" }, cheap: { model: api, thinkingLevel: "low" } };
|
||||
const enabled = composeSlotFast(models, "strong", true);
|
||||
assert.deepEqual(enabled, { ...models, strong: { ...models.strong, fastMode: true } });
|
||||
assert.equal(models.strong?.fastMode, undefined);
|
||||
assert.deepEqual(composeSlotFast(enabled, "cheap", true), enabled);
|
||||
assert.equal(composeSlotModel(enabled, "strong", "chatgpt", "another-model", "low").strong?.fastMode, true);
|
||||
assert.equal(composeSlotModel(enabled, "strong", "openai", "gpt-5", "high").strong?.fastMode, undefined);
|
||||
assert.equal(composeSlotFast(enabled, "strong", false).strong?.fastMode, false);
|
||||
});
|
||||
it("updates existing conversations on the next resolution without replacing their saved model or reasoning", () => {
|
||||
const chat = { model: { provider: "chatgpt", id: "saved-chat-model" }, thinkingLevel: "low" as const };
|
||||
const before = structuredClone(chat);
|
||||
const strong = { provider: "chatgpt", id: "new-session-default", thinkingLevel: "high" as const, fastMode: true };
|
||||
assert.deepEqual(resolveConversationSpec(chat, strong), { provider: "chatgpt", id: "saved-chat-model", thinkingLevel: "low", fastMode: true });
|
||||
assert.equal(resolveConversationSpec(chat, { ...strong, fastMode: false })?.fastMode, undefined);
|
||||
assert.equal(resolveConversationSpec({ ...chat, model: api }, strong)?.fastMode, undefined);
|
||||
assert.equal(resolveConversationSpec(chat, { ...strong, provider: "openai" })?.fastMode, undefined);
|
||||
assert.deepEqual(chat, before);
|
||||
});
|
||||
it("background work uses only its mapped tier's Fast preference and never opts another provider in", () => {
|
||||
const settings: UserSettings = { version: 1, models: { strong: { model: chatgpt, fastMode: true }, cheap: { model: chatgpt, fastMode: false }, vision: { model: chatgpt, fastMode: true } } };
|
||||
assert.equal(resolveRoleSpec("researcher", settings)?.fastMode, undefined);
|
||||
assert.equal(resolveRoleSpec("visualMaker", settings)?.fastMode, true);
|
||||
assert.equal(resolveRoleSpec("pdfReader", settings)?.fastMode, true);
|
||||
settings.models!.cheap!.fastMode = true;
|
||||
assert.equal(resolveRoleSpec("researcher", settings)?.fastMode, true);
|
||||
assert.equal(resolveModelSpec("researcher", { model: api }, settings.models)?.fastMode, undefined);
|
||||
assert.equal(resolveSettings({ version: 1, models: { strong: { model: api, fastMode: true } } }).models.strong?.fastMode, undefined);
|
||||
});
|
||||
it("the server's reasoning clamp preserves Fast rather than silently dropping the opt-in on write", () => {
|
||||
const models: ModelsSettings = { strong: { model: chatgpt, thinkingLevel: "high", fastMode: true }, cheap: { model: api, thinkingLevel: "low" } };
|
||||
assert.equal(clampModelsLevels(models).strong?.fastMode, true);
|
||||
assert.equal(clampModelsLevels(models).strong?.thinkingLevel, "high");
|
||||
});
|
||||
it("Fast is off for old records, request sanitization pins the chosen tier, and unsupported-mode errors are localized", () => {
|
||||
const settings = resolveSettings({ version: 1, models: { strong: { model: chatgpt } } });
|
||||
assert.equal(settings.models.strong?.fastMode, undefined);
|
||||
assert.equal(chatGPTPayload({ input: [], service_tier: "priority" }, "gpt-5").service_tier, undefined);
|
||||
assert.equal(chatGPTPayload({ input: [], service_tier: "flex" }, "gpt-5", true).service_tier, "priority");
|
||||
const error = chatGPTStreamError("400 invalid service_tier");
|
||||
assert(error.includes("Turn off Fast mode"));
|
||||
assert(translate("ru", error).includes("Отключите быстрый режим"));
|
||||
assert(chatGPTStreamError("429 service_tier quota exceeded").includes("https://chatgpt.com/settings/usage"));
|
||||
});
|
||||
+57
-9
@@ -7,7 +7,8 @@ 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 { resolveSettings } from "../src/core/settings-resolution.ts";
|
||||
import { countLabel, dictationUrl, resolveLanguage, resolveSttLanguage, systemLanguage, translate } from "../src/core/i18n.ts";
|
||||
import { sessionMarkdown } from "../src/core/session-markdown.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { I18nProvider, LanguageSelect } from "../components/i18n.tsx";
|
||||
@@ -29,6 +30,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 +48,54 @@ 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
it("validates and resolves preferred speech languages without changing the recognition mode", () => {
|
||||
const general = { language: "ru", sttLanguage: "multi", sttPreferredLanguages: ["ru", "en", "ru"], autoUpdate: false };
|
||||
const expected = { ...general, sttPreferredLanguages: ["ru", "en"] };
|
||||
const parsed = parseGeneralSettingsBody(general);
|
||||
assert.deepEqual(parsed, { ok: true, settings: expected });
|
||||
const record = sanitizeUserSettings({ version: 1, general });
|
||||
assert.deepEqual(record?.general, expected);
|
||||
assert.deepEqual(resolveSettings(record).general, expected);
|
||||
assert.deepEqual(parseGeneralSettingsBody({ sttPreferredLanguages: [] }), { ok: true, settings: { sttPreferredLanguages: [] } });
|
||||
for (const invalid of [null, "en", ["de"], ["multi"], ["en", 1], {}]) {
|
||||
const result = parseGeneralSettingsBody({ sttPreferredLanguages: invalid });
|
||||
assert(!result.ok);
|
||||
assert.equal(translate("ru", result.error), "Предпочитаемые языки должны быть списком кодов «en» и «ru»");
|
||||
}
|
||||
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { sttPreferredLanguages: ["ru", "de", "en", null, "ru"] } })?.general, { sttPreferredLanguages: ["ru", "en"] });
|
||||
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { sttPreferredLanguages: "ru" } })?.general, {});
|
||||
});
|
||||
it("sends repeated language hints only for multilingual recognition, with no hints by default", () => {
|
||||
assert.deepEqual(new URL(dictationUrl("multi")).searchParams.getAll("language_hint"), []);
|
||||
assert.deepEqual(new URL(dictationUrl("multi", [])).searchParams.getAll("language_hint"), []);
|
||||
const url = new URL(dictationUrl("multi", ["en", "ru", "en"]));
|
||||
assert.deepEqual(url.searchParams.getAll("language_hint"), ["en", "ru"]);
|
||||
assert.equal(url.searchParams.get("language"), "multi");
|
||||
url.searchParams.delete("language_hint");
|
||||
assert.equal(url.toString(), dictationUrl("multi"), "hinting must preserve every existing streaming parameter");
|
||||
for (const language of ["en", "ru"] as const) {
|
||||
assert.equal(dictationUrl(language, ["en", "ru"]), dictationUrl(language));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -90,10 +139,9 @@ describe("translation quality and rendering", () => {
|
||||
it("offers independent saved language choices with localized accessible labels", () => {
|
||||
const html = renderToStaticMarkup(<I18nProvider initialGeneral={{ language: "ru", sttLanguage: "en" }}><LanguageSelect field="sttLanguage" /></I18nProvider>);
|
||||
assert(html.includes('aria-label="Язык распознавания речи"'));
|
||||
assert(html.includes('value="en" selected=""'));
|
||||
assert(html.includes("Язык системы"));
|
||||
assert(html.includes('aria-haspopup="menu"'));
|
||||
assert(html.includes('aria-expanded="false"'));
|
||||
assert(html.includes("English"));
|
||||
assert(html.includes("Русский"));
|
||||
});
|
||||
it("translates dialog text and accessibility labels", () => {
|
||||
const html = renderToStaticMarkup(<I18nProvider system="ru"><AbortTurnBody mode="turn" onClose={() => {}} onConfirm={() => {}} /></I18nProvider>);
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
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 { 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;
|
||||
let general: GeneralSettings;
|
||||
let writes: GeneralSettings[];
|
||||
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);
|
||||
assert(found, `expected ${selector}`);
|
||||
return found;
|
||||
}
|
||||
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(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 }));
|
||||
});
|
||||
}
|
||||
async function pick(text: string) {
|
||||
const option = [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')].find((node) => node.textContent?.includes(text));
|
||||
assert(option, `expected option ${text}`);
|
||||
await click(option);
|
||||
}
|
||||
async function mount(initialGeneral: GeneralSettings = { language: "ru", sttLanguage: "en", autoUpdate: false }) {
|
||||
general = initialGeneral;
|
||||
writes = []; failSave = false; holdSave = undefined; submits = 0;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = String(input);
|
||||
if ((init?.method ?? "GET") === "GET") {
|
||||
assert.equal(url, "/api/settings");
|
||||
return Response.json({ settings: { general } });
|
||||
}
|
||||
assert.equal(url, "/api/settings/general"); assert.equal(init?.method, "PUT");
|
||||
const next = JSON.parse(String(init.body)) as GeneralSettings;
|
||||
writes.push(next);
|
||||
await holdSave?.();
|
||||
if (failSave) return new Response("failed", { status: 503 });
|
||||
general = next;
|
||||
return Response.json({ ok: true });
|
||||
};
|
||||
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" /><SttModelSelect /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /><SttPreferredLanguages /></>;
|
||||
}
|
||||
await act(async () => { root!.render(<I18nProvider initialGeneral={general}><Controls /></I18nProvider>); });
|
||||
}
|
||||
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);
|
||||
await click(trigger("sttLanguage"));
|
||||
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("Русский");
|
||||
assert.deepEqual(general, { language: "ru", sttLanguage: "ru", autoUpdate: false });
|
||||
assert(trigger("sttLanguage", true).textContent?.includes("Русский"));
|
||||
assert.equal(document.querySelector('[role="menu"]'), null);
|
||||
await click(trigger("language")); await pick("Язык системы");
|
||||
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;
|
||||
const onOpen = () => opened++; const onClose = () => opened--;
|
||||
window.addEventListener(MENU_OPEN_EVENT, onOpen); window.addEventListener(MENU_CLOSE_EVENT, onClose);
|
||||
try {
|
||||
await click(trigger("language")); assert.equal(opened, 1);
|
||||
await click(trigger("sttLanguage", true)); assert.equal(opened, 1);
|
||||
assert.equal(trigger("language").getAttribute("aria-expanded"), "false");
|
||||
await act(async () => {
|
||||
const event = new dom.window.Event("keydown", { bubbles: true, cancelable: true });
|
||||
Object.assign(event, { key: "Escape" }); element('[role="menuitemradio"]').dispatchEvent(event);
|
||||
});
|
||||
assert.equal(opened, 0);
|
||||
await click(trigger("language")); await click(document.body);
|
||||
assert.equal(opened, 0); assert.equal(writes.length, 0);
|
||||
} finally { window.removeEventListener(MENU_OPEN_EVENT, onOpen); window.removeEventListener(MENU_CLOSE_EVENT, onClose); }
|
||||
});
|
||||
|
||||
it("locks both STT selectors and closes an open menu when recording begins", async () => {
|
||||
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);
|
||||
assert((trigger("sttLanguage", true) as HTMLButtonElement).disabled);
|
||||
assert(!(trigger("language") as HTMLButtonElement).disabled);
|
||||
await click(trigger("sttLanguage")); assert.equal(document.querySelector('[role="menu"]'), null);
|
||||
await act(async () => recording(false));
|
||||
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("Несколько языков");
|
||||
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, "multi"); assert.equal(document.querySelector('[role="alert"]'), null);
|
||||
});
|
||||
|
||||
it("serializes rapid picks in independent selectors without losing either preference", async () => {
|
||||
await mount(); let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||
holdSave = () => pending;
|
||||
await click(trigger("language")); await pick("English");
|
||||
assert((trigger("language") as HTMLButtonElement).disabled);
|
||||
await click(trigger("sttLanguage")); await pick("Русский");
|
||||
assert.equal(writes.length, 1);
|
||||
await act(async () => { release(); });
|
||||
assert.deepEqual(general, { language: "en", sttLanguage: "ru", autoUpdate: false });
|
||||
assert.equal(writes.length, 2);
|
||||
});
|
||||
|
||||
function preferred(language: "en" | "ru") { return element(`[data-testid="stt-preferred-${language}"]`); }
|
||||
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(preferredTrigger().disabled);
|
||||
assert.equal(writes.length, 0);
|
||||
await click(trigger("sttLanguage")); await pick("Несколько языков");
|
||||
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 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");
|
||||
assert.equal(general.sttPreferredLanguages, undefined);
|
||||
assert.equal(element('[role="alert"]').textContent, "Не удалось сохранить предпочитаемые языки");
|
||||
failSave = false;
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; }); holdSave = () => pending;
|
||||
await click(preferred("en"));
|
||||
assert.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(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(!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"));
|
||||
await click(trigger("language")); await pick("English");
|
||||
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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" 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);
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
@@ -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