feat: add preferred STT languages and fix model settings layout
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Loader2Icon, MicIcon } from "lucide-react";
|
import { Loader2Icon, MicIcon } from "lucide-react";
|
||||||
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
||||||
import { dictationUrl, type SttLanguage } from "@/src/core";
|
import { dictationUrl } from "@/src/core";
|
||||||
import { LanguageSelect, useI18n } from "@/components/i18n";
|
import { LanguageSelect, useI18n } from "@/components/i18n";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
|
|
||||||
@@ -279,10 +279,10 @@ export type Dictation = {
|
|||||||
* question waits.
|
* question waits.
|
||||||
*/
|
*/
|
||||||
export function useDictation({ append }: { append: (text: string) => void }): Dictation {
|
export function useDictation({ append }: { append: (text: string) => void }): Dictation {
|
||||||
const { sttLanguage, setDictating } = useI18n();
|
const { sttLanguage, general, setDictating } = useI18n();
|
||||||
const selectedLanguage = useRef(sttLanguage);
|
const selectedUrl = useRef("");
|
||||||
selectedLanguage.current = sttLanguage;
|
selectedUrl.current = dictationUrl(sttLanguage, general.sttPreferredLanguages);
|
||||||
const recordingLanguage = useRef<SttLanguage>(sttLanguage);
|
const recordingUrl = useRef(selectedUrl.current);
|
||||||
const [phase, setPhase] = useState<DictationPhase>("idle");
|
const [phase, setPhase] = useState<DictationPhase>("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
|
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
|
||||||
@@ -365,7 +365,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
// Minted JWTs authenticate via the "bearer" subprotocol — ["token",
|
// Minted JWTs authenticate via the "bearer" subprotocol — ["token",
|
||||||
// jwt] is rejected at the handshake (verified live; "token" is for
|
// jwt] is rejected at the handshake (verified live; "token" is for
|
||||||
// raw API keys only, as in the owner's pi dictate extension).
|
// 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;
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.addEventListener("open", () => {
|
ws.addEventListener("open", () => {
|
||||||
@@ -425,7 +425,8 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
);
|
);
|
||||||
|
|
||||||
const start = useCallback(async () => {
|
const start = useCallback(async () => {
|
||||||
recordingLanguage.current = selectedLanguage.current;
|
// Pin the complete configuration once; reconnects must retain both language and hints.
|
||||||
|
recordingUrl.current = selectedUrl.current;
|
||||||
setDictating(true);
|
setDictating(true);
|
||||||
dictationActiveFlag = true;
|
dictationActiveFlag = true;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
+30
-1
@@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useR
|
|||||||
import { resolveLanguage, resolveSttLanguage, translator, type GeneralSettings, type Language, type SttLanguage, type Translator } from "@/src/core";
|
import { resolveLanguage, resolveSttLanguage, translator, type GeneralSettings, type Language, type SttLanguage, type Translator } from "@/src/core";
|
||||||
import { notifySettingsSaved, useSettingsSaved } from "./settings-signal";
|
import { notifySettingsSaved, useSettingsSaved } from "./settings-signal";
|
||||||
import { MenuShell } from "./menu-shell";
|
import { MenuShell } from "./menu-shell";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
|
||||||
type LanguageField = "language" | "sttLanguage";
|
type LanguageField = "language" | "sttLanguage";
|
||||||
type I18nValue = {
|
type I18nValue = {
|
||||||
@@ -57,7 +58,7 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: {
|
|||||||
if (!current.ok) throw new Error("Could not load language settings");
|
if (!current.ok) throw new Error("Could not load language settings");
|
||||||
const body = await current.json() as { settings?: { general?: GeneralSettings } };
|
const body = await current.json() as { settings?: { general?: GeneralSettings } };
|
||||||
const next = { ...(body.settings?.general ?? latest.current) };
|
const next = { ...(body.settings?.general ?? latest.current) };
|
||||||
for (const field of ["language", "sttLanguage", "autoUpdate"] as const) {
|
for (const field of ["language", "sttLanguage", "sttPreferredLanguages", "autoUpdate"] as const) {
|
||||||
if (!Object.hasOwn(patch, field)) continue;
|
if (!Object.hasOwn(patch, field)) continue;
|
||||||
if (patch[field] === undefined) delete next[field];
|
if (patch[field] === undefined) delete next[field];
|
||||||
else Object.assign(next, { [field]: patch[field] });
|
else Object.assign(next, { [field]: patch[field] });
|
||||||
@@ -148,3 +149,31 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
|
|||||||
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
|
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
|
||||||
</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 preferred = general.sttPreferredLanguages ?? [];
|
||||||
|
const locked = sttLanguage !== "multi" || dictating || saving;
|
||||||
|
const toggle = (language: Language) => {
|
||||||
|
if (locked) return;
|
||||||
|
const next = (["en", "ru"] as const).filter((code) => code === language ? !preferred.includes(code) : preferred.includes(code));
|
||||||
|
setSaving(true); setError(null);
|
||||||
|
void saveGeneral({ sttPreferredLanguages: next })
|
||||||
|
.catch(() => setError("Could not save preferred languages"))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
return <span className="flex max-w-full flex-col items-start gap-1">
|
||||||
|
<span role="group" aria-label={t("Preferred languages")} className="flex max-w-full flex-wrap gap-2">
|
||||||
|
{(["en", "ru"] as const).map((language) => <button key={language} type="button" role="checkbox" aria-checked={preferred.includes(language)}
|
||||||
|
data-testid={`stt-preferred-${language}`} disabled={locked} onClick={() => toggle(language)}
|
||||||
|
className={`flex h-7 items-center gap-1.5 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${preferred.includes(language) ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50"}`}>
|
||||||
|
<span aria-hidden className="flex size-3.5 shrink-0 items-center justify-center rounded-xs border border-border">{preferred.includes(language) && <CheckIcon className="size-3" />}</span>
|
||||||
|
{language === "en" ? "English" : "Русский"}
|
||||||
|
</button>)}
|
||||||
|
</span>
|
||||||
|
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -666,15 +666,15 @@ export function AgentSection({
|
|||||||
label={SLOT_META[slot].label}
|
label={SLOT_META[slot].label}
|
||||||
hint={SLOT_META[slot].roster}
|
hint={SLOT_META[slot].roster}
|
||||||
testid={`slot-row-${slot}`}
|
testid={`slot-row-${slot}`}
|
||||||
className="flex-wrap gap-y-2"
|
className="flex-wrap items-start gap-y-2 [&>span:first-child]:flex-[1_1_12rem]"
|
||||||
>
|
>
|
||||||
{/* The anchor for the outside-click contract is the
|
{/* The anchor for the outside-click contract is the
|
||||||
span holding BOTH triggers of the open menu. */}
|
span holding the tier's controls. */}
|
||||||
<span
|
<span
|
||||||
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
||||||
className="relative ml-auto flex max-w-full flex-wrap items-center justify-end gap-2"
|
className="relative flex w-68 max-w-full shrink-0 flex-col items-start gap-2"
|
||||||
>
|
>
|
||||||
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
|
<span data-testid={`slot-controls-${slot}`} className="grid w-full grid-cols-[minmax(0,1fr)_5rem_auto] items-center gap-2">
|
||||||
<MenuTrigger
|
<MenuTrigger
|
||||||
value={modelId ? prettyModel(modelId) : ""}
|
value={modelId ? prettyModel(modelId) : ""}
|
||||||
emptyLabel="—"
|
emptyLabel="—"
|
||||||
@@ -682,7 +682,7 @@ export function AgentSection({
|
|||||||
testid={`slot-model-${slot}`}
|
testid={`slot-model-${slot}`}
|
||||||
open={thisMenuOpen === "model"}
|
open={thisMenuOpen === "model"}
|
||||||
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
|
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
|
||||||
widthClass="min-w-20"
|
widthClass="w-full min-w-0"
|
||||||
disabled={savingModels}
|
disabled={savingModels}
|
||||||
/>
|
/>
|
||||||
<MenuTrigger
|
<MenuTrigger
|
||||||
@@ -709,6 +709,8 @@ export function AgentSection({
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</Hint>
|
</Hint>
|
||||||
|
</span>
|
||||||
|
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
|
||||||
{thisMenuOpen === "model" && (
|
{thisMenuOpen === "model" && (
|
||||||
<MenuShell
|
<MenuShell
|
||||||
preferredSide="down"
|
preferredSide="down"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
type GraphSettings,
|
type GraphSettings,
|
||||||
type UserSettings,
|
type UserSettings,
|
||||||
} from "@/src/core";
|
} from "@/src/core";
|
||||||
import { LanguageSelect, useI18n } from "@/components/i18n";
|
import { LanguageSelect, SttPreferredLanguages, useI18n } from "@/components/i18n";
|
||||||
import { useAppearance } from "./appearance.tsx";
|
import { useAppearance } from "./appearance.tsx";
|
||||||
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
|
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
|
||||||
import { AgentSection } from "./agent-section.tsx";
|
import { AgentSection } from "./agent-section.tsx";
|
||||||
@@ -368,6 +368,9 @@ export function SettingsDialogBody({
|
|||||||
>
|
>
|
||||||
<SettingsRow label={t("Interface language")} testid="row-language"><LanguageSelect field="language" /></SettingsRow>
|
<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("Speech recognition language")} testid="row-stt-language"><LanguageSelect field="sttLanguage" /></SettingsRow>
|
||||||
|
<SettingsRow label={t("Preferred languages")} hint={t("Used with Multilingual. Favors selected languages; other languages may still be recognized.")} testid="row-stt-preferred-languages" className="flex-col items-start gap-2">
|
||||||
|
<SttPreferredLanguages />
|
||||||
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label={t("Auto-update")}
|
label={t("Auto-update")}
|
||||||
hint={AUTO_UPDATE_HINT}
|
hint={AUTO_UPDATE_HINT}
|
||||||
|
|||||||
+4
-1
@@ -63,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.";
|
: "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. */
|
/** Streaming configuration: the language is pinned once at recording start, including reconnects. */
|
||||||
export function dictationUrl(language: SttLanguage): 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" });
|
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}`;
|
return `wss://api.deepgram.com/v1/listen?${params}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,10 @@
|
|||||||
"System default": "System default",
|
"System default": "System default",
|
||||||
"Multilingual": "Multilingual",
|
"Multilingual": "Multilingual",
|
||||||
"Multi": "Multi",
|
"Multi": "Multi",
|
||||||
|
"Preferred languages": "Preferred languages",
|
||||||
|
"Used with Multilingual. Favors selected languages; other languages may still be recognized.": "Used with Multilingual. Favors selected languages; other languages may still be recognized.",
|
||||||
|
"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",
|
"Global": "Global",
|
||||||
"Chat": "Chat",
|
"Chat": "Chat",
|
||||||
"Graph view": "Graph view",
|
"Graph view": "Graph view",
|
||||||
|
|||||||
@@ -251,6 +251,10 @@
|
|||||||
"System default": "Язык системы",
|
"System default": "Язык системы",
|
||||||
"Multilingual": "Несколько языков",
|
"Multilingual": "Несколько языков",
|
||||||
"Multi": "Мульти",
|
"Multi": "Мульти",
|
||||||
|
"Preferred languages": "Предпочитаемые языки",
|
||||||
|
"Used with Multilingual. Favors selected languages; other languages may still be recognized.": "Используется в режиме «Несколько языков». Выбранные языки получают приоритет, но могут распознаваться и другие.",
|
||||||
|
"Could not save preferred languages": "Не удалось сохранить предпочитаемые языки",
|
||||||
|
"sttPreferredLanguages must be an array of \"en\" and \"ru\"": "Предпочитаемые языки должны быть списком кодов «en» и «ru»",
|
||||||
"Global": "Общие",
|
"Global": "Общие",
|
||||||
"Chat": "Занятие",
|
"Chat": "Занятие",
|
||||||
"Graph view": "Граф",
|
"Graph view": "Граф",
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
|
|||||||
autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate,
|
autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate,
|
||||||
...(record?.general?.language ? { language: record.general.language } : {}),
|
...(record?.general?.language ? { language: record.general.language } : {}),
|
||||||
...(record?.general?.sttLanguage ? { sttLanguage: record.general.sttLanguage } : {}),
|
...(record?.general?.sttLanguage ? { sttLanguage: record.general.sttLanguage } : {}),
|
||||||
|
...(record?.general?.sttPreferredLanguages ? { sttPreferredLanguages: [...record.general.sttPreferredLanguages] } : {}),
|
||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
models,
|
models,
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ export interface GeneralSettings {
|
|||||||
language?: Language;
|
language?: Language;
|
||||||
/** Independent dictation language; multi = multilingual, absent = browser/system language. */
|
/** Independent dictation language; multi = multilingual, absent = browser/system language. */
|
||||||
sttLanguage?: SttLanguage;
|
sttLanguage?: SttLanguage;
|
||||||
|
/** Optional recognition hints for multilingual dictation; absent/empty = automatic detection. */
|
||||||
|
sttPreferredLanguages?: Language[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Agent settings — everything about the non-conversation machinery. */
|
/** Agent settings — everything about the non-conversation machinery. */
|
||||||
@@ -450,6 +452,7 @@ function sanitizeGeneral(raw: Record<string, unknown>): GeneralSettings {
|
|||||||
if (autoUpdate !== undefined) out.autoUpdate = autoUpdate;
|
if (autoUpdate !== undefined) out.autoUpdate = autoUpdate;
|
||||||
if (isLanguage(raw.language)) out.language = raw.language;
|
if (isLanguage(raw.language)) out.language = raw.language;
|
||||||
if (isSttLanguage(raw.sttLanguage)) out.sttLanguage = raw.sttLanguage;
|
if (isSttLanguage(raw.sttLanguage)) out.sttLanguage = raw.sttLanguage;
|
||||||
|
if (Array.isArray(raw.sttPreferredLanguages)) out.sttPreferredLanguages = [...new Set(raw.sttPreferredLanguages.filter(isLanguage))];
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<Gene
|
|||||||
if (!isSttLanguage(raw.sttLanguage)) return { ok: false, error: 'sttLanguage must be "en", "ru", or "multi"' };
|
if (!isSttLanguage(raw.sttLanguage)) return { ok: false, error: 'sttLanguage must be "en", "ru", or "multi"' };
|
||||||
settings.sttLanguage = raw.sttLanguage;
|
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)];
|
||||||
|
}
|
||||||
return { ok: true, settings };
|
return { ok: true, settings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
|||||||
import { createPracticeAgent } from "../src/agent/teacher-agent.ts";
|
import { createPracticeAgent } from "../src/agent/teacher-agent.ts";
|
||||||
import { sanitizeUserSettings } from "../src/core/settings.ts";
|
import { sanitizeUserSettings } from "../src/core/settings.ts";
|
||||||
import { parseGeneralSettingsBody } from "../src/server/settings-body.ts";
|
import { parseGeneralSettingsBody } from "../src/server/settings-body.ts";
|
||||||
|
import { resolveSettings } from "../src/core/settings-resolution.ts";
|
||||||
import { countLabel, dictationUrl, resolveLanguage, resolveSttLanguage, systemLanguage, translate } from "../src/core/i18n.ts";
|
import { countLabel, dictationUrl, resolveLanguage, resolveSttLanguage, systemLanguage, translate } from "../src/core/i18n.ts";
|
||||||
import { sessionMarkdown } from "../src/core/session-markdown.ts";
|
import { sessionMarkdown } from "../src/core/session-markdown.ts";
|
||||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
@@ -68,6 +69,35 @@ describe("language preferences", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("translation quality and rendering", () => {
|
describe("translation quality and rendering", () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import assert from "node:assert/strict";
|
|||||||
import { afterEach, it } from "node:test";
|
import { afterEach, it } from "node:test";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { parseHTML } from "linkedom";
|
import { parseHTML } from "linkedom";
|
||||||
import { I18nProvider, LanguageSelect, useI18n } from "../components/i18n.tsx";
|
import { I18nProvider, LanguageSelect, SttPreferredLanguages, useI18n } from "../components/i18n.tsx";
|
||||||
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "../components/menu-shell.tsx";
|
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "../components/menu-shell.tsx";
|
||||||
import { dictationUrl, translate, type GeneralSettings, type SttLanguage } from "../src/core/index.ts";
|
import { dictationUrl, translate, type GeneralSettings, type SttLanguage } from "../src/core/index.ts";
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ async function mount(initialGeneral: GeneralSettings = { language: "ru", sttLang
|
|||||||
function Controls() {
|
function Controls() {
|
||||||
recording = useI18n().setDictating;
|
recording = useI18n().setDictating;
|
||||||
resolvedSttLanguage = useI18n().sttLanguage;
|
resolvedSttLanguage = useI18n().sttLanguage;
|
||||||
return <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /></>;
|
return <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /><SttPreferredLanguages /></>;
|
||||||
}
|
}
|
||||||
await act(async () => { root!.render(<I18nProvider initialGeneral={general}><Controls /></I18nProvider>); });
|
await act(async () => { root!.render(<I18nProvider initialGeneral={general}><Controls /></I18nProvider>); });
|
||||||
}
|
}
|
||||||
@@ -201,3 +201,64 @@ it("serializes rapid picks in independent selectors without losing either prefer
|
|||||||
assert.deepEqual(general, { language: "en", sttLanguage: "ru", autoUpdate: false });
|
assert.deepEqual(general, { language: "en", sttLanguage: "ru", autoUpdate: false });
|
||||||
assert.equal(writes.length, 2);
|
assert.equal(writes.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function preferred(language: "en" | "ru") { return element(`[data-testid="stt-preferred-${language}"]`) as HTMLButtonElement; }
|
||||||
|
|
||||||
|
it("saves RU + EN hints, restores them after reload, and retains the choices in single-language mode", async () => {
|
||||||
|
await mount();
|
||||||
|
assert(preferred("en").disabled); assert(preferred("ru").disabled);
|
||||||
|
assert.equal(writes.length, 0);
|
||||||
|
await click(trigger("sttLanguage")); await pick("Несколько языков");
|
||||||
|
assert(!preferred("en").disabled);
|
||||||
|
await click(preferred("en")); await click(preferred("ru"));
|
||||||
|
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.equal(preferred("en").getAttribute("aria-checked"), "true");
|
||||||
|
assert.equal(preferred("ru").getAttribute("aria-checked"), "true");
|
||||||
|
await click(trigger("sttLanguage", true)); await pick("English");
|
||||||
|
assert(preferred("en").disabled); assert(preferred("ru").disabled);
|
||||||
|
assert.deepEqual(general.sttPreferredLanguages, ["en", "ru"]);
|
||||||
|
assert.deepEqual(new URL(dictationUrl(resolvedSttLanguage, general.sttPreferredLanguages)).searchParams.getAll("language_hint"), []);
|
||||||
|
await click(trigger("sttLanguage")); await pick("Несколько языков");
|
||||||
|
assert.equal(preferred("en").getAttribute("aria-checked"), "true");
|
||||||
|
await click(preferred("en")); await click(preferred("ru"));
|
||||||
|
assert.deepEqual(general.sttPreferredLanguages, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
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(preferred("en").disabled); assert(preferred("ru").disabled);
|
||||||
|
await click(preferred("ru"));
|
||||||
|
assert.equal(writes.length, 2);
|
||||||
|
await act(async () => release()); holdSave = undefined;
|
||||||
|
assert.equal(element('[data-testid="stt-preferred-en"]').getAttribute("aria-checked"), "true");
|
||||||
|
assert.equal(document.querySelector('[role="alert"]'), null);
|
||||||
|
await act(async () => recording(true));
|
||||||
|
assert(preferred("en").disabled); assert(preferred("ru").disabled);
|
||||||
|
await click(preferred("en"));
|
||||||
|
assert.deepEqual(general.sttPreferredLanguages, ["en"]);
|
||||||
|
await act(async () => recording(false));
|
||||||
|
assert(!preferred("en").disabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes hint and interface-language saves without losing either preference", async () => {
|
||||||
|
await mount({ language: "ru", sttLanguage: "multi", autoUpdate: false });
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe("the settings store", () => {
|
|||||||
assert.deepEqual(out.graph, { enabled: false });
|
assert.deepEqual(out.graph, { enabled: false });
|
||||||
});
|
});
|
||||||
it("persists multilingual STT independently of the interface language and update preference", () => {
|
it("persists multilingual STT independently of the interface language and update preference", () => {
|
||||||
const general = { language: "ru", sttLanguage: "multi", autoUpdate: false } as const;
|
const general = { language: "ru" as const, sttLanguage: "multi" as const, sttPreferredLanguages: ["en" as const, "ru" as const], autoUpdate: false };
|
||||||
writeSettings({ version: 1, general });
|
writeSettings({ version: 1, general });
|
||||||
assert.deepEqual(JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8")).general, general);
|
assert.deepEqual(JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8")).general, general);
|
||||||
assert.deepEqual(readSettings()?.general, general);
|
assert.deepEqual(readSettings()?.general, general);
|
||||||
|
|||||||
Reference in New Issue
Block a user