Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
168cad7470 | ||
|
|
ce28d95bcf |
@@ -1,12 +1,12 @@
|
|||||||
name: Linux desktop release
|
name: Linux desktop release
|
||||||
|
|
||||||
# Manual until a native Linux runner is registered. The current 0.5.1
|
# Manual until a native Linux runner is registered. The current 0.5.2
|
||||||
# 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.1)
|
description: Existing stable version tag to package (for example v0.5.2)
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Loader2Icon, MicIcon } from "lucide-react";
|
import { Loader2Icon, MicIcon } from "lucide-react";
|
||||||
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
||||||
import { dictationUrl, type SttLanguage } from "@/src/core";
|
import { dictationUrl } from "@/src/core";
|
||||||
import { LanguageSelect, useI18n } from "@/components/i18n";
|
import { LanguageSelect, useI18n } from "@/components/i18n";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
|
|
||||||
@@ -279,10 +279,10 @@ export type Dictation = {
|
|||||||
* question waits.
|
* question waits.
|
||||||
*/
|
*/
|
||||||
export function useDictation({ append }: { append: (text: string) => void }): Dictation {
|
export function useDictation({ append }: { append: (text: string) => void }): Dictation {
|
||||||
const { sttLanguage, setDictating } = useI18n();
|
const { sttLanguage, general, setDictating } = useI18n();
|
||||||
const selectedLanguage = useRef(sttLanguage);
|
const selectedUrl = useRef("");
|
||||||
selectedLanguage.current = sttLanguage;
|
selectedUrl.current = dictationUrl(sttLanguage, general.sttPreferredLanguages);
|
||||||
const recordingLanguage = useRef<SttLanguage>(sttLanguage);
|
const recordingUrl = useRef(selectedUrl.current);
|
||||||
const [phase, setPhase] = useState<DictationPhase>("idle");
|
const [phase, setPhase] = useState<DictationPhase>("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
|
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
|
||||||
@@ -365,7 +365,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
// Minted JWTs authenticate via the "bearer" subprotocol — ["token",
|
// Minted JWTs authenticate via the "bearer" subprotocol — ["token",
|
||||||
// jwt] is rejected at the handshake (verified live; "token" is for
|
// jwt] is rejected at the handshake (verified live; "token" is for
|
||||||
// raw API keys only, as in the owner's pi dictate extension).
|
// raw API keys only, as in the owner's pi dictate extension).
|
||||||
const ws = new WebSocket(dictationUrl(recordingLanguage.current), ["bearer", token]);
|
const ws = new WebSocket(recordingUrl.current, ["bearer", token]);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.addEventListener("open", () => {
|
ws.addEventListener("open", () => {
|
||||||
@@ -425,7 +425,8 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
);
|
);
|
||||||
|
|
||||||
const start = useCallback(async () => {
|
const start = useCallback(async () => {
|
||||||
recordingLanguage.current = selectedLanguage.current;
|
// Pin the complete configuration once; reconnects must retain both language and hints.
|
||||||
|
recordingUrl.current = selectedUrl.current;
|
||||||
setDictating(true);
|
setDictating(true);
|
||||||
dictationActiveFlag = true;
|
dictationActiveFlag = true;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
+30
-1
@@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useR
|
|||||||
import { resolveLanguage, resolveSttLanguage, translator, type GeneralSettings, type Language, type SttLanguage, type Translator } from "@/src/core";
|
import { resolveLanguage, resolveSttLanguage, translator, type GeneralSettings, type Language, type SttLanguage, type Translator } from "@/src/core";
|
||||||
import { notifySettingsSaved, useSettingsSaved } from "./settings-signal";
|
import { notifySettingsSaved, useSettingsSaved } from "./settings-signal";
|
||||||
import { MenuShell } from "./menu-shell";
|
import { MenuShell } from "./menu-shell";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
|
||||||
type LanguageField = "language" | "sttLanguage";
|
type LanguageField = "language" | "sttLanguage";
|
||||||
type I18nValue = {
|
type I18nValue = {
|
||||||
@@ -57,7 +58,7 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: {
|
|||||||
if (!current.ok) throw new Error("Could not load language settings");
|
if (!current.ok) throw new Error("Could not load language settings");
|
||||||
const body = await current.json() as { settings?: { general?: GeneralSettings } };
|
const body = await current.json() as { settings?: { general?: GeneralSettings } };
|
||||||
const next = { ...(body.settings?.general ?? latest.current) };
|
const next = { ...(body.settings?.general ?? latest.current) };
|
||||||
for (const field of ["language", "sttLanguage", "autoUpdate"] as const) {
|
for (const field of ["language", "sttLanguage", "sttPreferredLanguages", "autoUpdate"] as const) {
|
||||||
if (!Object.hasOwn(patch, field)) continue;
|
if (!Object.hasOwn(patch, field)) continue;
|
||||||
if (patch[field] === undefined) delete next[field];
|
if (patch[field] === undefined) delete next[field];
|
||||||
else Object.assign(next, { [field]: patch[field] });
|
else Object.assign(next, { [field]: patch[field] });
|
||||||
@@ -148,3 +149,31 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
|
|||||||
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
|
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
|
||||||
</span>;
|
</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Preferred languages guide multilingual recognition; they are saved independently of its mode. */
|
||||||
|
export function SttPreferredLanguages() {
|
||||||
|
const { general, sttLanguage, dictating, saveGeneral, t } = useI18n();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const preferred = general.sttPreferredLanguages ?? [];
|
||||||
|
const locked = sttLanguage !== "multi" || dictating || saving;
|
||||||
|
const toggle = (language: Language) => {
|
||||||
|
if (locked) return;
|
||||||
|
const next = (["en", "ru"] as const).filter((code) => code === language ? !preferred.includes(code) : preferred.includes(code));
|
||||||
|
setSaving(true); setError(null);
|
||||||
|
void saveGeneral({ sttPreferredLanguages: next })
|
||||||
|
.catch(() => setError("Could not save preferred languages"))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
return <span className="flex max-w-full flex-col items-start gap-1">
|
||||||
|
<span role="group" aria-label={t("Preferred languages")} className="flex max-w-full flex-wrap gap-2">
|
||||||
|
{(["en", "ru"] as const).map((language) => <button key={language} type="button" role="checkbox" aria-checked={preferred.includes(language)}
|
||||||
|
data-testid={`stt-preferred-${language}`} disabled={locked} onClick={() => toggle(language)}
|
||||||
|
className={`flex h-7 items-center gap-1.5 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${preferred.includes(language) ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50"}`}>
|
||||||
|
<span aria-hidden className="flex size-3.5 shrink-0 items-center justify-center rounded-xs border border-border">{preferred.includes(language) && <CheckIcon className="size-3" />}</span>
|
||||||
|
{language === "en" ? "English" : "Русский"}
|
||||||
|
</button>)}
|
||||||
|
</span>
|
||||||
|
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -666,49 +666,51 @@ export function AgentSection({
|
|||||||
label={SLOT_META[slot].label}
|
label={SLOT_META[slot].label}
|
||||||
hint={SLOT_META[slot].roster}
|
hint={SLOT_META[slot].roster}
|
||||||
testid={`slot-row-${slot}`}
|
testid={`slot-row-${slot}`}
|
||||||
className="flex-wrap gap-y-2"
|
className="flex-wrap items-start gap-y-2 [&>span:first-child]:flex-[1_1_12rem]"
|
||||||
>
|
>
|
||||||
{/* The anchor for the outside-click contract is the
|
{/* The anchor for the outside-click contract is the
|
||||||
span holding BOTH triggers of the open menu. */}
|
span holding the tier's controls. */}
|
||||||
<span
|
<span
|
||||||
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
||||||
className="relative ml-auto flex max-w-full flex-wrap items-center justify-end gap-2"
|
className="relative flex w-68 max-w-full shrink-0 flex-col items-start gap-2"
|
||||||
>
|
>
|
||||||
|
<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} /></>}
|
{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" && (
|
{thisMenuOpen === "model" && (
|
||||||
<MenuShell
|
<MenuShell
|
||||||
preferredSide="down"
|
preferredSide="down"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
type GraphSettings,
|
type GraphSettings,
|
||||||
type UserSettings,
|
type UserSettings,
|
||||||
} from "@/src/core";
|
} from "@/src/core";
|
||||||
import { LanguageSelect, useI18n } from "@/components/i18n";
|
import { LanguageSelect, SttPreferredLanguages, useI18n } from "@/components/i18n";
|
||||||
import { useAppearance } from "./appearance.tsx";
|
import { useAppearance } from "./appearance.tsx";
|
||||||
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
|
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
|
||||||
import { AgentSection } from "./agent-section.tsx";
|
import { AgentSection } from "./agent-section.tsx";
|
||||||
@@ -368,6 +368,9 @@ export function SettingsDialogBody({
|
|||||||
>
|
>
|
||||||
<SettingsRow label={t("Interface language")} testid="row-language"><LanguageSelect field="language" /></SettingsRow>
|
<SettingsRow label={t("Interface language")} testid="row-language"><LanguageSelect field="language" /></SettingsRow>
|
||||||
<SettingsRow label={t("Speech recognition language")} testid="row-stt-language"><LanguageSelect field="sttLanguage" /></SettingsRow>
|
<SettingsRow label={t("Speech recognition language")} testid="row-stt-language"><LanguageSelect field="sttLanguage" /></SettingsRow>
|
||||||
|
<SettingsRow label={t("Preferred languages")} hint={t("Used with Multilingual. Favors selected languages; other languages may still be recognized.")} testid="row-stt-preferred-languages" className="flex-col items-start gap-2">
|
||||||
|
<SttPreferredLanguages />
|
||||||
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label={t("Auto-update")}
|
label={t("Auto-update")}
|
||||||
hint={AUTO_UPDATE_HINT}
|
hint={AUTO_UPDATE_HINT}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Desktop builds and releases
|
# Desktop builds and releases
|
||||||
|
|
||||||
For this fork’s Linux and Windows 0.5.1 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.
|
The three-platform GitHub process below documents the upstream reference.
|
||||||
|
|
||||||
## Platforms
|
## Platforms
|
||||||
|
|||||||
+21
-18
@@ -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.1 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
|
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.1 has these attachments:
|
The Linux portion of 0.5.2 has these attachments:
|
||||||
|
|
||||||
- `LearnHarness-0.5.1-x86_64.AppImage`
|
- `LearnHarness-0.5.2-x86_64.AppImage`
|
||||||
- `LearnHarness-0.5.1-amd64.deb`
|
- `LearnHarness-0.5.2-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.1` 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
|
for Windows. Keep build tools and logs outside the checkout. The Windows
|
||||||
release checkout is `~/Documents/Gitea/learnharness-release-0.5.1`, linked to the
|
release checkout is `~/Documents/Gitea/learnharness-release-0.5.2`, 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.1-x64-setup.exe`
|
- `LearnHarness-0.5.2-x64-setup.exe`
|
||||||
- `LearnHarness-0.5.1-x64-setup.exe.blockmap`
|
- `LearnHarness-0.5.2-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.1`, 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
|
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.1 -m 'LearnHarness 0.5.1'
|
git tag -a v0.5.2 -m 'LearnHarness 0.5.2'
|
||||||
git push gitea main v0.5.1
|
git push gitea main v0.5.2
|
||||||
```
|
```
|
||||||
|
|
||||||
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,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
|
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.1.md`.
|
visibility. Release notes come from `docs/release-0.5.2.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.1 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`
|
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 and sizes against the verified local builds. For
|
||||||
verified local builds; update the bilingual release notes to include Windows.
|
the owner-approved 0.5.2 release, local installer/feed/blockmap validation and
|
||||||
Do not publish until both platforms' checks pass.
|
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:
|
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
|
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.1 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.
|
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.1 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.
|
Windows has its own standalone payload and smoke checks, as described above.
|
||||||
|
|||||||
@@ -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 не подписан, как и в предыдущих выпусках.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Amos Blomqvist",
|
"name": "Amos Blomqvist",
|
||||||
|
|||||||
+4
-1
@@ -63,8 +63,11 @@ export function learningLanguageInstruction(language: Language): string {
|
|||||||
: "LANGUAGE: Use English for all learner-facing content, including teaching, questions and choices, plans, titles, practice, research summaries, knowledge-graph propositions, and text in generated diagrams, figures and demos. Preserve code, notation, identifiers, proper names and quoted source text. Follow an explicit learner request for a different language or a language-learning exercise when necessary.";
|
: "LANGUAGE: Use English for all learner-facing content, including teaching, questions and choices, plans, titles, practice, research summaries, knowledge-graph propositions, and text in generated diagrams, figures and demos. Preserve code, notation, identifiers, proper names and quoted source text. Follow an explicit learner request for a different language or a language-learning exercise when necessary.";
|
||||||
}
|
}
|
||||||
/** Streaming configuration: the language is pinned once at recording start, including reconnects. */
|
/** Streaming configuration: the language is pinned once at recording start, including reconnects. */
|
||||||
export function dictationUrl(language: SttLanguage): string {
|
export function dictationUrl(language: SttLanguage, preferredLanguages: readonly Language[] = []): string {
|
||||||
const params = new URLSearchParams({ model: "nova-3", language, encoding: "linear16", sample_rate: "16000", channels: "1", smart_format: "true", punctuate: "true", endpointing: "300" });
|
const params = new URLSearchParams({ model: "nova-3", language, encoding: "linear16", sample_rate: "16000", channels: "1", smart_format: "true", punctuate: "true", endpointing: "300" });
|
||||||
|
if (language === "multi") {
|
||||||
|
for (const preferred of new Set(preferredLanguages)) if (isLanguage(preferred)) params.append("language_hint", preferred);
|
||||||
|
}
|
||||||
return `wss://api.deepgram.com/v1/listen?${params}`;
|
return `wss://api.deepgram.com/v1/listen?${params}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,10 @@
|
|||||||
"System default": "System default",
|
"System default": "System default",
|
||||||
"Multilingual": "Multilingual",
|
"Multilingual": "Multilingual",
|
||||||
"Multi": "Multi",
|
"Multi": "Multi",
|
||||||
|
"Preferred languages": "Preferred languages",
|
||||||
|
"Used with Multilingual. Favors selected languages; other languages may still be recognized.": "Used with Multilingual. Favors selected languages; other languages may still be recognized.",
|
||||||
|
"Could not save preferred languages": "Could not save preferred languages",
|
||||||
|
"sttPreferredLanguages must be an array of \"en\" and \"ru\"": "sttPreferredLanguages must be an array of \"en\" and \"ru\"",
|
||||||
"Global": "Global",
|
"Global": "Global",
|
||||||
"Chat": "Chat",
|
"Chat": "Chat",
|
||||||
"Graph view": "Graph view",
|
"Graph view": "Graph view",
|
||||||
|
|||||||
@@ -251,6 +251,10 @@
|
|||||||
"System default": "Язык системы",
|
"System default": "Язык системы",
|
||||||
"Multilingual": "Несколько языков",
|
"Multilingual": "Несколько языков",
|
||||||
"Multi": "Мульти",
|
"Multi": "Мульти",
|
||||||
|
"Preferred languages": "Предпочитаемые языки",
|
||||||
|
"Used with Multilingual. Favors selected languages; other languages may still be recognized.": "Используется в режиме «Несколько языков». Выбранные языки получают приоритет, но могут распознаваться и другие.",
|
||||||
|
"Could not save preferred languages": "Не удалось сохранить предпочитаемые языки",
|
||||||
|
"sttPreferredLanguages must be an array of \"en\" and \"ru\"": "Предпочитаемые языки должны быть списком кодов «en» и «ru»",
|
||||||
"Global": "Общие",
|
"Global": "Общие",
|
||||||
"Chat": "Занятие",
|
"Chat": "Занятие",
|
||||||
"Graph view": "Граф",
|
"Graph view": "Граф",
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
|
|||||||
autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate,
|
autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate,
|
||||||
...(record?.general?.language ? { language: record.general.language } : {}),
|
...(record?.general?.language ? { language: record.general.language } : {}),
|
||||||
...(record?.general?.sttLanguage ? { sttLanguage: record.general.sttLanguage } : {}),
|
...(record?.general?.sttLanguage ? { sttLanguage: record.general.sttLanguage } : {}),
|
||||||
|
...(record?.general?.sttPreferredLanguages ? { sttPreferredLanguages: [...record.general.sttPreferredLanguages] } : {}),
|
||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
models,
|
models,
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ export interface GeneralSettings {
|
|||||||
language?: Language;
|
language?: Language;
|
||||||
/** Independent dictation language; multi = multilingual, absent = browser/system language. */
|
/** Independent dictation language; multi = multilingual, absent = browser/system language. */
|
||||||
sttLanguage?: SttLanguage;
|
sttLanguage?: SttLanguage;
|
||||||
|
/** Optional recognition hints for multilingual dictation; absent/empty = automatic detection. */
|
||||||
|
sttPreferredLanguages?: Language[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Agent settings — everything about the non-conversation machinery. */
|
/** Agent settings — everything about the non-conversation machinery. */
|
||||||
@@ -450,6 +452,7 @@ function sanitizeGeneral(raw: Record<string, unknown>): GeneralSettings {
|
|||||||
if (autoUpdate !== undefined) out.autoUpdate = autoUpdate;
|
if (autoUpdate !== undefined) out.autoUpdate = autoUpdate;
|
||||||
if (isLanguage(raw.language)) out.language = raw.language;
|
if (isLanguage(raw.language)) out.language = raw.language;
|
||||||
if (isSttLanguage(raw.sttLanguage)) out.sttLanguage = raw.sttLanguage;
|
if (isSttLanguage(raw.sttLanguage)) out.sttLanguage = raw.sttLanguage;
|
||||||
|
if (Array.isArray(raw.sttPreferredLanguages)) out.sttPreferredLanguages = [...new Set(raw.sttPreferredLanguages.filter(isLanguage))];
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<Gene
|
|||||||
if (!isSttLanguage(raw.sttLanguage)) return { ok: false, error: 'sttLanguage must be "en", "ru", or "multi"' };
|
if (!isSttLanguage(raw.sttLanguage)) return { ok: false, error: 'sttLanguage must be "en", "ru", or "multi"' };
|
||||||
settings.sttLanguage = raw.sttLanguage;
|
settings.sttLanguage = raw.sttLanguage;
|
||||||
}
|
}
|
||||||
|
if (raw.sttPreferredLanguages !== undefined) {
|
||||||
|
if (!Array.isArray(raw.sttPreferredLanguages) || !raw.sttPreferredLanguages.every(isLanguage)) return { ok: false, error: 'sttPreferredLanguages must be an array of "en" and "ru"' };
|
||||||
|
settings.sttPreferredLanguages = [...new Set(raw.sttPreferredLanguages)];
|
||||||
|
}
|
||||||
return { ok: true, settings };
|
return { ok: true, settings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
|||||||
import { createPracticeAgent } from "../src/agent/teacher-agent.ts";
|
import { createPracticeAgent } from "../src/agent/teacher-agent.ts";
|
||||||
import { sanitizeUserSettings } from "../src/core/settings.ts";
|
import { sanitizeUserSettings } from "../src/core/settings.ts";
|
||||||
import { parseGeneralSettingsBody } from "../src/server/settings-body.ts";
|
import { parseGeneralSettingsBody } from "../src/server/settings-body.ts";
|
||||||
|
import { resolveSettings } from "../src/core/settings-resolution.ts";
|
||||||
import { countLabel, dictationUrl, resolveLanguage, resolveSttLanguage, systemLanguage, translate } from "../src/core/i18n.ts";
|
import { countLabel, dictationUrl, resolveLanguage, resolveSttLanguage, systemLanguage, translate } from "../src/core/i18n.ts";
|
||||||
import { sessionMarkdown } from "../src/core/session-markdown.ts";
|
import { sessionMarkdown } from "../src/core/session-markdown.ts";
|
||||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
@@ -68,6 +69,35 @@ describe("language preferences", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
it("validates and resolves preferred speech languages without changing the recognition mode", () => {
|
||||||
|
const general = { language: "ru", sttLanguage: "multi", sttPreferredLanguages: ["ru", "en", "ru"], autoUpdate: false };
|
||||||
|
const expected = { ...general, sttPreferredLanguages: ["ru", "en"] };
|
||||||
|
const parsed = parseGeneralSettingsBody(general);
|
||||||
|
assert.deepEqual(parsed, { ok: true, settings: expected });
|
||||||
|
const record = sanitizeUserSettings({ version: 1, general });
|
||||||
|
assert.deepEqual(record?.general, expected);
|
||||||
|
assert.deepEqual(resolveSettings(record).general, expected);
|
||||||
|
assert.deepEqual(parseGeneralSettingsBody({ sttPreferredLanguages: [] }), { ok: true, settings: { sttPreferredLanguages: [] } });
|
||||||
|
for (const invalid of [null, "en", ["de"], ["multi"], ["en", 1], {}]) {
|
||||||
|
const result = parseGeneralSettingsBody({ sttPreferredLanguages: invalid });
|
||||||
|
assert(!result.ok);
|
||||||
|
assert.equal(translate("ru", result.error), "Предпочитаемые языки должны быть списком кодов «en» и «ru»");
|
||||||
|
}
|
||||||
|
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { sttPreferredLanguages: ["ru", "de", "en", null, "ru"] } })?.general, { sttPreferredLanguages: ["ru", "en"] });
|
||||||
|
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { sttPreferredLanguages: "ru" } })?.general, {});
|
||||||
|
});
|
||||||
|
it("sends repeated language hints only for multilingual recognition, with no hints by default", () => {
|
||||||
|
assert.deepEqual(new URL(dictationUrl("multi")).searchParams.getAll("language_hint"), []);
|
||||||
|
assert.deepEqual(new URL(dictationUrl("multi", [])).searchParams.getAll("language_hint"), []);
|
||||||
|
const url = new URL(dictationUrl("multi", ["en", "ru", "en"]));
|
||||||
|
assert.deepEqual(url.searchParams.getAll("language_hint"), ["en", "ru"]);
|
||||||
|
assert.equal(url.searchParams.get("language"), "multi");
|
||||||
|
url.searchParams.delete("language_hint");
|
||||||
|
assert.equal(url.toString(), dictationUrl("multi"), "hinting must preserve every existing streaming parameter");
|
||||||
|
for (const language of ["en", "ru"] as const) {
|
||||||
|
assert.equal(dictationUrl(language, ["en", "ru"]), dictationUrl(language));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("translation quality and rendering", () => {
|
describe("translation quality and rendering", () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import assert from "node:assert/strict";
|
|||||||
import { afterEach, it } from "node:test";
|
import { afterEach, it } from "node:test";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { parseHTML } from "linkedom";
|
import { parseHTML } from "linkedom";
|
||||||
import { I18nProvider, LanguageSelect, useI18n } from "../components/i18n.tsx";
|
import { I18nProvider, LanguageSelect, SttPreferredLanguages, useI18n } from "../components/i18n.tsx";
|
||||||
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "../components/menu-shell.tsx";
|
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "../components/menu-shell.tsx";
|
||||||
import { dictationUrl, translate, type GeneralSettings, type SttLanguage } from "../src/core/index.ts";
|
import { dictationUrl, translate, type GeneralSettings, type SttLanguage } from "../src/core/index.ts";
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ async function mount(initialGeneral: GeneralSettings = { language: "ru", sttLang
|
|||||||
function Controls() {
|
function Controls() {
|
||||||
recording = useI18n().setDictating;
|
recording = useI18n().setDictating;
|
||||||
resolvedSttLanguage = useI18n().sttLanguage;
|
resolvedSttLanguage = useI18n().sttLanguage;
|
||||||
return <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /></>;
|
return <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /><SttPreferredLanguages /></>;
|
||||||
}
|
}
|
||||||
await act(async () => { root!.render(<I18nProvider initialGeneral={general}><Controls /></I18nProvider>); });
|
await act(async () => { root!.render(<I18nProvider initialGeneral={general}><Controls /></I18nProvider>); });
|
||||||
}
|
}
|
||||||
@@ -201,3 +201,64 @@ it("serializes rapid picks in independent selectors without losing either prefer
|
|||||||
assert.deepEqual(general, { language: "en", sttLanguage: "ru", autoUpdate: false });
|
assert.deepEqual(general, { language: "en", sttLanguage: "ru", autoUpdate: false });
|
||||||
assert.equal(writes.length, 2);
|
assert.equal(writes.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function preferred(language: "en" | "ru") { return element(`[data-testid="stt-preferred-${language}"]`) as HTMLButtonElement; }
|
||||||
|
|
||||||
|
it("saves RU + EN hints, restores them after reload, and retains the choices in single-language mode", async () => {
|
||||||
|
await mount();
|
||||||
|
assert(preferred("en").disabled); assert(preferred("ru").disabled);
|
||||||
|
assert.equal(writes.length, 0);
|
||||||
|
await click(trigger("sttLanguage")); await pick("Несколько языков");
|
||||||
|
assert(!preferred("en").disabled);
|
||||||
|
await click(preferred("en")); await click(preferred("ru"));
|
||||||
|
assert.deepEqual(general, { language: "ru", sttLanguage: "multi", sttPreferredLanguages: ["en", "ru"], autoUpdate: false });
|
||||||
|
assert.deepEqual(new URL(dictationUrl(resolvedSttLanguage, general.sttPreferredLanguages)).searchParams.getAll("language_hint"), ["en", "ru"]);
|
||||||
|
const saved = general;
|
||||||
|
await act(async () => root!.unmount()); root = null; host.remove();
|
||||||
|
await mount(saved);
|
||||||
|
assert.equal(preferred("en").getAttribute("aria-checked"), "true");
|
||||||
|
assert.equal(preferred("ru").getAttribute("aria-checked"), "true");
|
||||||
|
await click(trigger("sttLanguage", true)); await pick("English");
|
||||||
|
assert(preferred("en").disabled); assert(preferred("ru").disabled);
|
||||||
|
assert.deepEqual(general.sttPreferredLanguages, ["en", "ru"]);
|
||||||
|
assert.deepEqual(new URL(dictationUrl(resolvedSttLanguage, general.sttPreferredLanguages)).searchParams.getAll("language_hint"), []);
|
||||||
|
await click(trigger("sttLanguage")); await pick("Несколько языков");
|
||||||
|
assert.equal(preferred("en").getAttribute("aria-checked"), "true");
|
||||||
|
await click(preferred("en")); await click(preferred("ru"));
|
||||||
|
assert.deepEqual(general.sttPreferredLanguages, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("locks preference edits while saving and recording, and reports a failed save without changing the choice", async () => {
|
||||||
|
await mount({ language: "ru", sttLanguage: "multi", autoUpdate: false });
|
||||||
|
failSave = true;
|
||||||
|
await click(preferred("en"));
|
||||||
|
assert.equal(preferred("en").getAttribute("aria-checked"), "false");
|
||||||
|
assert.equal(general.sttPreferredLanguages, undefined);
|
||||||
|
assert.equal(element('[role="alert"]').textContent, "Не удалось сохранить предпочитаемые языки");
|
||||||
|
failSave = false;
|
||||||
|
let release!: () => void;
|
||||||
|
const pending = new Promise<void>((resolve) => { release = resolve; }); holdSave = () => pending;
|
||||||
|
await click(preferred("en"));
|
||||||
|
assert(preferred("en").disabled); assert(preferred("ru").disabled);
|
||||||
|
await click(preferred("ru"));
|
||||||
|
assert.equal(writes.length, 2);
|
||||||
|
await act(async () => release()); holdSave = undefined;
|
||||||
|
assert.equal(element('[data-testid="stt-preferred-en"]').getAttribute("aria-checked"), "true");
|
||||||
|
assert.equal(document.querySelector('[role="alert"]'), null);
|
||||||
|
await act(async () => recording(true));
|
||||||
|
assert(preferred("en").disabled); assert(preferred("ru").disabled);
|
||||||
|
await click(preferred("en"));
|
||||||
|
assert.deepEqual(general.sttPreferredLanguages, ["en"]);
|
||||||
|
await act(async () => recording(false));
|
||||||
|
assert(!preferred("en").disabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes hint and interface-language saves without losing either preference", async () => {
|
||||||
|
await mount({ language: "ru", sttLanguage: "multi", autoUpdate: false });
|
||||||
|
let release!: () => void;
|
||||||
|
const pending = new Promise<void>((resolve) => { release = resolve; }); holdSave = () => pending;
|
||||||
|
await click(preferred("ru"));
|
||||||
|
await click(trigger("language")); await pick("English");
|
||||||
|
await act(async () => release());
|
||||||
|
assert.deepEqual(general, { language: "en", sttLanguage: "multi", sttPreferredLanguages: ["ru"], autoUpdate: false });
|
||||||
|
});
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe("the settings store", () => {
|
|||||||
assert.deepEqual(out.graph, { enabled: false });
|
assert.deepEqual(out.graph, { enabled: false });
|
||||||
});
|
});
|
||||||
it("persists multilingual STT independently of the interface language and update preference", () => {
|
it("persists multilingual STT independently of the interface language and update preference", () => {
|
||||||
const general = { language: "ru", sttLanguage: "multi", autoUpdate: false } as const;
|
const general = { language: "ru" as const, sttLanguage: "multi" as const, sttPreferredLanguages: ["en" as const, "ru" as const], autoUpdate: false };
|
||||||
writeSettings({ version: 1, general });
|
writeSettings({ version: 1, general });
|
||||||
assert.deepEqual(JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8")).general, general);
|
assert.deepEqual(JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8")).general, general);
|
||||||
assert.deepEqual(readSettings()?.general, general);
|
assert.deepEqual(readSettings()?.general, general);
|
||||||
|
|||||||
Reference in New Issue
Block a user