Compare commits

...
3 Commits
16 changed files with 214 additions and 61 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
name: Linux desktop release 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.1
# packages are built locally using the identical gates documented below. # packages are built locally using the identical gates documented below.
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag: tag:
description: Existing stable version tag to package (for example v0.5.0) description: Existing stable version tag to package (for example v0.5.1)
required: true required: true
type: string type: string
+2 -2
View File
@@ -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 Language } from "@/src/core"; import { dictationUrl, type SttLanguage } 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";
@@ -282,7 +282,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
const { sttLanguage, setDictating } = useI18n(); const { sttLanguage, setDictating } = useI18n();
const selectedLanguage = useRef(sttLanguage); const selectedLanguage = useRef(sttLanguage);
selectedLanguage.current = sttLanguage; selectedLanguage.current = sttLanguage;
const recordingLanguage = useRef<Language>(sttLanguage); const recordingLanguage = useRef<SttLanguage>(sttLanguage);
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). */
+14 -10
View File
@@ -1,19 +1,20 @@
"use client"; "use client";
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; 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 { notifySettingsSaved, useSettingsSaved } from "./settings-signal";
import { MenuShell } from "./menu-shell"; import { MenuShell } from "./menu-shell";
type LanguageField = "language" | "sttLanguage";
type I18nValue = { type I18nValue = {
language: Language; language: Language;
sttLanguage: Language; sttLanguage: SttLanguage;
general: GeneralSettings; general: GeneralSettings;
dictating: boolean; dictating: boolean;
setDictating: (active: boolean) => void; setDictating: (active: boolean) => void;
t: Translator; t: Translator;
saveGeneral: (patch: Partial<GeneralSettings>) => Promise<void>; 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 () => {} }); 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); } export function useI18n(): I18nValue { return useContext(I18nContext); }
@@ -28,7 +29,7 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: {
const latest = useRef(general); const latest = useRef(general);
latest.current = general; latest.current = general;
const language = resolveLanguage(general.language, detected); const language = resolveLanguage(general.language, detected);
const sttLanguage = resolveLanguage(general.sttLanguage, detected); const sttLanguage = resolveSttLanguage(general.sttLanguage, detected);
const t = useMemo(() => translator(language), [language]); const t = useMemo(() => translator(language), [language]);
useEffect(() => { useEffect(() => {
const detect = () => setDetected(resolveLanguage(undefined, navigator.languages)); const detect = () => setDetected(resolveLanguage(undefined, navigator.languages));
@@ -70,7 +71,7 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: {
pending.current = write; pending.current = write;
return 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]); 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>; return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
} }
@@ -84,13 +85,16 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
const menuId = useId(); const menuId = useId();
const locked = disabled || saving || (field === "sttLanguage" && dictating); const locked = disabled || saving || (field === "sttLanguage" && dictating);
const menuOpen = open && !locked; 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 label = t(field === "language" ? "Interface language" : "Speech recognition language");
const testid = `language-select-${field}${compact ? "-compact" : ""}`; const testid = `language-select-${field}${compact ? "-compact" : ""}`;
const options = [ 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: "en", label: "English" },
{ value: "ru", label: "Русский" }, { value: "ru", label: "Русский" },
...(field === "sttLanguage" ? [{ value: "multi", label: t("Multilingual") } as const] : []),
] as const; ] as const;
useEffect(() => { if (locked) setOpen(false); }, [locked]); useEffect(() => { if (locked) setOpen(false); }, [locked]);
useEffect(() => { useEffect(() => {
@@ -114,11 +118,11 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
document.removeEventListener("keydown", escape, true); document.removeEventListener("keydown", escape, true);
}; };
}, [menuOpen, menuId]); }, [menuOpen, menuId]);
const pick = (value: "auto" | Language) => { const pick = (value: "auto" | SttLanguage) => {
if (locked) return; if (locked) return;
setOpen(false); setOpen(false);
trigger.current?.focus(); trigger.current?.focus();
if (value === selected) return; if (value === saved) return;
setSaving(true); setError(null); 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)); 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 +134,7 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault(); event.stopPropagation(); setOpen(true); 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> <span aria-hidden className="text-muted-foreground">▾</span>
</button> </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"> {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">
+1 -1
View File
@@ -1,6 +1,6 @@
# Desktop builds and releases # 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.1 release on Gitea, see [Gitea releases](gitea-release.md).
The three-platform GitHub process below documents the upstream reference. The three-platform GitHub process below documents the upstream reference.
## Platforms ## Platforms
+15 -15
View File
@@ -2,7 +2,7 @@
The release destination is The release destination is
https://server2.sorokin-oleg-edu.xyz/replicated/learnharness. 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.1 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 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` configuration remain available as reference in `.github/workflows/release.yml`
and `electron-builder.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 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.1 has these attachments:
- `LearnHarness-0.5.0-x86_64.AppImage` - `LearnHarness-0.5.1-x86_64.AppImage`
- `LearnHarness-0.5.0-amd64.deb` - `LearnHarness-0.5.1-amd64.deb`
- `latest-linux.yml` - `latest-linux.yml`
- `SHA256SUMS` - `SHA256SUMS`
## Preparing Windows packages ## 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.1` tag on Windows x64 with Node 26 and Git
for Windows. Keep build tools and logs outside the checkout. The Windows 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.1`, linked to the
existing repository in `~/Documents/Gitea/learnharness`. existing repository in `~/Documents/Gitea/learnharness`.
In PowerShell, stop after any failed command: 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: Windows contributes three attachments:
- `LearnHarness-0.5.0-x64-setup.exe` - `LearnHarness-0.5.1-x64-setup.exe`
- `LearnHarness-0.5.0-x64-setup.exe.blockmap` - `LearnHarness-0.5.1-x64-setup.exe.blockmap`
- `latest.yml` - `latest.yml`
The combined release has **seven attachments**, including one `SHA256SUMS` The combined release has **seven attachments**, including one `SHA256SUMS`
covering all six other files. Verify that `latest.yml` names the Windows 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.1`, and matches the installer's actual
size and SHA-512. Its gzip blockmap must cover the installer and match every 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 block checksum. Confirm the packaged `resources/app-update.yml` uses the Gitea
generic provider. Windows builds follow upstream's unsigned, per-user installer 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: Commit and push the release source, then push its exact annotated tag:
```sh ```sh
git tag -a v0.5.0 -m 'LearnHarness 0.5.0' git tag -a v0.5.1 -m 'LearnHarness 0.5.1'
git push gitea main v0.5.0 git push gitea main v0.5.1
``` ```
Supply `GITEA_TOKEN` only to the upload command. A token file may remain outside Supply `GITEA_TOKEN` only to the upload command. A token file may remain outside
@@ -117,10 +117,10 @@ 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 tag, uploads missing assets, and downloads every attachment to verify its actual
SHA-256 against the local verified build. Existing mismatched attachments and SHA-256 against the local verified build. Existing mismatched attachments and
published releases are refused. It never publishes or changes repository 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.1.md`.
This uploader and its remote verifier enforce a **Linux-only** inventory. They 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.1 release, add
the verified Windows attachments to the existing draft and replace `SHA256SUMS` the verified Windows attachments to the existing draft and replace `SHA256SUMS`
with the combined manifest. Keep the Linux installers and feed unchanged. Check with the combined manifest. Keep the Linux installers and feed unchanged. Check
all seven attachment names, sizes, and actual downloaded hashes against the all seven attachment names, sizes, and actual downloaded hashes against the
@@ -142,12 +142,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 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, 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 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.1 was prepared, so its packages are built locally. A tag push
does not queue a job that cannot run. does not queue a job that cannot run.
The workflow uses the repository's ephemeral `GITEA_TOKEN` with release-write 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 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 persisting credentials and leaves the result as a verified draft. Public pull
requests cannot trigger this publishing workflow. This workflow remains 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.1 draft was assembled from local native builds.
Windows has its own standalone payload and smoke checks, as described above. 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 не подписан, как и в предыдущем выпуске.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "learnharness", "name": "learnharness",
"version": "0.5.0", "version": "0.5.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "learnharness", "name": "learnharness",
"version": "0.5.0", "version": "0.5.1",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@earendil-works/pi-agent-core": "1.4.0", "@earendil-works/pi-agent-core": "1.4.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "learnharness", "name": "learnharness",
"version": "0.5.0", "version": "0.5.1",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"author": { "author": {
"name": "Amos Blomqvist", "name": "Amos Blomqvist",
+6 -1
View File
@@ -2,9 +2,11 @@
import en from "./locales/en.json"; import en from "./locales/en.json";
import ru from "./locales/ru.json"; import ru from "./locales/ru.json";
export type Language = "en" | "ru"; export type Language = "en" | "ru";
export type SttLanguage = Language | "multi";
export type TranslationParams = Record<string, string | number | null | undefined>; export type TranslationParams = Record<string, string | number | null | undefined>;
export type Translator = (message: string | null | undefined, params?: TranslationParams) => string; 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 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. */ /** The preferred system language wins; unsupported preferences resolve to English. */
export function systemLanguage(languages: string | readonly string[] | null | undefined): Language { 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 { export function resolveLanguage(preference: unknown, system?: string | readonly string[] | null): Language {
return isLanguage(preference) ? preference : systemLanguage(system); 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"; } export function localeTag(language: Language): string { return language === "ru" ? "ru-RU" : "en-US"; }
type Entry = string | Partial<Record<Intl.LDMLPluralRule, string>>; type Entry = string | Partial<Record<Intl.LDMLPluralRule, string>>;
@@ -58,7 +63,7 @@ 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: Language): string { export function dictationUrl(language: SttLanguage): 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" });
return `wss://api.deepgram.com/v1/listen?${params}`; return `wss://api.deepgram.com/v1/listen?${params}`;
} }
+3
View File
@@ -249,6 +249,8 @@
"Speech recognition language": "Speech recognition language", "Speech recognition language": "Speech recognition language",
"Auto": "Auto", "Auto": "Auto",
"System default": "System default", "System default": "System default",
"Multilingual": "Multilingual",
"Multi": "Multi",
"Global": "Global", "Global": "Global",
"Chat": "Chat", "Chat": "Chat",
"Graph view": "Graph view", "Graph view": "Graph view",
@@ -723,6 +725,7 @@
"thinkingLevel must be a string": "thinkingLevel must be a string", "thinkingLevel must be a string": "thinkingLevel must be a string",
"autoUpdate must be a boolean": "autoUpdate must be a boolean", "autoUpdate must be a boolean": "autoUpdate must be a boolean",
"{v0} must be \"en\" or \"ru\"": "{v0} must be \"en\" or \"ru\"", "{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} must be an object": "{v0} must be an object",
"{v0}.model must be {provider, id}": "{v0}.model must be {provider, id}", "{v0}.model must be {provider, id}": "{v0}.model must be {provider, id}",
"{v0}.thinkingLevel must be a string": "{v0}.thinkingLevel must be a string", "{v0}.thinkingLevel must be a string": "{v0}.thinkingLevel must be a string",
+3
View File
@@ -249,6 +249,8 @@
"Speech recognition language": "Язык распознавания речи", "Speech recognition language": "Язык распознавания речи",
"Auto": "Авто", "Auto": "Авто",
"System default": "Язык системы", "System default": "Язык системы",
"Multilingual": "Несколько языков",
"Multi": "Мульти",
"Global": "Общие", "Global": "Общие",
"Chat": "Занятие", "Chat": "Занятие",
"Graph view": "Граф", "Graph view": "Граф",
@@ -754,6 +756,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} должен иметь значение «en» или «ru»", "{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} must be an object": "Параметр {v0} должен быть объектом",
"{v0}.model must be {provider, id}": "Поле {v0}.model должно содержать provider и id", "{v0}.model must be {provider, id}": "Поле {v0}.model должно содержать provider и id",
"{v0}.thinkingLevel must be a string": "Поле {v0}.thinkingLevel должно быть строкой", "{v0}.thinkingLevel must be a string": "Поле {v0}.thinkingLevel должно быть строкой",
+6 -6
View File
@@ -32,6 +32,7 @@ import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts";
import type { ModelRole } from "../../config.ts"; import type { ModelRole } from "../../config.ts";
import type { ChatSettings } from "./contracts/chat.ts"; import type { ChatSettings } from "./contracts/chat.ts";
import type { SearchEngineId } from "./search.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 /** The schema version this code understands. Bump ONLY for breaking shape
* changes (a field renamed/meaning changed); pure additions never bump. */ * changes (a field renamed/meaning changed); pure additions never bump. */
@@ -236,9 +237,9 @@ export interface GeneralSettings {
/** Auto-update the app (default ON). Mechanism ships in a later wave. */ /** Auto-update the app (default ON). Mechanism ships in a later wave. */
autoUpdate?: boolean; autoUpdate?: boolean;
/** Absent = browser/system language (English for unsupported languages). */ /** Absent = browser/system language (English for unsupported languages). */
language?: "en" | "ru"; language?: Language;
/** Independent dictation language; absent = browser/system language. */ /** Independent dictation language; multi = multilingual, absent = browser/system language. */
sttLanguage?: "en" | "ru"; sttLanguage?: SttLanguage;
} }
/** Agent settings — everything about the non-conversation machinery. */ /** Agent settings — everything about the non-conversation machinery. */
@@ -447,9 +448,8 @@ function sanitizeGeneral(raw: Record<string, unknown>): GeneralSettings {
const out: GeneralSettings = {}; const out: GeneralSettings = {};
const autoUpdate = boolOrDrop(raw.autoUpdate); const autoUpdate = boolOrDrop(raw.autoUpdate);
if (autoUpdate !== undefined) out.autoUpdate = autoUpdate; if (autoUpdate !== undefined) out.autoUpdate = autoUpdate;
for (const key of ["language", "sttLanguage"] as const) { if (isLanguage(raw.language)) out.language = raw.language;
if (raw[key] === "en" || raw[key] === "ru") out[key] = raw[key]; if (isSttLanguage(raw.sttLanguage)) out.sttLanguage = raw.sttLanguage;
}
return out; return out;
} }
+9 -7
View File
@@ -12,7 +12,7 @@
*/ */
import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; 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 { import type {
AgentSettings, AgentSettings,
AppearanceSettings, AppearanceSettings,
@@ -75,7 +75,7 @@ function asRecord(body: unknown): Record<string, unknown> | null {
return body as Record<string, unknown>; return body as Record<string, unknown>;
} }
/** general: { autoUpdate?: boolean } */ /** Independent interface/STT language choices and automatic updates. */
export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<GeneralSettings> { export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<GeneralSettings> {
const raw = asRecord(body); const raw = asRecord(body);
if (!raw) return { ok: false, error: "Invalid JSON body" }; if (!raw) return { ok: false, error: "Invalid JSON body" };
@@ -86,11 +86,13 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<Gene
} }
settings.autoUpdate = raw.autoUpdate; settings.autoUpdate = raw.autoUpdate;
} }
for (const key of ["language", "sttLanguage"] as const) { if (raw.language !== undefined) {
if (raw[key] !== undefined) { if (!isLanguage(raw.language)) return { ok: false, error: 'language must be "en" or "ru"' };
if (raw[key] !== "en" && raw[key] !== "ru") return { ok: false, error: `${key} must be "en" or "ru"` }; settings.language = raw.language;
settings[key] = raw[key]; }
} if (raw.sttLanguage !== undefined) {
if (!isSttLanguage(raw.sttLanguage)) return { ok: false, error: 'sttLanguage must be "en", "ru", or "multi"' };
settings.sttLanguage = raw.sttLanguage;
} }
return { ok: true, settings }; return { ok: true, settings };
} }
+25 -6
View File
@@ -7,7 +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 { countLabel, dictationUrl, resolveLanguage, 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";
import { I18nProvider, LanguageSelect } from "../components/i18n.tsx"; import { I18nProvider, LanguageSelect } from "../components/i18n.tsx";
@@ -29,6 +29,15 @@ describe("language preferences", () => {
assert.equal(resolveLanguage("en", "ru-RU"), "en"); assert.equal(resolveLanguage("en", "ru-RU"), "en");
assert.equal(resolveLanguage("ru", "en-US"), "ru"); 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", () => { it("validates both independent preferences without changing existing general options", () => {
const general = { autoUpdate: false, language: "ru", sttLanguage: "en" }; const general = { autoUpdate: false, language: "ru", sttLanguage: "en" };
assert.deepEqual(parseGeneralSettingsBody(general), { ok: true, settings: general }); assert.deepEqual(parseGeneralSettingsBody(general), { ok: true, settings: general });
@@ -38,15 +47,25 @@ describe("language preferences", () => {
assert.equal(parseGeneralSettingsBody({ sttLanguage: "fr" }).ok, false); assert.equal(parseGeneralSettingsBody({ sttLanguage: "fr" }).ok, false);
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { language: null, sttLanguage: "fr", autoUpdate: false } })?.general, { autoUpdate: 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", () => { 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)); const url = new URL(dictationUrl(language));
assert.equal(url.protocol, "wss:"); assert.equal(url.protocol, "wss:");
assert.equal(url.hostname, "api.deepgram.com"); assert.equal(url.hostname, "api.deepgram.com");
assert.equal(url.searchParams.get("language"), language); assert.deepEqual(Object.fromEntries(url.searchParams), {
assert.equal(url.searchParams.get("model"), "nova-3"); model: "nova-3", language, encoding: "linear16", sample_rate: "16000",
assert.equal(url.searchParams.get("encoding"), "linear16"); channels: "1", smart_format: "true", punctuate: "true", endpointing: "300",
assert.equal(url.searchParams.get("sample_rate"), "16000"); });
} }
}); });
}); });
+66 -8
View File
@@ -6,7 +6,7 @@ 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, 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 { 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>"); const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { Object.assign(globalThis, {
@@ -27,6 +27,7 @@ let writes: GeneralSettings[];
let failSave = false; let failSave = false;
let holdSave: (() => Promise<void>) | undefined; let holdSave: (() => Promise<void>) | undefined;
let recording: (active: boolean) => void; let recording: (active: boolean) => void;
let resolvedSttLanguage: SttLanguage;
function element(selector: string): HTMLElement { function element(selector: string): HTMLElement {
const found = document.querySelector<HTMLElement>(selector); const found = document.querySelector<HTMLElement>(selector);
@@ -45,8 +46,8 @@ async function pick(text: string) {
assert(option, `expected option ${text}`); assert(option, `expected option ${text}`);
await click(option); await click(option);
} }
async function mount() { async function mount(initialGeneral: GeneralSettings = { language: "ru", sttLanguage: "en", autoUpdate: false }) {
general = { language: "ru", sttLanguage: "en", autoUpdate: false }; general = initialGeneral;
writes = []; failSave = false; holdSave = undefined; writes = []; failSave = false; holdSave = undefined;
globalThis.fetch = async (input, init) => { globalThis.fetch = async (input, init) => {
const url = String(input); const url = String(input);
@@ -65,6 +66,7 @@ async function mount() {
host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host); host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host);
function Controls() { function Controls() {
recording = useI18n().setDictating; recording = useI18n().setDictating;
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 /></>;
} }
await act(async () => { root!.render(<I18nProvider initialGeneral={general}><Controls /></I18nProvider>); }); 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(); await mount();
assert.equal(document.querySelector("select"), null); assert.equal(document.querySelector("select"), null);
await click(trigger("sttLanguage")); 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(element('[role="menuitemradio"][aria-checked="true"]').textContent?.includes("English"));
assert(document.querySelector('[role="menu"]')?.textContent?.includes("Язык системы")); assert(document.querySelector('[role="menu"]')?.textContent?.includes("Язык системы"));
await pick("Русский"); 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 }); 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 () => { it("dismisses outside clicks and Escape, and opening another selector balances menu scroll locks", async () => {
await mount(); await mount();
let opened = 0; 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 () => { 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)); await act(async () => recording(true));
assert.equal(document.querySelector('[role="menu"]'), null); assert.equal(document.querySelector('[role="menu"]'), null);
assert((trigger("sttLanguage") as HTMLButtonElement).disabled); 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(!(trigger("sttLanguage", true) as HTMLButtonElement).disabled);
assert.equal(trigger("sttLanguage", true).getAttribute("aria-expanded"), "false"); assert.equal(trigger("sttLanguage", true).getAttribute("aria-expanded"), "false");
assert.equal(writes.length, 0); assert.equal(writes.length, 0);
assert.equal(resolvedSttLanguage, "multi");
}); });
it("shows a localized save failure without changing the selected language and allows retry", async () => { it("shows a localized save failure without changing the selected language and allows retry", async () => {
await mount(); failSave = true; 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(element('[role="alert"]').textContent, translate("ru", "Could not save language settings"));
assert.equal(general.sttLanguage, "en"); assert(trigger("sttLanguage").textContent?.includes("English")); assert.equal(general.sttLanguage, "en"); assert(trigger("sttLanguage").textContent?.includes("English"));
failSave = false; failSave = false;
await click(trigger("sttLanguage")); await pick("Русский"); await click(trigger("sttLanguage")); await pick("Несколько языков");
assert.equal(general.sttLanguage, "ru"); assert.equal(document.querySelector('[role="alert"]'), null); assert.equal(general.sttLanguage, "multi"); assert.equal(document.querySelector('[role="alert"]'), null);
}); });
it("serializes rapid picks in independent selectors without losing either preference", async () => { it("serializes rapid picks in independent selectors without losing either preference", async () => {
+6
View File
@@ -48,6 +48,12 @@ describe("the settings store", () => {
assert.ok(out); assert.ok(out);
assert.deepEqual(out.graph, { enabled: false }); assert.deepEqual(out.graph, { enabled: false });
}); });
it("persists multilingual STT independently of the interface language and update preference", () => {
const general = { language: "ru", sttLanguage: "multi", autoUpdate: false } as const;
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)", () => { 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 }); fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });