Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70977bad84 | ||
|
|
fdd63b27a9 | ||
|
|
bdd7d5e866 |
@@ -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.1
|
||||
# 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.1)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
|
||||
@@ -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, type SttLanguage } from "@/src/core";
|
||||
import { LanguageSelect, useI18n } from "@/components/i18n";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
|
||||
@@ -282,7 +282,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
const { sttLanguage, setDictating } = useI18n();
|
||||
const selectedLanguage = useRef(sttLanguage);
|
||||
selectedLanguage.current = sttLanguage;
|
||||
const recordingLanguage = useRef<Language>(sttLanguage);
|
||||
const recordingLanguage = useRef<SttLanguage>(sttLanguage);
|
||||
const [phase, setPhase] = useState<DictationPhase>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
|
||||
|
||||
+14
-10
@@ -1,19 +1,20 @@
|
||||
"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";
|
||||
|
||||
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 +29,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));
|
||||
@@ -70,7 +71,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 +85,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 +118,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 +134,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">
|
||||
|
||||
@@ -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.1 release on Gitea, see [Gitea releases](gitea-release.md).
|
||||
The three-platform GitHub process below documents the upstream reference.
|
||||
|
||||
## Platforms
|
||||
|
||||
+15
-15
@@ -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.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
|
||||
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.1 has these attachments:
|
||||
|
||||
- `LearnHarness-0.5.0-x86_64.AppImage`
|
||||
- `LearnHarness-0.5.0-amd64.deb`
|
||||
- `LearnHarness-0.5.1-x86_64.AppImage`
|
||||
- `LearnHarness-0.5.1-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.1` 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.1`, 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.1-x64-setup.exe`
|
||||
- `LearnHarness-0.5.1-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.1`, 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.1 -m 'LearnHarness 0.5.1'
|
||||
git push gitea main v0.5.1
|
||||
```
|
||||
|
||||
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
|
||||
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.1.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.1 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
|
||||
@@ -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
|
||||
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.1 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.1 draft was assembled from local native builds.
|
||||
Windows has its own standalone payload and smoke checks, as described above.
|
||||
|
||||
@@ -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 не подписан, как и в предыдущем выпуске.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "learnharness",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "learnharness",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "1.4.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "learnharness",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"license": "AGPL-3.0-only",
|
||||
"author": {
|
||||
"name": "Amos Blomqvist",
|
||||
|
||||
+6
-1
@@ -2,9 +2,11 @@
|
||||
import en from "./locales/en.json";
|
||||
import ru from "./locales/ru.json";
|
||||
export type Language = "en" | "ru";
|
||||
export type SttLanguage = Language | "multi";
|
||||
export type TranslationParams = Record<string, string | number | null | undefined>;
|
||||
export type Translator = (message: string | null | undefined, params?: TranslationParams) => string;
|
||||
export function isLanguage(value: unknown): value is Language { return value === "en" || value === "ru"; }
|
||||
export function isSttLanguage(value: unknown): value is SttLanguage { return isLanguage(value) || value === "multi"; }
|
||||
|
||||
/** The preferred system language wins; unsupported preferences resolve to English. */
|
||||
export function systemLanguage(languages: string | readonly string[] | null | undefined): Language {
|
||||
@@ -20,6 +22,9 @@ export function systemLanguage(languages: string | readonly string[] | null | un
|
||||
export function resolveLanguage(preference: unknown, system?: string | readonly string[] | null): Language {
|
||||
return isLanguage(preference) ? preference : systemLanguage(system);
|
||||
}
|
||||
export function resolveSttLanguage(preference: unknown, system?: string | readonly string[] | null): SttLanguage {
|
||||
return isSttLanguage(preference) ? preference : systemLanguage(system);
|
||||
}
|
||||
export function localeTag(language: Language): string { return language === "ru" ? "ru-RU" : "en-US"; }
|
||||
|
||||
type Entry = string | Partial<Record<Intl.LDMLPluralRule, string>>;
|
||||
@@ -58,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.";
|
||||
}
|
||||
/** 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" });
|
||||
return `wss://api.deepgram.com/v1/listen?${params}`;
|
||||
}
|
||||
|
||||
@@ -249,6 +249,8 @@
|
||||
"Speech recognition language": "Speech recognition language",
|
||||
"Auto": "Auto",
|
||||
"System default": "System default",
|
||||
"Multilingual": "Multilingual",
|
||||
"Multi": "Multi",
|
||||
"Global": "Global",
|
||||
"Chat": "Chat",
|
||||
"Graph view": "Graph view",
|
||||
@@ -723,6 +725,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",
|
||||
|
||||
@@ -249,6 +249,8 @@
|
||||
"Speech recognition language": "Язык распознавания речи",
|
||||
"Auto": "Авто",
|
||||
"System default": "Язык системы",
|
||||
"Multilingual": "Несколько языков",
|
||||
"Multi": "Мульти",
|
||||
"Global": "Общие",
|
||||
"Chat": "Занятие",
|
||||
"Graph view": "Граф",
|
||||
@@ -754,6 +756,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 должно быть строкой",
|
||||
|
||||
@@ -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,9 @@ 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;
|
||||
}
|
||||
|
||||
/** Agent settings — everything about the non-conversation machinery. */
|
||||
@@ -447,9 +448,8 @@ 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;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,13 @@ 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;
|
||||
}
|
||||
return { ok: true, settings };
|
||||
}
|
||||
|
||||
+25
-6
@@ -7,7 +7,7 @@ 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 { 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 +29,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 +47,25 @@ 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { I18nProvider, LanguageSelect, 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,6 +66,7 @@ async function mount() {
|
||||
host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host);
|
||||
function Controls() {
|
||||
recording = useI18n().setDictating;
|
||||
resolvedSttLanguage = useI18n().sttLanguage;
|
||||
return <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /></>;
|
||||
}
|
||||
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 () => {
|
||||
|
||||
@@ -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", 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)", () => {
|
||||
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
|
||||
|
||||
Reference in New Issue
Block a user