Compare commits

...
5 Commits
20 changed files with 467 additions and 110 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
name: Linux desktop release
# Manual until a native Linux runner is registered. The current 0.5.0
# Manual until a native Linux runner is registered. The current 0.5.2
# 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.5.0)
description: Existing stable version tag to package (for example v0.5.2)
required: true
type: string
+8 -7
View File
@@ -3,7 +3,7 @@
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 { dictationUrl } from "@/src/core";
import { LanguageSelect, useI18n } from "@/components/i18n";
import { Hint } from "@/components/ui/hint";
@@ -279,10 +279,10 @@ 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 [phase, setPhase] = useState<DictationPhase>("idle");
const [error, setError] = useState<string | null>(null);
/** 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",
// 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 +425,8 @@ 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;
setDictating(true);
dictationActiveFlag = true;
setError(null);
+44 -11
View File
@@ -1,19 +1,21 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { resolveLanguage, translator, type GeneralSettings, type Language, 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 { MenuShell } from "./menu-shell";
import { CheckIcon } from "lucide-react";
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); }
@@ -28,7 +30,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));
@@ -56,7 +58,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", "sttPreferredLanguages", "autoUpdate"] as const) {
if (!Object.hasOwn(patch, field)) continue;
if (patch[field] === undefined) delete next[field];
else Object.assign(next, { [field]: patch[field] });
@@ -70,7 +72,7 @@ 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>;
}
@@ -84,13 +86,16 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
const menuId = useId();
const locked = disabled || saving || (field === "sttLanguage" && dictating);
const menuOpen = open && !locked;
const selected = general[field] ?? "auto";
const compactStt = compact && field === "sttLanguage";
const saved = general[field] ?? "auto";
const selected = compactStt && saved === "auto" ? sttLanguage : saved;
const label = t(field === "language" ? "Interface language" : "Speech recognition language");
const testid = `language-select-${field}${compact ? "-compact" : ""}`;
const options = [
{ value: "auto", label: compact ? `${t("Auto")} · ${sttLanguage === "ru" ? "RU" : "EN"}` : t("System default") },
...(!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(() => {
@@ -114,11 +119,11 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
document.removeEventListener("keydown", escape, true);
};
}, [menuOpen, menuId]);
const pick = (value: "auto" | Language) => {
const pick = (value: "auto" | SttLanguage) => {
if (locked) return;
setOpen(false);
trigger.current?.focus();
if (value === selected) return;
if (value === saved) return;
setSaving(true); setError(null);
void saveLanguage(field, value === "auto" ? undefined : value).catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not save language settings")).finally(() => setSaving(false));
};
@@ -130,7 +135,7 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault(); event.stopPropagation(); setOpen(true);
}}>
<span className="min-w-0 flex-1 truncate text-left">{options.find((option) => option.value === selected)?.label}</span>
<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 w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl">
@@ -144,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>}
</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>;
}
+39 -37
View File
@@ -666,49 +666,51 @@ export function AgentSection({
label={SLOT_META[slot].label}
hint={SLOT_META[slot].roster}
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
span holding BOTH triggers of the open menu. */}
span holding the tier's controls. */}
<span
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"
>
<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:cursor-not-allowed disabled:opacity-40",
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"
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:cursor-not-allowed disabled:opacity-40",
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>
{thisMenuOpen === "model" && (
<MenuShell
preferredSide="down"
+4 -1
View File
@@ -39,7 +39,7 @@ import {
type GraphSettings,
type UserSettings,
} from "@/src/core";
import { LanguageSelect, useI18n } from "@/components/i18n";
import { LanguageSelect, SttPreferredLanguages, useI18n } from "@/components/i18n";
import { useAppearance } from "./appearance.tsx";
import { SettingsSection, SettingsRow, Toggle } from "./controls.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("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
label={t("Auto-update")}
hint={AUTO_UPDATE_HINT}
+1 -1
View File
@@ -1,6 +1,6 @@
# Desktop builds and releases
For this fork’s Linux and Windows 0.5.0 release on Gitea, see [Gitea releases](gitea-release.md).
For this fork’s Linux and Windows 0.5.2 release on Gitea, see [Gitea releases](gitea-release.md).
The three-platform GitHub process below documents the upstream reference.
## Platforms
+21 -18
View File
@@ -2,7 +2,7 @@
The release destination is
https://server2.sorokin-oleg-edu.xyz/replicated/learnharness.
Version **0.5.0 targets Linux x64 and Windows x64**, with each platform built
Version **0.5.2 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,18 +48,18 @@ 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 portion of 0.5.0 has these attachments:
The Linux portion of 0.5.2 has these attachments:
- `LearnHarness-0.5.0-x86_64.AppImage`
- `LearnHarness-0.5.0-amd64.deb`
- `LearnHarness-0.5.2-x86_64.AppImage`
- `LearnHarness-0.5.2-amd64.deb`
- `latest-linux.yml`
- `SHA256SUMS`
## Preparing Windows packages
Use a clean checkout of the same `v0.5.0` tag on Windows x64 with Node 26 and Git
Use a clean checkout of the same `v0.5.2` 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.5.0`, linked to the
release checkout is `~/Documents/Gitea/learnharness-release-0.5.2`, linked to the
existing repository in `~/Documents/Gitea/learnharness`.
In PowerShell, stop after any failed command:
@@ -85,13 +85,13 @@ node scripts/smoke-demo-runtime.mjs --standalone dist/win-unpacked/resources/ser
Windows contributes three attachments:
- `LearnHarness-0.5.0-x64-setup.exe`
- `LearnHarness-0.5.0-x64-setup.exe.blockmap`
- `LearnHarness-0.5.2-x64-setup.exe`
- `LearnHarness-0.5.2-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.5.0`, and matches the installer's actual
installer exactly, reports version `0.5.2`, 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
@@ -102,8 +102,8 @@ configuration.
Commit and push the release source, then push its exact annotated tag:
```sh
git tag -a v0.5.0 -m 'LearnHarness 0.5.0'
git push gitea main v0.5.0
git tag -a v0.5.2 -m 'LearnHarness 0.5.2'
git push gitea main v0.5.2
```
Supply `GITEA_TOKEN` only to the upload command. A token file may remain outside
@@ -117,15 +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.5.0.md`.
visibility. Release notes come from `docs/release-0.5.2.md`.
This uploader and its remote verifier enforce a **Linux-only** inventory. They
will refuse a combined Linux/Windows draft. For the combined 0.5.0 release, add
will refuse a combined Linux/Windows draft. For the combined 0.5.2 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, sizes, and actual downloaded hashes against the
verified local builds; update the bilingual release notes to include Windows.
Do not publish until both platforms' checks pass.
all seven attachment names and sizes against the verified local builds. For
the owner-approved 0.5.2 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:
@@ -142,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.5.0 was prepared, so its packages are built locally. A tag push
registered when 0.5.2 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. This workflow remains
Linux-only; the combined 0.5.0 draft was assembled from local native builds.
Linux-only; the combined 0.5.2 draft was assembled from local native builds.
Windows has its own standalone payload and smoke checks, as described above.
+53
View File
@@ -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 не подписан, как и в предыдущем выпуске.
+56
View File
@@ -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 не подписан, как и в предыдущих выпусках.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "learnharness",
"version": "0.5.0",
"version": "0.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "learnharness",
"version": "0.5.0",
"version": "0.5.2",
"license": "AGPL-3.0-only",
"dependencies": {
"@earendil-works/pi-agent-core": "1.4.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "learnharness",
"version": "0.5.0",
"version": "0.5.2",
"license": "AGPL-3.0-only",
"author": {
"name": "Amos Blomqvist",
+9 -1
View File
@@ -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}`;
}
+7
View File
@@ -249,6 +249,12 @@
"Speech recognition language": "Speech recognition language",
"Auto": "Auto",
"System default": "System default",
"Multilingual": "Multilingual",
"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",
"Chat": "Chat",
"Graph view": "Graph view",
@@ -723,6 +729,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",
+7
View File
@@ -249,6 +249,12 @@
"Speech recognition language": "Язык распознавания речи",
"Auto": "Авто",
"System default": "Язык системы",
"Multilingual": "Несколько языков",
"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": "Общие",
"Chat": "Занятие",
"Graph view": "Граф",
@@ -754,6 +760,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 должно быть строкой",
+1
View File
@@ -184,6 +184,7 @@ 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?.sttPreferredLanguages ? { sttPreferredLanguages: [...record.general.sttPreferredLanguages] } : {}),
},
agent: {
models,
+9 -6
View File
@@ -32,6 +32,7 @@ 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";
/** The schema version this code understands. Bump ONLY for breaking shape
* changes (a field renamed/meaning changed); pure additions never bump. */
@@ -236,9 +237,11 @@ 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;
/** Optional recognition hints for multilingual dictation; absent/empty = automatic detection. */
sttPreferredLanguages?: Language[];
}
/** Agent settings — everything about the non-conversation machinery. */
@@ -447,9 +450,9 @@ 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 (Array.isArray(raw.sttPreferredLanguages)) out.sttPreferredLanguages = [...new Set(raw.sttPreferredLanguages.filter(isLanguage))];
return out;
}
+13 -7
View File
@@ -12,7 +12,7 @@
*/
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { isHexColor, type ChatSettings } from "../core";
import { isHexColor, isLanguage, isSttLanguage, 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,17 @@ 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)];
}
return { ok: true, settings };
}
+55 -6
View File
@@ -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));
}
});
});
+129 -10
View File
@@ -4,9 +4,9 @@ import assert from "node:assert/strict";
import { afterEach, it } from "node:test";
import { createRoot, type Root } from "react-dom/client";
import { parseHTML } from "linkedom";
import { I18nProvider, LanguageSelect, 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 { translate, type GeneralSettings } from "../src/core/index.ts";
import { dictationUrl, translate, type GeneralSettings, type SttLanguage } from "../src/core/index.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, {
@@ -27,6 +27,7 @@ let writes: GeneralSettings[];
let failSave = false;
let holdSave: (() => Promise<void>) | undefined;
let recording: (active: boolean) => void;
let resolvedSttLanguage: SttLanguage;
function element(selector: string): HTMLElement {
const found = document.querySelector<HTMLElement>(selector);
@@ -45,8 +46,8 @@ async function pick(text: string) {
assert(option, `expected option ${text}`);
await click(option);
}
async function mount() {
general = { language: "ru", sttLanguage: "en", autoUpdate: false };
async function mount(initialGeneral: GeneralSettings = { language: "ru", sttLanguage: "en", autoUpdate: false }) {
general = initialGeneral;
writes = []; failSave = false; holdSave = undefined;
globalThis.fetch = async (input, init) => {
const url = String(input);
@@ -65,7 +66,8 @@ async function mount() {
host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host);
function Controls() {
recording = useI18n().setDictating;
return <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /></>;
resolvedSttLanguage = useI18n().sttLanguage;
return <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /><SttPreferredLanguages /></>;
}
await act(async () => { root!.render(<I18nProvider initialGeneral={general}><Controls /></I18nProvider>); });
}
@@ -77,7 +79,7 @@ it("picks languages through the shared menu and preserves the other language and
await mount();
assert.equal(document.querySelector("select"), null);
await click(trigger("sttLanguage"));
assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3);
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("Русский");
@@ -88,6 +90,61 @@ it("picks languages through the shared menu and preserves the other language and
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;
@@ -108,7 +165,7 @@ it("dismisses outside clicks and Escape, and opening another selector balances m
});
it("locks both STT selectors and closes an open menu when recording begins", async () => {
await mount(); await click(trigger("sttLanguage", true));
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);
@@ -119,16 +176,17 @@ it("locks both STT selectors and closes an open menu when recording begins", asy
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("Русский");
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, "ru"); assert.equal(document.querySelector('[role="alert"]'), null);
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 () => {
@@ -143,3 +201,64 @@ it("serializes rapid picks in independent selectors without losing either prefer
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}"]`) 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 });
});
+6
View File
@@ -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, 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 });