Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
168cad7470 | ||
|
|
ce28d95bcf | ||
|
|
70977bad84 | ||
|
|
fdd63b27a9 | ||
|
|
bdd7d5e866 | ||
|
|
1c6ae6b2e2 | ||
|
|
d2d9fcd09e | ||
|
|
a65867fc39 | ||
|
|
8c564946ef | ||
|
|
f8ce0f21ef |
@@ -1,12 +1,12 @@
|
||||
name: Linux desktop release
|
||||
|
||||
# Manual until a native Linux runner is registered. The current 0.4.0
|
||||
# Manual until a native Linux runner is registered. The current 0.5.2
|
||||
# packages are built locally using the identical gates documented below.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing stable version tag to package (for example v0.4.0)
|
||||
description: Existing stable version tag to package (for example v0.5.2)
|
||||
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 } from "@/src/core";
|
||||
import { LanguageSelect, useI18n } from "@/components/i18n";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
|
||||
@@ -279,10 +279,10 @@ export type Dictation = {
|
||||
* question waits.
|
||||
*/
|
||||
export function useDictation({ append }: { append: (text: string) => void }): Dictation {
|
||||
const { sttLanguage, setDictating } = useI18n();
|
||||
const selectedLanguage = useRef(sttLanguage);
|
||||
selectedLanguage.current = sttLanguage;
|
||||
const recordingLanguage = useRef<Language>(sttLanguage);
|
||||
const { sttLanguage, general, setDictating } = useI18n();
|
||||
const selectedUrl = useRef("");
|
||||
selectedUrl.current = dictationUrl(sttLanguage, general.sttPreferredLanguages);
|
||||
const recordingUrl = useRef(selectedUrl.current);
|
||||
const [phase, setPhase] = useState<DictationPhase>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
|
||||
@@ -365,7 +365,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
// Minted JWTs authenticate via the "bearer" subprotocol — ["token",
|
||||
// jwt] is rejected at the handshake (verified live; "token" is for
|
||||
// raw API keys only, as in the owner's pi dictate extension).
|
||||
const ws = new WebSocket(dictationUrl(recordingLanguage.current), ["bearer", token]);
|
||||
const ws = new WebSocket(recordingUrl.current, ["bearer", token]);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
@@ -425,7 +425,8 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
||||
);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
recordingLanguage.current = selectedLanguage.current;
|
||||
// Pin the complete configuration once; reconnects must retain both language and hints.
|
||||
recordingUrl.current = selectedUrl.current;
|
||||
setDictating(true);
|
||||
dictationActiveFlag = true;
|
||||
setError(null);
|
||||
|
||||
+100
-17
@@ -1,18 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { resolveLanguage, translator, type GeneralSettings, type Language, type Translator } from "@/src/core";
|
||||
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { resolveLanguage, resolveSttLanguage, translator, type GeneralSettings, type Language, type SttLanguage, type Translator } from "@/src/core";
|
||||
import { notifySettingsSaved, useSettingsSaved } from "./settings-signal";
|
||||
import { MenuShell } from "./menu-shell";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
type LanguageField = "language" | "sttLanguage";
|
||||
type I18nValue = {
|
||||
language: Language;
|
||||
sttLanguage: Language;
|
||||
sttLanguage: SttLanguage;
|
||||
general: GeneralSettings;
|
||||
dictating: boolean;
|
||||
setDictating: (active: boolean) => void;
|
||||
t: Translator;
|
||||
saveGeneral: (patch: Partial<GeneralSettings>) => Promise<void>;
|
||||
saveLanguage: (field: "language" | "sttLanguage", value: Language | undefined) => Promise<void>;
|
||||
saveLanguage: <Field extends LanguageField>(field: Field, value: GeneralSettings[Field]) => Promise<void>;
|
||||
};
|
||||
const I18nContext = createContext<I18nValue>({ language: "en", sttLanguage: "en", general: {}, dictating: false, setDictating: () => {}, t: translator("en"), saveGeneral: async () => {}, saveLanguage: async () => {} });
|
||||
export function useI18n(): I18nValue { return useContext(I18nContext); }
|
||||
@@ -27,7 +30,7 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: {
|
||||
const latest = useRef(general);
|
||||
latest.current = general;
|
||||
const language = resolveLanguage(general.language, detected);
|
||||
const sttLanguage = resolveLanguage(general.sttLanguage, detected);
|
||||
const sttLanguage = resolveSttLanguage(general.sttLanguage, detected);
|
||||
const t = useMemo(() => translator(language), [language]);
|
||||
useEffect(() => {
|
||||
const detect = () => setDetected(resolveLanguage(undefined, navigator.languages));
|
||||
@@ -55,7 +58,7 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: {
|
||||
if (!current.ok) throw new Error("Could not load language settings");
|
||||
const body = await current.json() as { settings?: { general?: GeneralSettings } };
|
||||
const next = { ...(body.settings?.general ?? latest.current) };
|
||||
for (const field of ["language", "sttLanguage", "autoUpdate"] as const) {
|
||||
for (const field of ["language", "sttLanguage", "sttPreferredLanguages", "autoUpdate"] as const) {
|
||||
if (!Object.hasOwn(patch, field)) continue;
|
||||
if (patch[field] === undefined) delete next[field];
|
||||
else Object.assign(next, { [field]: patch[field] });
|
||||
@@ -69,7 +72,7 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: {
|
||||
pending.current = write;
|
||||
return write;
|
||||
}, []);
|
||||
const saveLanguage = useCallback((field: "language" | "sttLanguage", value: Language | undefined) => saveGeneral({ [field]: value }), [saveGeneral]);
|
||||
const saveLanguage = useCallback(<Field extends LanguageField,>(field: Field, value: GeneralSettings[Field]) => saveGeneral({ [field]: value }), [saveGeneral]);
|
||||
const value = useMemo(() => ({ language, sttLanguage, general, dictating, setDictating, t, saveGeneral, saveLanguage }), [language, sttLanguage, general, dictating, t, saveGeneral, saveLanguage]);
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
@@ -78,19 +81,99 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
|
||||
const { general, t, saveLanguage, sttLanguage, dictating } = useI18n();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const menuId = useId();
|
||||
const locked = disabled || saving || (field === "sttLanguage" && dictating);
|
||||
const menuOpen = open && !locked;
|
||||
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 = [
|
||||
...(!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(() => {
|
||||
if (!menuOpen) return;
|
||||
const outside = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!trigger.current?.contains(target) && !document.getElementById(menuId)?.contains(target)) setOpen(false);
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
trigger.current?.focus();
|
||||
};
|
||||
document.addEventListener("pointerdown", outside);
|
||||
// Claim Escape before the settings dialog or dictation's outer layer.
|
||||
document.addEventListener("keydown", escape, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", outside);
|
||||
document.removeEventListener("keydown", escape, true);
|
||||
};
|
||||
}, [menuOpen, menuId]);
|
||||
const pick = (value: "auto" | SttLanguage) => {
|
||||
if (locked) return;
|
||||
setOpen(false);
|
||||
trigger.current?.focus();
|
||||
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));
|
||||
};
|
||||
return <span className={compact ? "flex shrink-0 items-center gap-1" : "flex flex-col items-end gap-1"}>
|
||||
<select aria-label={label} title={label} value={general[field] ?? "auto"} disabled={disabled || saving || (field === "sttLanguage" && dictating)}
|
||||
className="h-7 max-w-full rounded-md border border-border bg-background px-1.5 text-xs text-foreground disabled:opacity-50"
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
setSaving(true); setError(null);
|
||||
void saveLanguage(field, value === "auto" ? undefined : value as Language).catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not save language settings")).finally(() => setSaving(false));
|
||||
<button ref={trigger} type="button" aria-label={label} title={label} aria-haspopup="menu" aria-expanded={menuOpen} aria-controls={menuOpen ? menuId : undefined} disabled={locked} data-testid={testid}
|
||||
className={`flex h-7 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-50 ${compact ? "w-24" : "w-36"}`}
|
||||
onClick={() => setOpen(!menuOpen)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||
event.preventDefault(); event.stopPropagation(); setOpen(true);
|
||||
}}>
|
||||
<option value="auto">{compact ? `${t("Auto")} · ${sttLanguage === "ru" ? "RU" : "EN"}` : t("System default")}</option>
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
<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">
|
||||
<div id={menuId} role="menu" aria-label={label} className="flex flex-col gap-0.5">
|
||||
{options.map((option) => <button key={option.value} type="button" role="menuitemradio" aria-checked={selected === option.value} onClick={() => pick(option.value)}
|
||||
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${selected === option.value ? "bg-muted/60 text-card-foreground" : "text-card-foreground"}`}>
|
||||
<span>{option.label}</span>{selected === option.value && <span aria-hidden>✓</span>}
|
||||
</button>)}
|
||||
</div>
|
||||
</MenuShell>}
|
||||
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
|
||||
</span>;
|
||||
}
|
||||
|
||||
/** Preferred languages guide multilingual recognition; they are saved independently of its mode. */
|
||||
export function SttPreferredLanguages() {
|
||||
const { general, sttLanguage, dictating, saveGeneral, t } = useI18n();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const preferred = general.sttPreferredLanguages ?? [];
|
||||
const locked = sttLanguage !== "multi" || dictating || saving;
|
||||
const toggle = (language: Language) => {
|
||||
if (locked) return;
|
||||
const next = (["en", "ru"] as const).filter((code) => code === language ? !preferred.includes(code) : preferred.includes(code));
|
||||
setSaving(true); setError(null);
|
||||
void saveGeneral({ sttPreferredLanguages: next })
|
||||
.catch(() => setError("Could not save preferred languages"))
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
return <span className="flex max-w-full flex-col items-start gap-1">
|
||||
<span role="group" aria-label={t("Preferred languages")} className="flex max-w-full flex-wrap gap-2">
|
||||
{(["en", "ru"] as const).map((language) => <button key={language} type="button" role="checkbox" aria-checked={preferred.includes(language)}
|
||||
data-testid={`stt-preferred-${language}`} disabled={locked} onClick={() => toggle(language)}
|
||||
className={`flex h-7 items-center gap-1.5 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${preferred.includes(language) ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50"}`}>
|
||||
<span aria-hidden className="flex size-3.5 shrink-0 items-center justify-center rounded-xs border border-border">{preferred.includes(language) && <CheckIcon className="size-3" />}</span>
|
||||
{language === "en" ? "English" : "Русский"}
|
||||
</button>)}
|
||||
</span>
|
||||
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
|
||||
</span>;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ import {
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { prettyModel } from "@/components/usage-format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ZapIcon } from "lucide-react";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
|
||||
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
||||
import { ChatGPTBackgroundAccess } from "./chatgpt-connection";
|
||||
@@ -152,13 +154,22 @@ export function composeSlotModel(
|
||||
delete next[slot];
|
||||
return next;
|
||||
}
|
||||
const model = provider && id ? { provider, id } : prev?.model;
|
||||
next[slot] = {
|
||||
...(provider && id ? { model: { provider, id } } : prev?.model ? { model: prev.model } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}),
|
||||
...(model?.provider === "chatgpt" && prev?.fastMode !== undefined ? { fastMode: prev.fastMode } : {}),
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Speed belongs to the tier, independently of its reasoning budget. */
|
||||
export function composeSlotFast(models: ModelsSettings | undefined, slot: ModelSlotName, enabled: boolean): ModelsSettings {
|
||||
const current = models?.[slot];
|
||||
if (current?.model?.provider !== "chatgpt") return { ...models };
|
||||
return { ...models, [slot]: { ...current, fastMode: enabled } };
|
||||
}
|
||||
|
||||
/** Compose: the search-engine preference. null = the explicit pin is
|
||||
* REMOVED (auto-activation takes over — the schema's resting state).
|
||||
* Pure — exported for tests. */
|
||||
@@ -258,11 +269,12 @@ function MenuTrigger({
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
data-testid={testid}
|
||||
onClick={() => {
|
||||
if (!disabled) onClick();
|
||||
}}
|
||||
className={`flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${
|
||||
className={`flex h-7 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${
|
||||
disabled ? "cursor-not-allowed" : "hover:border-muted-foreground/50"
|
||||
} ${widthClass}`}
|
||||
>
|
||||
@@ -302,12 +314,21 @@ export function AgentSection({
|
||||
/** Writes the composed WHOLE agent section (replace-PUT discipline). */
|
||||
onSectionChange: (next: AgentSettings) => void;
|
||||
/** Writes the composed models section (slots — replace-PUT discipline). */
|
||||
onModelsChange: (next: ModelsSettings) => void;
|
||||
onModelsChange: (next: ModelsSettings) => void | Promise<void>;
|
||||
/** Writes the whole compaction section (replace-PUT discipline). */
|
||||
onCompactionChange: (next: CompactionSettings) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
||||
const [savingModels, setSavingModels] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const updateModels = async (next: ModelsSettings) => {
|
||||
if (savingModels) return;
|
||||
setSavingModels(true); setModelsError(null);
|
||||
try { await onModelsChange(next); }
|
||||
catch { setModelsError("Could not save model settings"); }
|
||||
finally { setSavingModels(false); }
|
||||
};
|
||||
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
|
||||
* Default hints and thinking lists resolve from it. */
|
||||
const [slots, setSlots] = useState<ModelsBody["slots"] | null>(null);
|
||||
@@ -617,6 +638,7 @@ export function AgentSection({
|
||||
return (
|
||||
<>
|
||||
<SettingsGroup label={t("Models")} testid="group-models">
|
||||
{modelsError && <p role="alert" className="text-xs text-destructive">{t(modelsError)}</p>}
|
||||
{MODEL_SLOTS.map((slot) => {
|
||||
const override = models?.[slot];
|
||||
const modelId = override?.model?.id ?? "";
|
||||
@@ -644,35 +666,51 @@ export function AgentSection({
|
||||
label={SLOT_META[slot].label}
|
||||
hint={SLOT_META[slot].roster}
|
||||
testid={`slot-row-${slot}`}
|
||||
className="flex-wrap items-start gap-y-2 [&>span:first-child]:flex-[1_1_12rem]"
|
||||
>
|
||||
{/* The anchor for the outside-click contract is the
|
||||
span holding BOTH triggers of the open menu. */}
|
||||
span holding the tier's controls. */}
|
||||
<span
|
||||
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
||||
className="relative flex items-center gap-2"
|
||||
className="relative flex w-68 max-w-full shrink-0 flex-col items-start gap-2"
|
||||
>
|
||||
<span data-testid={`slot-controls-${slot}`} className="grid w-full grid-cols-[minmax(0,1fr)_5rem_auto] items-center gap-2">
|
||||
<MenuTrigger
|
||||
value={modelId ? prettyModel(modelId) : ""}
|
||||
emptyLabel="—"
|
||||
ariaLabel={t("{v0} model", { v0: t(SLOT_META[slot].label) })}
|
||||
testid={`slot-model-${slot}`}
|
||||
open={thisMenuOpen === "model"}
|
||||
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
|
||||
widthClass="w-full min-w-0"
|
||||
disabled={savingModels}
|
||||
/>
|
||||
<MenuTrigger
|
||||
// Legacy display (chunk 2 item 4): no stored level + a known
|
||||
// model → the RESOLVED level (the ordering's first supported —
|
||||
// what the engine build runs). Display-only until a pick writes.
|
||||
value={thinking || (chosen ? defaultOrderLevel(levels) : "")}
|
||||
emptyLabel="—"
|
||||
ariaLabel={t("{v0} thinking", { v0: t(SLOT_META[slot].label) })}
|
||||
testid={`slot-thinking-${slot}`}
|
||||
open={thisMenuOpen === "thinking"}
|
||||
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })}
|
||||
widthClass="w-20"
|
||||
disabled={savingModels}
|
||||
/>
|
||||
<Hint label={t(provider === "chatgpt" ? "Fast mode uses more ChatGPT allowance." : "Fast mode is available only for ChatGPT models.")}>
|
||||
<span className="inline-flex">
|
||||
<button type="button" data-testid={`slot-fast-${slot}`} aria-label={t("{v0} fast mode", { v0: t(SLOT_META[slot].label) })}
|
||||
aria-pressed={provider === "chatgpt" && override?.fastMode === true} disabled={provider !== "chatgpt" || !chosen || savingModels}
|
||||
onClick={() => { setOpenMenu(null); void updateModels(composeSlotFast(models, slot, !override?.fastMode)); }}
|
||||
className={cn("flex h-7 items-center gap-1 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-40",
|
||||
provider === "chatgpt" && override?.fastMode ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50")}>
|
||||
<ZapIcon className="size-3.5" aria-hidden /><span>{t("Fast")}</span>
|
||||
</button>
|
||||
</span>
|
||||
</Hint>
|
||||
</span>
|
||||
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
|
||||
<MenuTrigger
|
||||
value={modelId ? prettyModel(modelId) : ""}
|
||||
emptyLabel="—"
|
||||
ariaLabel={t("{v0} model", { v0: t(SLOT_META[slot].label) })}
|
||||
testid={`slot-model-${slot}`}
|
||||
open={thisMenuOpen === "model"}
|
||||
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
|
||||
widthClass="min-w-20"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
{thisMenuOpen === "model" && (
|
||||
<MenuShell
|
||||
preferredSide="down"
|
||||
@@ -689,7 +727,7 @@ export function AgentSection({
|
||||
// fresh pick writes a level — the stored one rides as-is
|
||||
// (the write clamps server-side), else the ordering's level.
|
||||
const next = populateLevel(thinking || undefined, entry.supportedLevels);
|
||||
onModelsChange(composeSlotModel(models, slot, entry.provider, entry.id, next));
|
||||
void updateModels(composeSlotModel(models, slot, entry.provider, entry.id, next));
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
footer={
|
||||
@@ -713,7 +751,7 @@ export function AgentSection({
|
||||
// the same one the trigger shows.
|
||||
effectiveLevel={thinking || (chosen ? defaultOrderLevel(levels) : undefined)}
|
||||
onPick={(level) => {
|
||||
onModelsChange(composeSlotModel(models, slot, provider, modelId, level));
|
||||
void updateModels(composeSlotModel(models, slot, provider, modelId, level));
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
/>
|
||||
@@ -762,11 +800,12 @@ export function AgentSection({
|
||||
label={t("Quizzing")}
|
||||
hint={t("How both tutors check your understanding")}
|
||||
testid="quiz-mechanics-row"
|
||||
className="flex-wrap gap-y-2 [&>span]:flex-[1_1_12rem]"
|
||||
>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t("Quizzing mechanics")}
|
||||
className="flex rounded-lg border border-border/60 bg-muted/30 p-0.5"
|
||||
className="flex max-w-full shrink-0 rounded-lg border border-border/60 bg-muted/30 p-0.5"
|
||||
>
|
||||
{(["choice", "free"] as const).map((m) => {
|
||||
const active = m === "free" ? free : !free;
|
||||
@@ -779,7 +818,7 @@ export function AgentSection({
|
||||
data-testid={`quiz-mechanics-${m}`}
|
||||
onClick={() => onSectionChange(composeQuizMechanics(agent, m))}
|
||||
className={cn(
|
||||
"h-8 rounded-md px-3 text-sm font-medium transition-colors",
|
||||
"min-h-8 min-w-0 rounded-md px-3 py-1 text-sm leading-snug font-medium break-words transition-colors",
|
||||
active ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -65,6 +65,7 @@ export function SettingsRow({
|
||||
children,
|
||||
testid,
|
||||
dimmed,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
@@ -73,10 +74,11 @@ export function SettingsRow({
|
||||
/** Dependency graying, graph-view style: the whole row dims when the
|
||||
* control is inert (parent off, no file yet, …). */
|
||||
dimmed?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div data-testid={testid} className={cn("flex items-center justify-between gap-4 py-1", dimmed && "opacity-40")}>
|
||||
<div data-testid={testid} className={cn("flex items-center justify-between gap-4 py-1", dimmed && "opacity-40", className)}>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-prose text-(length:--lh-prose-size)">{t(label)}</span>
|
||||
{hint && <span className="block text-xs text-muted-foreground/70">{t(hint)}</span>}
|
||||
@@ -237,4 +239,4 @@ export function CommitInput({
|
||||
className="h-7 w-40 rounded-sm border border-border bg-background px-2 font-ui text-xs text-foreground outline-none placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
type GraphSettings,
|
||||
type UserSettings,
|
||||
} from "@/src/core";
|
||||
import { LanguageSelect, useI18n } from "@/components/i18n";
|
||||
import { LanguageSelect, SttPreferredLanguages, useI18n } from "@/components/i18n";
|
||||
import { useAppearance } from "./appearance.tsx";
|
||||
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
|
||||
import { AgentSection } from "./agent-section.tsx";
|
||||
@@ -368,6 +368,9 @@ export function SettingsDialogBody({
|
||||
>
|
||||
<SettingsRow label={t("Interface language")} testid="row-language"><LanguageSelect field="language" /></SettingsRow>
|
||||
<SettingsRow label={t("Speech recognition language")} testid="row-stt-language"><LanguageSelect field="sttLanguage" /></SettingsRow>
|
||||
<SettingsRow label={t("Preferred languages")} hint={t("Used with Multilingual. Favors selected languages; other languages may still be recognized.")} testid="row-stt-preferred-languages" className="flex-col items-start gap-2">
|
||||
<SttPreferredLanguages />
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t("Auto-update")}
|
||||
hint={AUTO_UPDATE_HINT}
|
||||
@@ -399,7 +402,7 @@ export function SettingsDialogBody({
|
||||
graph={record.graph}
|
||||
compaction={record.compaction}
|
||||
onModelsChange={(next: ModelsSettings) =>
|
||||
void write("models", next)
|
||||
write("models", next)
|
||||
}
|
||||
onSectionChange={(next: AgentSettings) =>
|
||||
void write("agent", next)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Desktop builds and releases
|
||||
|
||||
For this fork’s Linux 0.4.0 release on Gitea, see [Gitea releases](gitea-release.md).
|
||||
For this fork’s Linux and Windows 0.5.2 release on Gitea, see [Gitea releases](gitea-release.md).
|
||||
The three-platform GitHub process below documents the upstream reference.
|
||||
|
||||
## Platforms
|
||||
|
||||
+66
-13
@@ -2,8 +2,8 @@
|
||||
|
||||
The release destination is
|
||||
https://server2.sorokin-oleg-edu.xyz/replicated/learnharness.
|
||||
Version **0.4.0 targets Linux x64**. Windows is deferred until a native Windows
|
||||
builder is available. The upstream GitHub workflow and three-platform packaging
|
||||
Version **0.5.2 targets Linux x64 and Windows x64**, with each platform built
|
||||
and verified on its native operating system. The upstream GitHub workflow and three-platform packaging
|
||||
configuration remain available as reference in `.github/workflows/release.yml`
|
||||
and `electron-builder.yml`.
|
||||
|
||||
@@ -48,20 +48,62 @@ 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 0.4.0 release has exactly four attachments:
|
||||
The Linux portion of 0.5.2 has these attachments:
|
||||
|
||||
- `LearnHarness-0.4.0-x86_64.AppImage`
|
||||
- `LearnHarness-0.4.0-amd64.deb`
|
||||
- `LearnHarness-0.5.2-x86_64.AppImage`
|
||||
- `LearnHarness-0.5.2-amd64.deb`
|
||||
- `latest-linux.yml`
|
||||
- `SHA256SUMS`
|
||||
|
||||
## Preparing the Gitea draft
|
||||
## Preparing Windows packages
|
||||
|
||||
Use a clean checkout of the same `v0.5.2` tag on Windows x64 with Node 26 and Git
|
||||
for Windows. Keep build tools and logs outside the checkout. The Windows
|
||||
release checkout is `~/Documents/Gitea/learnharness-release-0.5.2`, linked to the
|
||||
existing repository in `~/Documents/Gitea/learnharness`.
|
||||
|
||||
In PowerShell, stop after any failed command:
|
||||
|
||||
```powershell
|
||||
npm.cmd ci
|
||||
$env:NODE_ENV = 'test'
|
||||
$env:ELECTRON_RUN_AS_NODE = '1'
|
||||
node node_modules/electron/cli.js --import tsx --test tests/pdf-native-render.test.ts tests/pdf-ops.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts
|
||||
Remove-Item Env:ELECTRON_RUN_AS_NODE
|
||||
node --import tsx --test tests/i18n.test.tsx
|
||||
Remove-Item Env:NODE_ENV
|
||||
node node_modules/next/dist/bin/next build
|
||||
node scripts/smoke-provider-runtime.mjs
|
||||
node scripts/build-server.mjs
|
||||
node scripts/smoke-server.mjs --electron
|
||||
node node_modules/electron-builder/out/cli/cli.js --config electron-builder.gitea.yml --win --x64 --publish never
|
||||
node scripts/gitea-release.mjs verify-package dist/win-unpacked
|
||||
node scripts/smoke-provider-runtime.mjs --standalone dist/win-unpacked/resources/server/standalone
|
||||
node scripts/smoke-provider-runtime.mjs --standalone dist/win-unpacked/resources/server/standalone --runtime dist/win-unpacked/LearnHarness.exe
|
||||
node scripts/smoke-demo-runtime.mjs --standalone dist/win-unpacked/resources/server/standalone --runtime dist/win-unpacked/LearnHarness.exe --browser
|
||||
```
|
||||
|
||||
Windows contributes three attachments:
|
||||
|
||||
- `LearnHarness-0.5.2-x64-setup.exe`
|
||||
- `LearnHarness-0.5.2-x64-setup.exe.blockmap`
|
||||
- `latest.yml`
|
||||
|
||||
The combined release has **seven attachments**, including one `SHA256SUMS`
|
||||
covering all six other files. Verify that `latest.yml` names the Windows
|
||||
installer exactly, reports version `0.5.2`, and matches the installer's actual
|
||||
size and SHA-512. Its gzip blockmap must cover the installer and match every
|
||||
block checksum. Confirm the packaged `resources/app-update.yml` uses the Gitea
|
||||
generic provider. Windows builds follow upstream's unsigned, per-user installer
|
||||
configuration.
|
||||
|
||||
## Preparing a Linux-only Gitea draft
|
||||
|
||||
Commit and push the release source, then push its exact annotated tag:
|
||||
|
||||
```sh
|
||||
git tag -a v0.4.0 -m 'LearnHarness 0.4.0'
|
||||
git push gitea main v0.4.0
|
||||
git tag -a v0.5.2 -m 'LearnHarness 0.5.2'
|
||||
git push gitea main v0.5.2
|
||||
```
|
||||
|
||||
Supply `GITEA_TOKEN` only to the upload command. A token file may remain outside
|
||||
@@ -75,7 +117,18 @@ The tool requires the tag to exist in Gitea, creates or reuses one draft at that
|
||||
tag, uploads missing assets, and downloads every attachment to verify its actual
|
||||
SHA-256 against the local verified build. Existing mismatched attachments and
|
||||
published releases are refused. It never publishes or changes repository
|
||||
visibility. Release notes come from `docs/release-0.4.0.md`.
|
||||
visibility. Release notes come from `docs/release-0.5.2.md`.
|
||||
|
||||
This uploader and its remote verifier enforce a **Linux-only** inventory. They
|
||||
will refuse a combined Linux/Windows draft. For the combined 0.5.2 release, add
|
||||
the verified Windows attachments to the existing draft and replace `SHA256SUMS`
|
||||
with the combined manifest. Keep the Linux installers and feed unchanged. Check
|
||||
all seven attachment names and sizes against the verified local builds. For
|
||||
the owner-approved 0.5.2 release, local installer/feed/blockmap validation and
|
||||
the combined checksum manifest are completed before upload. Confirm successful
|
||||
upload responses and publish immediately afterward; do not download attachments
|
||||
again or run post-upload hash checks. Do not publish until both platforms'
|
||||
local checks pass.
|
||||
|
||||
A separate verification can be repeated without changing the draft:
|
||||
|
||||
@@ -92,12 +145,12 @@ public `latest` feed and in-app automatic updates expose this version.
|
||||
workflow with an existing release tag as input. It requires a native Linux x64
|
||||
runner labeled `ubuntu-24.04`, the label used by upstream's Linux release job,
|
||||
plus the native Electron dependencies and `xvfb-run`. There were no runners
|
||||
registered when 0.4.0 was prepared, so its packages are built locally. A tag push
|
||||
registered when 0.5.2 was prepared, so its packages are built locally. A tag push
|
||||
does not queue a job that cannot run.
|
||||
|
||||
The workflow uses the repository's ephemeral `GITEA_TOKEN` with release-write
|
||||
permission only in the final upload step. It checks out the requested tag without
|
||||
persisting credentials and leaves the result as a verified draft. Public pull
|
||||
requests cannot trigger this publishing workflow. Add Windows only with a native
|
||||
Windows build and its corresponding smoke checks; do not substitute a Linux
|
||||
standalone payload into a Windows installer.
|
||||
requests cannot trigger this publishing workflow. This workflow remains
|
||||
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.
|
||||
|
||||
@@ -21,9 +21,10 @@ for unsupported languages.
|
||||
- Language changes preserve other settings. Existing conversations and imported
|
||||
learning material retain their original text.
|
||||
|
||||
This Gitea release targets **Linux x64**: AppImage and Debian packages. Windows
|
||||
packaging is deferred until a native Windows builder is available. Automatic
|
||||
updates for these packages use this fork's public Gitea releases.
|
||||
This Gitea release includes **Linux x64** AppImage and Debian packages and a
|
||||
**Windows x64** installer. Both platforms were built and checked on their native
|
||||
operating systems. Automatic updates use this fork's public Gitea releases.
|
||||
The Windows installer is unsigned, matching upstream's Windows packaging.
|
||||
|
||||
To use live dictation, configure a Deepgram key under **Settings → Providers**.
|
||||
To start AI sessions, connect an AI provider and select the required model tiers.
|
||||
@@ -49,10 +50,10 @@ LearnHarness теперь поддерживает английский и ру
|
||||
- Смена языка сохраняет остальные настройки. Существующие разговоры и
|
||||
импортированные учебные материалы остаются на исходном языке.
|
||||
|
||||
Этот выпуск в Gitea предназначен для **Linux x64** и доступен в форматах AppImage
|
||||
и Debian. Выпуск для Windows отложен до появления сборочной машины с Windows.
|
||||
Автоматические обновления этих пакетов используют публичные выпуски этого форка
|
||||
в Gitea.
|
||||
Этот выпуск в Gitea включает пакеты AppImage и Debian для **Linux x64** и
|
||||
установщик для **Windows x64**. Сборка и проверка каждой версии выполнены в её
|
||||
операционной системе. Автоматические обновления используют публичные выпуски
|
||||
этого форка в Gitea. Установщик Windows не подписан — как и в исходном проекте.
|
||||
|
||||
Для диктовки добавьте ключ Deepgram в разделе **Настройки → Провайдеры**.
|
||||
Для занятий с ИИ подключите провайдера и выберите модели для всех нужных уровней.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# LearnHarness 0.5.0
|
||||
|
||||
## English
|
||||
|
||||
This release adds ChatGPT Fast mode and improves the language controls and
|
||||
Russian settings layout introduced in 0.4.0.
|
||||
|
||||
- Interface-language and speech-recognition selectors in **Settings → General**,
|
||||
plus the quick selector beside the microphone, now use the app's styled menus.
|
||||
The menus include selected-item checkmarks and keyboard navigation; Escape
|
||||
closes the menu and restores focus to its trigger. Language preferences stay
|
||||
independent, and STT selection remains locked while recording.
|
||||
- **Quizzing** keeps its full Russian labels. The label and segmented control
|
||||
share a row when space permits. Narrower panels stack the control beneath the
|
||||
hint with matching left alignment and enough height for wrapped text.
|
||||
- **Settings → Agent** now has a **⚡ Fast** toggle for each Strong, Cheap, and
|
||||
Vision model tier. Fast mode is **off by default** and initially available only
|
||||
for **ChatGPT** connections. It requests priority processing and may consume
|
||||
more ChatGPT plan allowance; availability depends on the model and account.
|
||||
- Fast preferences take effect on the **next request**, including existing
|
||||
ChatGPT conversations and newly started background work in the corresponding
|
||||
tier. The selected model and reasoning effort are preserved. Selecting another
|
||||
provider turns Fast off for that tier; other providers show it as unavailable.
|
||||
- Unsupported Fast requests produce actionable, localized errors. Connections
|
||||
keep their existing account, background-consent, and no-API-key-fallback rules.
|
||||
|
||||
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 upstream.
|
||||
|
||||
## Русский
|
||||
|
||||
В этом выпуске добавлен быстрый режим ChatGPT и улучшены языковые настройки
|
||||
и их отображение на русском языке, появившиеся в версии 0.4.0.
|
||||
|
||||
- Выбор языка интерфейса и распознавания речи в разделе **Настройки → Общие**,
|
||||
а также быстрый выбор языка рядом с микрофоном теперь используют меню в стиле
|
||||
приложения. Выбранный пункт отмечен галочкой, поддерживается управление
|
||||
клавиатурой. Escape закрывает меню и возвращает фокус на кнопку. Языковые
|
||||
настройки остаются независимыми; язык распознавания нельзя менять во время
|
||||
записи.
|
||||
- В разделе **Проверка понимания** сохранены полные русские подписи. Если места
|
||||
достаточно, заголовок и переключатель расположены в одной строке. В узких
|
||||
панелях переключатель размещается под пояснением с выравниванием по левому
|
||||
краю; высота кнопок учитывает перенос текста.
|
||||
- В разделе **Настройки → Агент** появился переключатель **⚡ Быстро** для каждого
|
||||
уровня моделей: основной, экономичной и с поддержкой изображений. Быстрый
|
||||
режим **отключён по умолчанию** и пока доступен только для подключений
|
||||
**ChatGPT**. Он запрашивает приоритетную обработку и может быстрее расходовать
|
||||
лимит ChatGPT. Доступность зависит от модели и учётной записи.
|
||||
- Настройка применяется к **следующему запросу**, в том числе в существующих
|
||||
занятиях с ChatGPT и в новых фоновых задачах соответствующего уровня.
|
||||
Выбранная модель и уровень рассуждений сохраняются. При выборе другого
|
||||
провайдера быстрый режим для этого уровня отключается; для остальных
|
||||
провайдеров переключатель недоступен.
|
||||
- Если быстрый режим не поддерживается, приложение показывает понятную ошибку
|
||||
на выбранном языке. Сохраняются выбранная учётная запись, разрешения на
|
||||
фоновую работу и запрет автоматического перехода на API-ключ.
|
||||
|
||||
Пакеты: AppImage и Debian для **Linux x64**, установщик для **Windows x64**.
|
||||
Обе версии используют публичные выпуски этого форка в Gitea для автоматических
|
||||
обновлений. Установщик Windows не подписан, как и в исходном проекте.
|
||||
@@ -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 не подписан, как и в предыдущем выпуске.
|
||||
@@ -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",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "learnharness",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.2",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "1.4.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "learnharness",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.2",
|
||||
"license": "AGPL-3.0-only",
|
||||
"author": {
|
||||
"name": "Amos Blomqvist",
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function verifyLocal(directory, version) {
|
||||
/** The downloaded bytes, not only API-reported sizes, must agree with the local verified package. */
|
||||
export async function verifyRemote(release, localInventory, headers = {}, request = fetch) {
|
||||
assert.equal(release.draft, true, "only a draft may be prepared or verified by this tool");
|
||||
assert.equal(release.prerelease, false, "0.4.0 is a stable release");
|
||||
assert.equal(release.prerelease, false, "release must be stable");
|
||||
assert.deepEqual(release.assets.map((asset) => asset.name).sort(), localInventory.map((asset) => asset.name).sort(), "remote draft has missing, duplicate, or unexpected assets");
|
||||
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), "lh-release-verify-"));
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,7 @@ export function chatGPTStreamError(message: string): string {
|
||||
if (/expired|invalid_grant|401|invalid_user/.test(message)) return "Your ChatGPT session expired. Reconnect in Providers. No API-key fallback was used.";
|
||||
if (/background|Background/.test(message)) return "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers to run this work. No API-key fallback was used.";
|
||||
if (/eligible|403/.test(message)) return "This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility.";
|
||||
if (/service[_ -]?tier|priority processing|fast mode/i.test(message)) return "Fast mode is unavailable for this ChatGPT model or account. Turn off Fast mode or choose another model.";
|
||||
if (/cancel|abort|closed|disconnect/i.test(message)) return "ChatGPT request cancelled or connection closed. No API-key fallback was used.";
|
||||
if (/catalog|model/.test(message)) return "The selected model is unavailable for this ChatGPT account. Refresh Providers and explicitly choose an available model. No API-key fallback was used.";
|
||||
if (/incomplete|terminal|interrupted|stop reason/.test(message)) return "ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used.";
|
||||
@@ -19,9 +20,10 @@ export function chatGPTStreamError(message: string): string {
|
||||
}
|
||||
|
||||
/** Restrict the body at the final payload seam, even if caller samplingParams add fields. */
|
||||
export function chatGPTPayload(raw: unknown, selectedModelId: string): Record<string, unknown> {
|
||||
export function chatGPTPayload(raw: unknown, selectedModelId: string, fastMode = false): Record<string, unknown> {
|
||||
const payload = raw as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = { model: selectedModelId, store: false, stream: true };
|
||||
if (fastMode) out.service_tier = "priority";
|
||||
for (const key of ["instructions", "reasoning", "include", "text", "tool_choice", "parallel_tool_calls", "prompt_cache_key"]) {
|
||||
if (payload[key] !== undefined) out[key] = payload[key];
|
||||
}
|
||||
@@ -44,7 +46,7 @@ export function chatGPTHistory(context: TranscriptContext, logicalModel: Model<A
|
||||
function empty(model: Model<Api>): AssistantMessage {
|
||||
return { role: "assistant", api: model.api, provider: "chatgpt", model: model.id, content: [], stopReason: "error", timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
|
||||
}
|
||||
export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDispatchLifecycle): StreamFunction<Api, SimpleStreamOptions> {
|
||||
export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDispatchLifecycle, fastMode = false): StreamFunction<Api, SimpleStreamOptions> {
|
||||
return (logicalModel, context, options) => {
|
||||
const output = new AssistantMessageEventStream();
|
||||
const purpose = background();
|
||||
@@ -83,14 +85,18 @@ export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDisp
|
||||
},
|
||||
onPayload: async (raw, model) => {
|
||||
const replacement = await options?.onPayload?.(raw, model);
|
||||
const payload = chatGPTPayload(replacement === undefined ? raw : replacement, logicalModel.id);
|
||||
const payload = chatGPTPayload(replacement === undefined ? raw : replacement, logicalModel.id, fastMode);
|
||||
check();
|
||||
return payload;
|
||||
},
|
||||
onProviderStreamEvent: async (raw, model) => {
|
||||
const event = raw as { type?: string; code?: string; error?: { code?: string }; response?: { error?: { code?: string }; status?: string } };
|
||||
const event = raw as { type?: string; code?: string; param?: string; error?: { code?: string; param?: string }; response?: { error?: { code?: string; param?: string }; status?: string } };
|
||||
if (event.type === "response.completed" && event.response?.status === "completed" && !event.response?.error) completed = true;
|
||||
if (["response.incomplete", "response.failed", "error"].includes(event.type ?? "") || event.response?.error) failure = event.code || event.error?.code || event.response?.error?.code || "incomplete terminal response";
|
||||
if (["response.incomplete", "response.failed", "error"].includes(event.type ?? "") || event.response?.error) {
|
||||
const code = event.code || event.error?.code || event.response?.error?.code || "incomplete terminal response";
|
||||
failure = [event.param, event.error?.param, event.response?.error?.param].includes("service_tier")
|
||||
? `${code} service_tier` : code;
|
||||
}
|
||||
await options?.onProviderStreamEvent?.(raw, model);
|
||||
},
|
||||
});
|
||||
|
||||
+2
-1
@@ -80,6 +80,7 @@ export interface ResolvedModelSpec {
|
||||
provider: string;
|
||||
id: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
fastMode?: boolean;
|
||||
}
|
||||
|
||||
/** The first entry of the baked default order the model supports (owner,
|
||||
@@ -119,7 +120,7 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (
|
||||
// the pin honest even when config or settings drift off the map.
|
||||
streamFn: (requestModel, context, options) => {
|
||||
const purpose = typeof background === "function" ? background : () => background;
|
||||
const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models);
|
||||
const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle, spec.fastMode === true) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models);
|
||||
return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level });
|
||||
},
|
||||
};
|
||||
|
||||
+9
-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,8 +63,11 @@ export function learningLanguageInstruction(language: Language): string {
|
||||
: "LANGUAGE: Use English for all learner-facing content, including teaching, questions and choices, plans, titles, practice, research summaries, knowledge-graph propositions, and text in generated diagrams, figures and demos. Preserve code, notation, identifiers, proper names and quoted source text. Follow an explicit learner request for a different language or a language-learning exercise when necessary.";
|
||||
}
|
||||
/** Streaming configuration: the language is pinned once at recording start, including reconnects. */
|
||||
export function dictationUrl(language: Language): string {
|
||||
export function dictationUrl(language: SttLanguage, preferredLanguages: readonly Language[] = []): string {
|
||||
const params = new URLSearchParams({ model: "nova-3", language, encoding: "linear16", sample_rate: "16000", channels: "1", smart_format: "true", punctuate: "true", endpointing: "300" });
|
||||
if (language === "multi") {
|
||||
for (const preferred of new Set(preferredLanguages)) if (isLanguage(preferred)) params.append("language_hint", preferred);
|
||||
}
|
||||
return `wss://api.deepgram.com/v1/listen?${params}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -249,6 +249,12 @@
|
||||
"Speech recognition language": "Speech recognition language",
|
||||
"Auto": "Auto",
|
||||
"System default": "System default",
|
||||
"Multilingual": "Multilingual",
|
||||
"Multi": "Multi",
|
||||
"Preferred languages": "Preferred languages",
|
||||
"Used with Multilingual. Favors selected languages; other languages may still be recognized.": "Used with Multilingual. Favors selected languages; other languages may still be recognized.",
|
||||
"Could not save preferred languages": "Could not save preferred languages",
|
||||
"sttPreferredLanguages must be an array of \"en\" and \"ru\"": "sttPreferredLanguages must be an array of \"en\" and \"ru\"",
|
||||
"Global": "Global",
|
||||
"Chat": "Chat",
|
||||
"Graph view": "Graph view",
|
||||
@@ -723,6 +729,7 @@
|
||||
"thinkingLevel must be a string": "thinkingLevel must be a string",
|
||||
"autoUpdate must be a boolean": "autoUpdate must be a boolean",
|
||||
"{v0} must be \"en\" or \"ru\"": "{v0} must be \"en\" or \"ru\"",
|
||||
"sttLanguage must be \"en\", \"ru\", or \"multi\"": "sttLanguage must be \"en\", \"ru\", or \"multi\"",
|
||||
"{v0} must be an object": "{v0} must be an object",
|
||||
"{v0}.model must be {provider, id}": "{v0}.model must be {provider, id}",
|
||||
"{v0}.thinkingLevel must be a string": "{v0}.thinkingLevel must be a string",
|
||||
@@ -835,5 +842,11 @@
|
||||
"search engine is set to \"{v0}\" but its API key is missing — set the {v1} key": "search engine is set to \"{v0}\" but its API key is missing — set the {v1} key",
|
||||
"search engine: {v0} (chosen)": "search engine: {v0} (chosen)",
|
||||
"search engine: {v0} (auto)": "search engine: {v0} (auto)",
|
||||
"update": "update"
|
||||
"update": "update",
|
||||
"Fast": "Fast",
|
||||
"{v0} fast mode": "{v0} fast mode",
|
||||
"Fast mode uses more ChatGPT allowance.": "Fast mode uses more ChatGPT allowance.",
|
||||
"Fast mode is available only for ChatGPT models.": "Fast mode is available only for ChatGPT models.",
|
||||
"Could not save model settings": "Could not save model settings",
|
||||
"Fast mode is unavailable for this ChatGPT model or account. Turn off Fast mode or choose another model.": "Fast mode is unavailable for this ChatGPT model or account. Turn off Fast mode or choose another model."
|
||||
}
|
||||
|
||||
@@ -249,6 +249,12 @@
|
||||
"Speech recognition language": "Язык распознавания речи",
|
||||
"Auto": "Авто",
|
||||
"System default": "Язык системы",
|
||||
"Multilingual": "Несколько языков",
|
||||
"Multi": "Мульти",
|
||||
"Preferred languages": "Предпочитаемые языки",
|
||||
"Used with Multilingual. Favors selected languages; other languages may still be recognized.": "Используется в режиме «Несколько языков». Выбранные языки получают приоритет, но могут распознаваться и другие.",
|
||||
"Could not save preferred languages": "Не удалось сохранить предпочитаемые языки",
|
||||
"sttPreferredLanguages must be an array of \"en\" and \"ru\"": "Предпочитаемые языки должны быть списком кодов «en» и «ru»",
|
||||
"Global": "Общие",
|
||||
"Chat": "Занятие",
|
||||
"Graph view": "Граф",
|
||||
@@ -754,6 +760,7 @@
|
||||
"thinkingLevel must be a string": "Уровень рассуждения должен быть строкой",
|
||||
"autoUpdate must be a boolean": "Параметр автообновления должен быть логическим значением",
|
||||
"{v0} must be \"en\" or \"ru\"": "Параметр {v0} должен иметь значение «en» или «ru»",
|
||||
"sttLanguage must be \"en\", \"ru\", or \"multi\"": "Параметр sttLanguage должен иметь значение «en», «ru» или «multi»",
|
||||
"{v0} must be an object": "Параметр {v0} должен быть объектом",
|
||||
"{v0}.model must be {provider, id}": "Поле {v0}.model должно содержать provider и id",
|
||||
"{v0}.thinkingLevel must be a string": "Поле {v0}.thinkingLevel должно быть строкой",
|
||||
@@ -871,5 +878,11 @@
|
||||
"search engine is set to \"{v0}\" but its API key is missing — set the {v1} key": "Выбрана поисковая система «{v0}», но её API-ключ отсутствует — добавьте ключ {v1}",
|
||||
"search engine: {v0} (chosen)": "Поисковая система: {v0} (выбрана вручную)",
|
||||
"search engine: {v0} (auto)": "Поисковая система: {v0} (автоматически)",
|
||||
"update": "обновление"
|
||||
"update": "обновление",
|
||||
"Fast": "Быстро",
|
||||
"{v0} fast mode": "Быстрый режим: {v0}",
|
||||
"Fast mode uses more ChatGPT allowance.": "Быстрый режим быстрее расходует лимит ChatGPT.",
|
||||
"Fast mode is available only for ChatGPT models.": "Быстрый режим доступен только для моделей ChatGPT.",
|
||||
"Could not save model settings": "Не удалось сохранить настройки моделей",
|
||||
"Fast mode is unavailable for this ChatGPT model or account. Turn off Fast mode or choose another model.": "Быстрый режим недоступен для этой модели или учётной записи ChatGPT. Отключите быстрый режим или выберите другую модель."
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface EffectiveSlot {
|
||||
provider: string;
|
||||
id: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
fastMode?: boolean;
|
||||
}
|
||||
|
||||
/** A fully-resolved model pin for one role — same shape/level semantics
|
||||
@@ -61,6 +62,7 @@ export interface EffectiveModelSpec {
|
||||
provider: string;
|
||||
id: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
fastMode?: boolean;
|
||||
}
|
||||
|
||||
/** The concrete appearance view — same shape as the factory's. */
|
||||
@@ -110,6 +112,8 @@ export function resolveConversationSpec(
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
thinkingLevel: chat?.thinkingLevel ?? strong?.thinkingLevel,
|
||||
// Speed is a live tier preference, including for existing conversations.
|
||||
...(model.provider === "chatgpt" && strong?.provider === "chatgpt" && strong.fastMode === true ? { fastMode: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,7 +135,9 @@ export function resolveRoleSpec(role: ModelRole, record: UserSettings | null): E
|
||||
* to ride without the factory floor). */
|
||||
export function resolveSlot(slot: ModelSlot | undefined): EffectiveSlot | null {
|
||||
if (!slot?.model) return null;
|
||||
return { provider: slot.model.provider, id: slot.model.id, thinkingLevel: slot.thinkingLevel };
|
||||
return { provider: slot.model.provider, id: slot.model.id, thinkingLevel: slot.thinkingLevel,
|
||||
...(slot.model.provider === "chatgpt" && slot.fastMode === true ? { fastMode: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve one role's spec — the BYOK chain (owner 2026-09-22): role
|
||||
@@ -153,6 +159,7 @@ export function resolveModelSpec(
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
thinkingLevel: override?.thinkingLevel ?? slot?.thinkingLevel,
|
||||
...(model.provider === "chatgpt" && slot?.model?.provider === "chatgpt" && slot.fastMode === true ? { fastMode: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,6 +184,7 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
|
||||
autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate,
|
||||
...(record?.general?.language ? { language: record.general.language } : {}),
|
||||
...(record?.general?.sttLanguage ? { sttLanguage: record.general.sttLanguage } : {}),
|
||||
...(record?.general?.sttPreferredLanguages ? { sttPreferredLanguages: [...record.general.sttPreferredLanguages] } : {}),
|
||||
},
|
||||
agent: {
|
||||
models,
|
||||
|
||||
+18
-7
@@ -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. */
|
||||
@@ -193,6 +194,8 @@ export interface ModelSlot {
|
||||
model?: { provider: string; id: string };
|
||||
/** The thinking budget swap. */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
/** Opt-in ChatGPT priority processing for this tier. Absent = off. */
|
||||
fastMode?: boolean;
|
||||
}
|
||||
|
||||
/** The `models` settings section (owner decision 2026-09-20: a dedicated
|
||||
@@ -234,9 +237,11 @@ export interface GeneralSettings {
|
||||
/** Auto-update the app (default ON). Mechanism ships in a later wave. */
|
||||
autoUpdate?: boolean;
|
||||
/** Absent = browser/system language (English for unsupported languages). */
|
||||
language?: "en" | "ru";
|
||||
/** Independent dictation language; absent = browser/system language. */
|
||||
sttLanguage?: "en" | "ru";
|
||||
language?: Language;
|
||||
/** Independent dictation language; multi = multilingual, absent = browser/system language. */
|
||||
sttLanguage?: SttLanguage;
|
||||
/** Optional recognition hints for multilingual dictation; absent/empty = automatic detection. */
|
||||
sttPreferredLanguages?: Language[];
|
||||
}
|
||||
|
||||
/** Agent settings — everything about the non-conversation machinery. */
|
||||
@@ -445,9 +450,9 @@ function sanitizeGeneral(raw: Record<string, unknown>): GeneralSettings {
|
||||
const out: GeneralSettings = {};
|
||||
const autoUpdate = boolOrDrop(raw.autoUpdate);
|
||||
if (autoUpdate !== undefined) out.autoUpdate = autoUpdate;
|
||||
for (const key of ["language", "sttLanguage"] as const) {
|
||||
if (raw[key] === "en" || raw[key] === "ru") out[key] = raw[key];
|
||||
}
|
||||
if (isLanguage(raw.language)) out.language = raw.language;
|
||||
if (isSttLanguage(raw.sttLanguage)) out.sttLanguage = raw.sttLanguage;
|
||||
if (Array.isArray(raw.sttPreferredLanguages)) out.sttPreferredLanguages = [...new Set(raw.sttPreferredLanguages.filter(isLanguage))];
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -490,7 +495,13 @@ function sanitizeModels(raw: Record<string, unknown>): ModelsSettings {
|
||||
const out: ModelsSettings = {};
|
||||
for (const slot of MODEL_SLOTS) {
|
||||
const cleaned = sanitizeRoleOverride(raw[slot]);
|
||||
if (cleaned) out[slot] = cleaned;
|
||||
if (cleaned) {
|
||||
const value = raw[slot] as Record<string, unknown>;
|
||||
out[slot] = {
|
||||
...cleaned,
|
||||
...(cleaned.model?.provider === "chatgpt" && typeof value.fastMode === "boolean" ? { fastMode: value.fastMode } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ export function clampModelsLevels(settings: ModelsSettings): ModelsSettings {
|
||||
out[name] =
|
||||
slot.thinkingLevel !== undefined
|
||||
? {
|
||||
model: slot.model,
|
||||
...slot,
|
||||
thinkingLevel: clampLevelFor(slot.model.provider, slot.model.id, slot.thinkingLevel),
|
||||
}
|
||||
: slot;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { isHexColor, type ChatSettings } from "../core";
|
||||
import { isHexColor, isLanguage, isSttLanguage, type ChatSettings } from "../core";
|
||||
import type {
|
||||
AgentSettings,
|
||||
AppearanceSettings,
|
||||
@@ -75,7 +75,7 @@ function asRecord(body: unknown): Record<string, unknown> | null {
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** general: { autoUpdate?: boolean } */
|
||||
/** Independent interface/STT language choices and automatic updates. */
|
||||
export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<GeneralSettings> {
|
||||
const raw = asRecord(body);
|
||||
if (!raw) return { ok: false, error: "Invalid JSON body" };
|
||||
@@ -86,11 +86,17 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<Gene
|
||||
}
|
||||
settings.autoUpdate = raw.autoUpdate;
|
||||
}
|
||||
for (const key of ["language", "sttLanguage"] as const) {
|
||||
if (raw[key] !== undefined) {
|
||||
if (raw[key] !== "en" && raw[key] !== "ru") return { ok: false, error: `${key} must be "en" or "ru"` };
|
||||
settings[key] = raw[key];
|
||||
}
|
||||
if (raw.language !== undefined) {
|
||||
if (!isLanguage(raw.language)) return { ok: false, error: 'language must be "en" or "ru"' };
|
||||
settings.language = raw.language;
|
||||
}
|
||||
if (raw.sttLanguage !== undefined) {
|
||||
if (!isSttLanguage(raw.sttLanguage)) return { ok: false, error: 'sttLanguage must be "en", "ru", or "multi"' };
|
||||
settings.sttLanguage = raw.sttLanguage;
|
||||
}
|
||||
if (raw.sttPreferredLanguages !== undefined) {
|
||||
if (!Array.isArray(raw.sttPreferredLanguages) || !raw.sttPreferredLanguages.every(isLanguage)) return { ok: false, error: 'sttPreferredLanguages must be an array of "en" and "ru"' };
|
||||
settings.sttPreferredLanguages = [...new Set(raw.sttPreferredLanguages)];
|
||||
}
|
||||
return { ok: true, settings };
|
||||
}
|
||||
@@ -348,7 +354,17 @@ export function parseModelsSettingsBody(body: unknown): SettingsBodyResult<Model
|
||||
if (!parsed.override.model) {
|
||||
return { ok: false, error: `slot "${key}" must set a model (thinkingLevel alone cannot run — absent slot = unset)` };
|
||||
}
|
||||
settings[key as (typeof MODEL_SLOTS)[number]] = parsed.override;
|
||||
const value = asRecord(raw[key])!;
|
||||
if (value.fastMode !== undefined && typeof value.fastMode !== "boolean") {
|
||||
return { ok: false, error: `${key}.fastMode must be a boolean` };
|
||||
}
|
||||
if (value.fastMode === true && parsed.override.model.provider !== "chatgpt") {
|
||||
return { ok: false, error: "Fast mode is available only for ChatGPT models." };
|
||||
}
|
||||
settings[key as (typeof MODEL_SLOTS)[number]] = {
|
||||
...parsed.override,
|
||||
...(typeof value.fastMode === "boolean" ? { fastMode: value.fastMode } : {}),
|
||||
};
|
||||
}
|
||||
return { ok: true, settings };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createChatGPT, fileStore, allowedAuthEndpoint, SCOPES } = require("../electron/chatgpt.cjs");
|
||||
@@ -55,6 +56,14 @@ function fixture(options: { state?: any; expires?: number; timeoutMs?: number; t
|
||||
return fetch(callback);
|
||||
} };
|
||||
}
|
||||
// Callback HTTP success precedes asynchronous token verification and persistence.
|
||||
async function waitForSignIn(service: { status(): Promise<{ pending: boolean }> }) {
|
||||
const deadline = Date.now() + 5000;
|
||||
while ((await service.status()).pending) {
|
||||
assert(Date.now() < deadline, "sign-in did not settle within the fixture timeout");
|
||||
await delay(5);
|
||||
}
|
||||
}
|
||||
async function loggedIn(options: Parameters<typeof fixture>[0] = {}) {
|
||||
const f = fixture(options);
|
||||
await f.service.login();
|
||||
@@ -450,7 +459,7 @@ test("repeated real verified sign-in creates one visible account while preservin
|
||||
try {
|
||||
const original = f.saved.registrations[0];
|
||||
await f.service.login(); await f.callback({ client_id: "oaiapp_repeat" });
|
||||
for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r));
|
||||
await waitForSignIn(f.service);
|
||||
const status = await f.service.status();
|
||||
assert.equal(f.saved.registrations.length, 2); assert.equal(status.accounts.length, 1);
|
||||
assert.equal(status.activeId, status.accounts[0].id); assert.notEqual(status.activeId, original.id);
|
||||
@@ -595,7 +604,7 @@ test("one app-wide background preference defaults ON over legacy OFF, persists w
|
||||
const id = f.saved.activeId;
|
||||
await f.service.select(id); assert.equal((await f.service.status()).allowBackgroundUse, false);
|
||||
await f.service.login(id); await f.callback();
|
||||
for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r));
|
||||
await waitForSignIn(f.service);
|
||||
assert.equal(f.saved.allowBackgroundUse, false, "reconnecting cannot overwrite app preference");
|
||||
assert.equal(f.saved.registrations[0].background, undefined, "new verified registrations omit obsolete per-account preference");
|
||||
await f.service.disconnect(f.saved.activeId);
|
||||
@@ -604,7 +613,7 @@ test("one app-wide background preference defaults ON over legacy OFF, persists w
|
||||
try {
|
||||
assert.equal((await restarted.service.status()).allowBackgroundUse, false, "zero-account restart preserves global OFF");
|
||||
await restarted.service.login(); await restarted.callback();
|
||||
for (let i = 0; i < 100 && (await restarted.service.status()).pending; i++) await new Promise((r) => setImmediate(r));
|
||||
await waitForSignIn(restarted.service);
|
||||
assert.equal(restarted.saved.allowBackgroundUse, false);
|
||||
await restarted.service.consent(true); assert.equal(restarted.saved.allowBackgroundUse, true);
|
||||
} finally { restarted.service.close(); }
|
||||
|
||||
@@ -170,6 +170,41 @@ test("case-insensitive Authorization overrides including null are refused before
|
||||
assert.equal(requests.length, before);
|
||||
});
|
||||
|
||||
test("Fast opt-in pins priority on the actual OAuth request without altering reasoning or accepting caller tier overrides", async () => {
|
||||
for (const fastMode of [undefined, false, true]) {
|
||||
requests = []; responseEvents = [complete()];
|
||||
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id, thinkingLevel: "high", fastMode }, false);
|
||||
const result = await engine.streamFn(engine.model, context(), {
|
||||
samplingParams: { service_tier: "flex" },
|
||||
onPayload: (raw) => ({ ...raw as object, service_tier: "default" }),
|
||||
}).result();
|
||||
assert.equal(result.stopReason, "stop"); assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0]!.body.service_tier, fastMode ? "priority" : undefined);
|
||||
assert.equal(requests[0]!.body.reasoning.effort, "high");
|
||||
assert.equal(requests[0]!.authorization, "Bearer scratch-oauth-only");
|
||||
}
|
||||
});
|
||||
|
||||
test("the Fast preference never injects priority into an API-key provider request", async () => {
|
||||
requests = []; responseEvents = [complete()];
|
||||
const engine = piEngineForSpec({ provider: "openai", id: model.id, fastMode: true }, false);
|
||||
const result = await engine.streamFn(engine.model, context(), { apiKey: "sk-offline-api-key-fixture" }).result();
|
||||
assert.equal(result.stopReason, "stop"); assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0]!.body.service_tier, undefined);
|
||||
assert.equal(requests[0]!.authorization, "Bearer sk-offline-api-key-fixture");
|
||||
});
|
||||
|
||||
test("an unsupported priority tier gives actionable feedback without retrying or using an API key", async () => {
|
||||
requests = [];
|
||||
responseEvents = [{ type: "error", code: "unsupported_parameter", param: "service_tier", message: "private provider diagnostic" }];
|
||||
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id, fastMode: true }, false);
|
||||
const result = await engine.streamFn(engine.model, context()).result();
|
||||
assert.equal(result.stopReason, "error"); assert.equal(requests.length, 1);
|
||||
assert(result.errorMessage?.includes("Turn off Fast mode"));
|
||||
assert(!result.errorMessage?.includes("private provider diagnostic"));
|
||||
assert.equal(requests[0]!.authorization, "Bearer scratch-oauth-only");
|
||||
});
|
||||
|
||||
test("full credential-storage invalidation aborts foreground and background controllers and clears capabilities", () => {
|
||||
const foreground = chatGPTAbortController(false), background = chatGPTAbortController(true);
|
||||
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 2, authorityRevision: 2, revision: 3 }, status: { ...status, available: false }, invalidate: "all" });
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { act } from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, it } from "node:test";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { AgentSection } from "../components/settings/agent-section.tsx";
|
||||
import { I18nProvider } from "../components/i18n.tsx";
|
||||
import type { ModelCatalogEntry, ModelsSettings } from "../src/core/index.ts";
|
||||
|
||||
const dom = parseHTML("<html><body></body></html>");
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const originalFetch = globalThis.fetch;
|
||||
const chatgpt = { provider: "chatgpt", id: "gpt-5" };
|
||||
const api = { provider: "openai", id: "gpt-5" };
|
||||
const fixture: ModelCatalogEntry = { ...chatgpt, name: "Offline model", reasoning: true, input: ["text", "image"], supportedLevels: ["low", "high"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 2048 };
|
||||
let root: Root | null = null;
|
||||
let host: HTMLDivElement;
|
||||
let writes: ModelsSettings[];
|
||||
let saved: ModelsSettings;
|
||||
let waitForSave: (() => Promise<void>) | undefined;
|
||||
let failSave = false;
|
||||
function button(slot: string, kind = "fast"): HTMLButtonElement {
|
||||
const found = document.querySelector<HTMLButtonElement>(`[data-testid="slot-${kind}-${slot}"]`);
|
||||
assert(found); return found;
|
||||
}
|
||||
async function click(target: HTMLButtonElement) { await act(async () => target.click()); }
|
||||
async function mount() {
|
||||
writes = []; waitForSave = undefined; failSave = false;
|
||||
saved = { strong: { model: chatgpt, thinkingLevel: "high" }, cheap: { model: chatgpt, thinkingLevel: "low" }, vision: { model: api } };
|
||||
const responses: Record<string, unknown> = {
|
||||
"/api/models": { models: [fixture, { ...fixture, provider: "openai" }], slots: saved },
|
||||
"/api/search": { available: false, engine: null, reason: "No search engine is configured." },
|
||||
"/api/keys": { keys: [] }, "/api/settings/prompts": { prober: [], teacher: [] },
|
||||
};
|
||||
globalThis.fetch = async input => { const name = String(input); assert(name in responses); return Response.json(responses[name]); };
|
||||
host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host);
|
||||
function Harness() {
|
||||
const [models, setModels] = React.useState(saved);
|
||||
return <I18nProvider initialGeneral={{ language: "ru" }}><AgentSection agent={undefined} models={models} graph={undefined} compaction={undefined} onSectionChange={() => {}} onCompactionChange={() => {}} onModelsChange={async next => {
|
||||
writes.push(next); await waitForSave?.();
|
||||
if (failSave) throw new Error("offline save failure");
|
||||
saved = next; setModels(next);
|
||||
}} /></I18nProvider>;
|
||||
}
|
||||
await act(async () => { root!.render(<Harness />); });
|
||||
}
|
||||
afterEach(async () => { await act(async () => root?.unmount()); root = null; host?.remove(); globalThis.fetch = originalFetch; });
|
||||
|
||||
it("Fast controls wait for stored truth, lock related edits while saving, and preserve other tiers", async () => {
|
||||
await mount();
|
||||
assert.equal(button("strong").getAttribute("aria-pressed"), "false");
|
||||
assert.equal(button("vision").disabled, true);
|
||||
assert.equal(button("strong").textContent, "Быстро");
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>(resolve => { release = resolve; }); waitForSave = () => pending;
|
||||
await click(button("strong"));
|
||||
assert.equal(writes.length, 1);
|
||||
for (const slot of ["strong", "cheap"]) for (const kind of ["fast", "model", "thinking"]) assert.equal(button(slot, kind).disabled, true);
|
||||
assert.equal(button("strong").getAttribute("aria-pressed"), "false", "unconfirmed billing choices must not appear saved");
|
||||
await act(async () => { release(); }); waitForSave = undefined;
|
||||
assert.equal(button("strong").getAttribute("aria-pressed"), "true");
|
||||
await click(button("cheap"));
|
||||
assert.equal(saved.strong?.fastMode, true); assert.equal(saved.cheap?.fastMode, true);
|
||||
assert.equal(saved.strong?.thinkingLevel, "high"); assert.equal(saved.vision?.fastMode, undefined);
|
||||
});
|
||||
it("failed Fast saves retain the previous choice, show a Russian error, and allow retry", async () => {
|
||||
await mount(); failSave = true;
|
||||
await click(button("strong"));
|
||||
assert.equal(button("strong").getAttribute("aria-pressed"), "false"); assert.equal(button("strong").disabled, false);
|
||||
assert.equal(document.querySelector('[role="alert"]')?.textContent, "Не удалось сохранить настройки моделей");
|
||||
failSave = false; await click(button("strong"));
|
||||
assert.equal(button("strong").getAttribute("aria-pressed"), "true"); assert.equal(document.querySelector('[role="alert"]'), null);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { it } from "node:test";
|
||||
import { sanitizeUserSettings, type ModelsSettings, type UserSettings } from "../src/core/settings.ts";
|
||||
import { resolveConversationSpec, resolveModelSpec, resolveRoleSpec, resolveSettings } from "../src/core/settings-resolution.ts";
|
||||
import { parseModelsSettingsBody } from "../src/server/settings-body.ts";
|
||||
import { clampModelsLevels } from "../src/server/model-catalog.ts";
|
||||
import { composeSlotFast, composeSlotModel } from "../components/settings/agent-section.tsx";
|
||||
import { chatGPTPayload, chatGPTStreamError } from "../src/agent/chatgpt-stream.ts";
|
||||
import { translate } from "../src/core/i18n.ts";
|
||||
|
||||
const chatgpt = { provider: "chatgpt", id: "gpt-5" };
|
||||
const api = { provider: "openai", id: "gpt-5" };
|
||||
it("validates and saves explicit per-tier Fast choices, dropping invalid and unsupported saved flags", () => {
|
||||
const models = { strong: { model: chatgpt, thinkingLevel: "high", fastMode: true }, cheap: { model: chatgpt, fastMode: false }, vision: { model: api } };
|
||||
assert.deepEqual(parseModelsSettingsBody(models), { ok: true, settings: models });
|
||||
assert.deepEqual(sanitizeUserSettings({ version: 1, models })?.models, models);
|
||||
for (const fastMode of ["true", 1, null]) assert.equal(parseModelsSettingsBody({ strong: { model: chatgpt, fastMode } }).ok, false);
|
||||
assert.equal(parseModelsSettingsBody({ strong: { model: api, fastMode: true } }).ok, false);
|
||||
assert.deepEqual(sanitizeUserSettings({ version: 1, models: { strong: { model: api, fastMode: true }, cheap: { model: chatgpt, fastMode: "true" } } })?.models, { strong: { model: api }, cheap: { model: chatgpt } });
|
||||
});
|
||||
it("toggles one tier without altering its model, reasoning, or siblings; a provider swap clears Fast", () => {
|
||||
const models: ModelsSettings = { strong: { model: chatgpt, thinkingLevel: "high" }, cheap: { model: api, thinkingLevel: "low" } };
|
||||
const enabled = composeSlotFast(models, "strong", true);
|
||||
assert.deepEqual(enabled, { ...models, strong: { ...models.strong, fastMode: true } });
|
||||
assert.equal(models.strong?.fastMode, undefined);
|
||||
assert.deepEqual(composeSlotFast(enabled, "cheap", true), enabled);
|
||||
assert.equal(composeSlotModel(enabled, "strong", "chatgpt", "another-model", "low").strong?.fastMode, true);
|
||||
assert.equal(composeSlotModel(enabled, "strong", "openai", "gpt-5", "high").strong?.fastMode, undefined);
|
||||
assert.equal(composeSlotFast(enabled, "strong", false).strong?.fastMode, false);
|
||||
});
|
||||
it("updates existing conversations on the next resolution without replacing their saved model or reasoning", () => {
|
||||
const chat = { model: { provider: "chatgpt", id: "saved-chat-model" }, thinkingLevel: "low" as const };
|
||||
const before = structuredClone(chat);
|
||||
const strong = { provider: "chatgpt", id: "new-session-default", thinkingLevel: "high" as const, fastMode: true };
|
||||
assert.deepEqual(resolveConversationSpec(chat, strong), { provider: "chatgpt", id: "saved-chat-model", thinkingLevel: "low", fastMode: true });
|
||||
assert.equal(resolveConversationSpec(chat, { ...strong, fastMode: false })?.fastMode, undefined);
|
||||
assert.equal(resolveConversationSpec({ ...chat, model: api }, strong)?.fastMode, undefined);
|
||||
assert.equal(resolveConversationSpec(chat, { ...strong, provider: "openai" })?.fastMode, undefined);
|
||||
assert.deepEqual(chat, before);
|
||||
});
|
||||
it("background work uses only its mapped tier's Fast preference and never opts another provider in", () => {
|
||||
const settings: UserSettings = { version: 1, models: { strong: { model: chatgpt, fastMode: true }, cheap: { model: chatgpt, fastMode: false }, vision: { model: chatgpt, fastMode: true } } };
|
||||
assert.equal(resolveRoleSpec("researcher", settings)?.fastMode, undefined);
|
||||
assert.equal(resolveRoleSpec("visualMaker", settings)?.fastMode, true);
|
||||
assert.equal(resolveRoleSpec("pdfReader", settings)?.fastMode, true);
|
||||
settings.models!.cheap!.fastMode = true;
|
||||
assert.equal(resolveRoleSpec("researcher", settings)?.fastMode, true);
|
||||
assert.equal(resolveModelSpec("researcher", { model: api }, settings.models)?.fastMode, undefined);
|
||||
assert.equal(resolveSettings({ version: 1, models: { strong: { model: api, fastMode: true } } }).models.strong?.fastMode, undefined);
|
||||
});
|
||||
it("the server's reasoning clamp preserves Fast rather than silently dropping the opt-in on write", () => {
|
||||
const models: ModelsSettings = { strong: { model: chatgpt, thinkingLevel: "high", fastMode: true }, cheap: { model: api, thinkingLevel: "low" } };
|
||||
assert.equal(clampModelsLevels(models).strong?.fastMode, true);
|
||||
assert.equal(clampModelsLevels(models).strong?.thinkingLevel, "high");
|
||||
});
|
||||
it("Fast is off for old records, request sanitization pins the chosen tier, and unsupported-mode errors are localized", () => {
|
||||
const settings = resolveSettings({ version: 1, models: { strong: { model: chatgpt } } });
|
||||
assert.equal(settings.models.strong?.fastMode, undefined);
|
||||
assert.equal(chatGPTPayload({ input: [], service_tier: "priority" }, "gpt-5").service_tier, undefined);
|
||||
assert.equal(chatGPTPayload({ input: [], service_tier: "flex" }, "gpt-5", true).service_tier, "priority");
|
||||
const error = chatGPTStreamError("400 invalid service_tier");
|
||||
assert(error.includes("Turn off Fast mode"));
|
||||
assert(translate("ru", error).includes("Отключите быстрый режим"));
|
||||
assert(chatGPTStreamError("429 service_tier quota exceeded").includes("https://chatgpt.com/settings/usage"));
|
||||
});
|
||||
+57
-9
@@ -7,7 +7,8 @@ import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||
import { createPracticeAgent } from "../src/agent/teacher-agent.ts";
|
||||
import { sanitizeUserSettings } from "../src/core/settings.ts";
|
||||
import { parseGeneralSettingsBody } from "../src/server/settings-body.ts";
|
||||
import { countLabel, dictationUrl, resolveLanguage, systemLanguage, translate } from "../src/core/i18n.ts";
|
||||
import { resolveSettings } from "../src/core/settings-resolution.ts";
|
||||
import { countLabel, dictationUrl, resolveLanguage, resolveSttLanguage, systemLanguage, translate } from "../src/core/i18n.ts";
|
||||
import { sessionMarkdown } from "../src/core/session-markdown.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { I18nProvider, LanguageSelect } from "../components/i18n.tsx";
|
||||
@@ -29,6 +30,15 @@ describe("language preferences", () => {
|
||||
assert.equal(resolveLanguage("en", "ru-RU"), "en");
|
||||
assert.equal(resolveLanguage("ru", "en-US"), "ru");
|
||||
});
|
||||
it("resolves multilingual only for STT and keeps system defaults independent", () => {
|
||||
assert.equal(resolveSttLanguage("multi", "ru-RU"), "multi");
|
||||
assert.equal(resolveSttLanguage("multi", "en-US"), "multi");
|
||||
assert.equal(resolveLanguage("multi", "ru-RU"), "ru");
|
||||
assert.equal(resolveSttLanguage(undefined, "ru-RU"), "ru");
|
||||
assert.equal(resolveSttLanguage(undefined, "de-DE"), "en");
|
||||
assert.equal(resolveSttLanguage("en", "ru-RU"), "en");
|
||||
assert.equal(resolveSttLanguage("ru", "en-US"), "ru");
|
||||
});
|
||||
it("validates both independent preferences without changing existing general options", () => {
|
||||
const general = { autoUpdate: false, language: "ru", sttLanguage: "en" };
|
||||
assert.deepEqual(parseGeneralSettingsBody(general), { ok: true, settings: general });
|
||||
@@ -38,15 +48,54 @@ describe("language preferences", () => {
|
||||
assert.equal(parseGeneralSettingsBody({ sttLanguage: "fr" }).ok, false);
|
||||
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { language: null, sttLanguage: "fr", autoUpdate: false } })?.general, { autoUpdate: false });
|
||||
});
|
||||
it("accepts a multilingual STT preference without allowing a multilingual interface", () => {
|
||||
const general = { autoUpdate: false, language: "ru", sttLanguage: "multi" };
|
||||
assert.deepEqual(parseGeneralSettingsBody(general), { ok: true, settings: general });
|
||||
assert.deepEqual(sanitizeUserSettings({ version: 1, general })?.general, general);
|
||||
assert.equal(parseGeneralSettingsBody({ language: "multi" }).ok, false);
|
||||
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { language: "multi", sttLanguage: "multi" } })?.general, { sttLanguage: "multi" });
|
||||
const invalid = parseGeneralSettingsBody({ sttLanguage: "auto" });
|
||||
assert(!invalid.ok);
|
||||
assert.equal(translate("ru", invalid.error), "Параметр sttLanguage должен иметь значение «en», «ru» или «multi»");
|
||||
});
|
||||
it("pins an explicit supported language in the STT provider request", () => {
|
||||
for (const language of ["en", "ru"] as const) {
|
||||
for (const language of ["en", "ru", "multi"] as const) {
|
||||
const url = new URL(dictationUrl(language));
|
||||
assert.equal(url.protocol, "wss:");
|
||||
assert.equal(url.hostname, "api.deepgram.com");
|
||||
assert.equal(url.searchParams.get("language"), language);
|
||||
assert.equal(url.searchParams.get("model"), "nova-3");
|
||||
assert.equal(url.searchParams.get("encoding"), "linear16");
|
||||
assert.equal(url.searchParams.get("sample_rate"), "16000");
|
||||
assert.deepEqual(Object.fromEntries(url.searchParams), {
|
||||
model: "nova-3", language, encoding: "linear16", sample_rate: "16000",
|
||||
channels: "1", smart_format: "true", punctuate: "true", endpointing: "300",
|
||||
});
|
||||
}
|
||||
});
|
||||
it("validates and resolves preferred speech languages without changing the recognition mode", () => {
|
||||
const general = { language: "ru", sttLanguage: "multi", sttPreferredLanguages: ["ru", "en", "ru"], autoUpdate: false };
|
||||
const expected = { ...general, sttPreferredLanguages: ["ru", "en"] };
|
||||
const parsed = parseGeneralSettingsBody(general);
|
||||
assert.deepEqual(parsed, { ok: true, settings: expected });
|
||||
const record = sanitizeUserSettings({ version: 1, general });
|
||||
assert.deepEqual(record?.general, expected);
|
||||
assert.deepEqual(resolveSettings(record).general, expected);
|
||||
assert.deepEqual(parseGeneralSettingsBody({ sttPreferredLanguages: [] }), { ok: true, settings: { sttPreferredLanguages: [] } });
|
||||
for (const invalid of [null, "en", ["de"], ["multi"], ["en", 1], {}]) {
|
||||
const result = parseGeneralSettingsBody({ sttPreferredLanguages: invalid });
|
||||
assert(!result.ok);
|
||||
assert.equal(translate("ru", result.error), "Предпочитаемые языки должны быть списком кодов «en» и «ru»");
|
||||
}
|
||||
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { sttPreferredLanguages: ["ru", "de", "en", null, "ru"] } })?.general, { sttPreferredLanguages: ["ru", "en"] });
|
||||
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { sttPreferredLanguages: "ru" } })?.general, {});
|
||||
});
|
||||
it("sends repeated language hints only for multilingual recognition, with no hints by default", () => {
|
||||
assert.deepEqual(new URL(dictationUrl("multi")).searchParams.getAll("language_hint"), []);
|
||||
assert.deepEqual(new URL(dictationUrl("multi", [])).searchParams.getAll("language_hint"), []);
|
||||
const url = new URL(dictationUrl("multi", ["en", "ru", "en"]));
|
||||
assert.deepEqual(url.searchParams.getAll("language_hint"), ["en", "ru"]);
|
||||
assert.equal(url.searchParams.get("language"), "multi");
|
||||
url.searchParams.delete("language_hint");
|
||||
assert.equal(url.toString(), dictationUrl("multi"), "hinting must preserve every existing streaming parameter");
|
||||
for (const language of ["en", "ru"] as const) {
|
||||
assert.equal(dictationUrl(language, ["en", "ru"]), dictationUrl(language));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -90,10 +139,9 @@ describe("translation quality and rendering", () => {
|
||||
it("offers independent saved language choices with localized accessible labels", () => {
|
||||
const html = renderToStaticMarkup(<I18nProvider initialGeneral={{ language: "ru", sttLanguage: "en" }}><LanguageSelect field="sttLanguage" /></I18nProvider>);
|
||||
assert(html.includes('aria-label="Язык распознавания речи"'));
|
||||
assert(html.includes('value="en" selected=""'));
|
||||
assert(html.includes("Язык системы"));
|
||||
assert(html.includes('aria-haspopup="menu"'));
|
||||
assert(html.includes('aria-expanded="false"'));
|
||||
assert(html.includes("English"));
|
||||
assert(html.includes("Русский"));
|
||||
});
|
||||
it("translates dialog text and accessibility labels", () => {
|
||||
const html = renderToStaticMarkup(<I18nProvider system="ru"><AbortTurnBody mode="turn" onClose={() => {}} onConfirm={() => {}} /></I18nProvider>);
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { act } from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, it } from "node:test";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { I18nProvider, LanguageSelect, SttPreferredLanguages, useI18n } from "../components/i18n.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";
|
||||
|
||||
const dom = parseHTML("<html><body></body></html>");
|
||||
Object.assign(globalThis, {
|
||||
window: dom.window, document: dom.document, Element: dom.window.Element,
|
||||
HTMLElement: dom.window.HTMLElement, HTMLInputElement: dom.window.HTMLInputElement,
|
||||
HTMLTextAreaElement: dom.window.HTMLTextAreaElement, CustomEvent: dom.window.CustomEvent,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(performance.now()), 0),
|
||||
cancelAnimationFrame: (id: number) => clearTimeout(id),
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: { languages: ["en-US"] } });
|
||||
Object.assign(dom.window, { innerWidth: 1280, innerHeight: 800 });
|
||||
const originalFetch = globalThis.fetch;
|
||||
let root: Root | null = null;
|
||||
let host: HTMLDivElement;
|
||||
let general: GeneralSettings;
|
||||
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);
|
||||
assert(found, `expected ${selector}`);
|
||||
return found;
|
||||
}
|
||||
function trigger(field: string, compact = false) { return element(`[data-testid="language-select-${field}${compact ? "-compact" : ""}"]`); }
|
||||
async function click(target: HTMLElement) {
|
||||
await act(async () => {
|
||||
target.dispatchEvent(new dom.window.Event("pointerdown", { bubbles: true }));
|
||||
target.dispatchEvent(new dom.window.Event("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
async function pick(text: string) {
|
||||
const option = [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')].find((node) => node.textContent?.includes(text));
|
||||
assert(option, `expected option ${text}`);
|
||||
await click(option);
|
||||
}
|
||||
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);
|
||||
if ((init?.method ?? "GET") === "GET") {
|
||||
assert.equal(url, "/api/settings");
|
||||
return Response.json({ settings: { general } });
|
||||
}
|
||||
assert.equal(url, "/api/settings/general"); assert.equal(init?.method, "PUT");
|
||||
const next = JSON.parse(String(init.body)) as GeneralSettings;
|
||||
writes.push(next);
|
||||
await holdSave?.();
|
||||
if (failSave) return new Response("failed", { status: 503 });
|
||||
general = next;
|
||||
return Response.json({ ok: true });
|
||||
};
|
||||
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 /><SttPreferredLanguages /></>;
|
||||
}
|
||||
await act(async () => { root!.render(<I18nProvider initialGeneral={general}><Controls /></I18nProvider>); });
|
||||
}
|
||||
afterEach(async () => {
|
||||
await act(async () => { root?.unmount(); }); root = null; host?.remove(); globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("picks languages through the shared menu and preserves the other language and update preference", async () => {
|
||||
await mount();
|
||||
assert.equal(document.querySelector("select"), null);
|
||||
await click(trigger("sttLanguage"));
|
||||
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("Русский");
|
||||
assert.deepEqual(general, { language: "ru", sttLanguage: "ru", autoUpdate: false });
|
||||
assert(trigger("sttLanguage", true).textContent?.includes("Русский"));
|
||||
assert.equal(document.querySelector('[role="menu"]'), null);
|
||||
await click(trigger("language")); await pick("Язык системы");
|
||||
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;
|
||||
const onOpen = () => opened++; const onClose = () => opened--;
|
||||
window.addEventListener(MENU_OPEN_EVENT, onOpen); window.addEventListener(MENU_CLOSE_EVENT, onClose);
|
||||
try {
|
||||
await click(trigger("language")); assert.equal(opened, 1);
|
||||
await click(trigger("sttLanguage", true)); assert.equal(opened, 1);
|
||||
assert.equal(trigger("language").getAttribute("aria-expanded"), "false");
|
||||
await act(async () => {
|
||||
const event = new dom.window.Event("keydown", { bubbles: true, cancelable: true });
|
||||
Object.assign(event, { key: "Escape" }); element('[role="menuitemradio"]').dispatchEvent(event);
|
||||
});
|
||||
assert.equal(opened, 0);
|
||||
await click(trigger("language")); await click(document.body);
|
||||
assert.equal(opened, 0); assert.equal(writes.length, 0);
|
||||
} finally { window.removeEventListener(MENU_OPEN_EVENT, onOpen); window.removeEventListener(MENU_CLOSE_EVENT, onClose); }
|
||||
});
|
||||
|
||||
it("locks both STT selectors and closes an open menu when recording begins", async () => {
|
||||
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);
|
||||
assert((trigger("sttLanguage", true) as HTMLButtonElement).disabled);
|
||||
assert(!(trigger("language") as HTMLButtonElement).disabled);
|
||||
await click(trigger("sttLanguage")); assert.equal(document.querySelector('[role="menu"]'), null);
|
||||
await act(async () => recording(false));
|
||||
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("Несколько языков");
|
||||
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, "multi"); assert.equal(document.querySelector('[role="alert"]'), null);
|
||||
});
|
||||
|
||||
it("serializes rapid picks in independent selectors without losing either preference", async () => {
|
||||
await mount(); let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||
holdSave = () => pending;
|
||||
await click(trigger("language")); await pick("English");
|
||||
assert((trigger("language") as HTMLButtonElement).disabled);
|
||||
await click(trigger("sttLanguage")); await pick("Русский");
|
||||
assert.equal(writes.length, 1);
|
||||
await act(async () => { release(); });
|
||||
assert.deepEqual(general, { language: "en", sttLanguage: "ru", autoUpdate: false });
|
||||
assert.equal(writes.length, 2);
|
||||
});
|
||||
|
||||
function preferred(language: "en" | "ru") { return element(`[data-testid="stt-preferred-${language}"]`) as HTMLButtonElement; }
|
||||
|
||||
it("saves RU + EN hints, restores them after reload, and retains the choices in single-language mode", async () => {
|
||||
await mount();
|
||||
assert(preferred("en").disabled); assert(preferred("ru").disabled);
|
||||
assert.equal(writes.length, 0);
|
||||
await click(trigger("sttLanguage")); await pick("Несколько языков");
|
||||
assert(!preferred("en").disabled);
|
||||
await click(preferred("en")); await click(preferred("ru"));
|
||||
assert.deepEqual(general, { language: "ru", sttLanguage: "multi", sttPreferredLanguages: ["en", "ru"], autoUpdate: false });
|
||||
assert.deepEqual(new URL(dictationUrl(resolvedSttLanguage, general.sttPreferredLanguages)).searchParams.getAll("language_hint"), ["en", "ru"]);
|
||||
const saved = general;
|
||||
await act(async () => root!.unmount()); root = null; host.remove();
|
||||
await mount(saved);
|
||||
assert.equal(preferred("en").getAttribute("aria-checked"), "true");
|
||||
assert.equal(preferred("ru").getAttribute("aria-checked"), "true");
|
||||
await click(trigger("sttLanguage", true)); await pick("English");
|
||||
assert(preferred("en").disabled); assert(preferred("ru").disabled);
|
||||
assert.deepEqual(general.sttPreferredLanguages, ["en", "ru"]);
|
||||
assert.deepEqual(new URL(dictationUrl(resolvedSttLanguage, general.sttPreferredLanguages)).searchParams.getAll("language_hint"), []);
|
||||
await click(trigger("sttLanguage")); await pick("Несколько языков");
|
||||
assert.equal(preferred("en").getAttribute("aria-checked"), "true");
|
||||
await click(preferred("en")); await click(preferred("ru"));
|
||||
assert.deepEqual(general.sttPreferredLanguages, []);
|
||||
});
|
||||
|
||||
it("locks preference edits while saving and recording, and reports a failed save without changing the choice", async () => {
|
||||
await mount({ language: "ru", sttLanguage: "multi", autoUpdate: false });
|
||||
failSave = true;
|
||||
await click(preferred("en"));
|
||||
assert.equal(preferred("en").getAttribute("aria-checked"), "false");
|
||||
assert.equal(general.sttPreferredLanguages, undefined);
|
||||
assert.equal(element('[role="alert"]').textContent, "Не удалось сохранить предпочитаемые языки");
|
||||
failSave = false;
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; }); holdSave = () => pending;
|
||||
await click(preferred("en"));
|
||||
assert(preferred("en").disabled); assert(preferred("ru").disabled);
|
||||
await click(preferred("ru"));
|
||||
assert.equal(writes.length, 2);
|
||||
await act(async () => release()); holdSave = undefined;
|
||||
assert.equal(element('[data-testid="stt-preferred-en"]').getAttribute("aria-checked"), "true");
|
||||
assert.equal(document.querySelector('[role="alert"]'), null);
|
||||
await act(async () => recording(true));
|
||||
assert(preferred("en").disabled); assert(preferred("ru").disabled);
|
||||
await click(preferred("en"));
|
||||
assert.deepEqual(general.sttPreferredLanguages, ["en"]);
|
||||
await act(async () => recording(false));
|
||||
assert(!preferred("en").disabled);
|
||||
});
|
||||
|
||||
it("serializes hint and interface-language saves without losing either preference", async () => {
|
||||
await mount({ language: "ru", sttLanguage: "multi", autoUpdate: false });
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; }); holdSave = () => pending;
|
||||
await click(preferred("ru"));
|
||||
await click(trigger("language")); await pick("English");
|
||||
await act(async () => release());
|
||||
assert.deepEqual(general, { language: "en", sttLanguage: "multi", sttPreferredLanguages: ["ru"], autoUpdate: false });
|
||||
});
|
||||
@@ -48,6 +48,12 @@ describe("the settings store", () => {
|
||||
assert.ok(out);
|
||||
assert.deepEqual(out.graph, { enabled: false });
|
||||
});
|
||||
it("persists multilingual STT independently of the interface language and update preference", () => {
|
||||
const general = { language: "ru" as const, sttLanguage: "multi" as const, sttPreferredLanguages: ["en" as const, "ru" as const], autoUpdate: false };
|
||||
writeSettings({ version: 1, general });
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8")).general, general);
|
||||
assert.deepEqual(readSettings()?.general, general);
|
||||
});
|
||||
|
||||
it("a chat section on disk is IGNORED — the field died with per-session models (owner, 2026-09-28)", () => {
|
||||
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
|
||||
|
||||
Reference in New Issue
Block a user