Compare commits

..
5 Commits
26 changed files with 640 additions and 67 deletions
+2 -2
View File
@@ -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.0
# 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.0)
required: true
type: string
+61 -11
View File
@@ -1,8 +1,9 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { resolveLanguage, translator, type GeneralSettings, type Language, type Translator } from "@/src/core";
import { notifySettingsSaved, useSettingsSaved } from "./settings-signal";
import { MenuShell } from "./menu-shell";
type I18nValue = {
language: Language;
@@ -78,19 +79,68 @@ 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 selected = general[field] ?? "auto";
const label = t(field === "language" ? "Interface language" : "Speech recognition language");
const testid = `language-select-${field}${compact ? "-compact" : ""}`;
const options = [
{ value: "auto", label: compact ? `${t("Auto")} · ${sttLanguage === "ru" ? "RU" : "EN"}` : t("System default") },
{ value: "en", label: "English" },
{ value: "ru", label: "Русский" },
] 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" | Language) => {
if (locked) return;
setOpen(false);
trigger.current?.focus();
if (value === selected) 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">{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>;
}
+45 -8
View File
@@ -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,12 +666,13 @@ export function AgentSection({
label={SLOT_META[slot].label}
hint={SLOT_META[slot].roster}
testid={`slot-row-${slot}`}
className="flex-wrap gap-y-2"
>
{/* The anchor for the outside-click contract is the
span holding BOTH triggers of the open menu. */}
<span
ref={thisMenuOpen ? menuAnchorRef : undefined}
className="relative flex items-center gap-2"
className="relative ml-auto flex max-w-full flex-wrap items-center justify-end gap-2"
>
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
<MenuTrigger
@@ -660,6 +683,7 @@ export function AgentSection({
open={thisMenuOpen === "model"}
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
widthClass="min-w-20"
disabled={savingModels}
/>
<MenuTrigger
// Legacy display (chunk 2 item 4): no stored level + a known
@@ -672,7 +696,19 @@ export function AgentSection({
open={thisMenuOpen === "thinking"}
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })}
widthClass="w-20"
disabled={savingModels}
/>
<Hint label={t(provider === "chatgpt" ? "Fast mode uses more ChatGPT allowance." : "Fast mode is available only for ChatGPT models.")}>
<span className="inline-flex">
<button type="button" data-testid={`slot-fast-${slot}`} aria-label={t("{v0} fast mode", { v0: t(SLOT_META[slot].label) })}
aria-pressed={provider === "chatgpt" && override?.fastMode === true} disabled={provider !== "chatgpt" || !chosen || savingModels}
onClick={() => { setOpenMenu(null); void updateModels(composeSlotFast(models, slot, !override?.fastMode)); }}
className={cn("flex h-7 items-center gap-1 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-40",
provider === "chatgpt" && override?.fastMode ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50")}>
<ZapIcon className="size-3.5" aria-hidden /><span>{t("Fast")}</span>
</button>
</span>
</Hint>
{thisMenuOpen === "model" && (
<MenuShell
preferredSide="down"
@@ -689,7 +725,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 +749,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 +798,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 +816,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",
)}
>
+4 -2
View File
@@ -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"
/>
);
}
}
+1 -1
View File
@@ -399,7 +399,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 -1
View File
@@ -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.0 release on Gitea, see [Gitea releases](gitea-release.md).
The three-platform GitHub process below documents the upstream reference.
## Platforms
+63 -13
View File
@@ -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.0 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.0 has these attachments:
- `LearnHarness-0.4.0-x86_64.AppImage`
- `LearnHarness-0.4.0-amd64.deb`
- `LearnHarness-0.5.0-x86_64.AppImage`
- `LearnHarness-0.5.0-amd64.deb`
- `latest-linux.yml`
- `SHA256SUMS`
## Preparing the Gitea draft
## Preparing Windows packages
Use a clean checkout of the same `v0.5.0` tag on Windows x64 with Node 26 and Git
for Windows. Keep build tools and logs outside the checkout. The Windows
release checkout is `~/Documents/Gitea/learnharness-release-0.5.0`, linked to the
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.0-x64-setup.exe`
- `LearnHarness-0.5.0-x64-setup.exe.blockmap`
- `latest.yml`
The combined release has **seven attachments**, including one `SHA256SUMS`
covering all six other files. Verify that `latest.yml` names the Windows
installer exactly, reports version `0.5.0`, and matches the installer's actual
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.0 -m 'LearnHarness 0.5.0'
git push gitea main v0.5.0
```
Supply `GITEA_TOKEN` only to the upload command. A token file may remain outside
@@ -75,7 +117,15 @@ 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.0.md`.
This uploader and its remote verifier enforce a **Linux-only** inventory. They
will refuse a combined Linux/Windows draft. For the combined 0.5.0 release, add
the verified Windows attachments to the existing draft and replace `SHA256SUMS`
with the combined manifest. Keep the Linux installers and feed unchanged. Check
all seven attachment names, sizes, and actual downloaded hashes against the
verified local builds; update the bilingual release notes to include Windows.
Do not publish until both platforms' checks pass.
A separate verification can be repeated without changing the draft:
@@ -92,12 +142,12 @@ public `latest` feed and in-app automatic updates expose this version.
workflow with an existing release tag as input. It requires a native Linux x64
runner labeled `ubuntu-24.04`, the label used by upstream's Linux release job,
plus the native Electron dependencies and `xvfb-run`. There were no runners
registered when 0.4.0 was prepared, so its packages are built locally. A tag push
registered when 0.5.0 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.0 draft was assembled from local native builds.
Windows has its own standalone payload and smoke checks, as described above.
+8 -7
View File
@@ -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 в разделе **Настройки → Провайдеры**.
Для занятий с ИИ подключите провайдера и выберите модели для всех нужных уровней.
+62
View File
@@ -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 не подписан, как и в исходном проекте.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "learnharness",
"version": "0.4.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "learnharness",
"version": "0.4.0",
"version": "0.5.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@earendil-works/pi-agent-core": "1.4.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "learnharness",
"version": "0.4.0",
"version": "0.5.0",
"license": "AGPL-3.0-only",
"author": {
"name": "Amos Blomqvist",
+1 -1
View File
@@ -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 {
+11 -5
View File
@@ -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
View File
@@ -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 });
},
};
+7 -1
View File
@@ -835,5 +835,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."
}
+7 -1
View File
@@ -871,5 +871,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. Отключите быстрый режим или выберите другую модель."
}
+8 -1
View File
@@ -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 } : {}),
};
}
+9 -1
View File
@@ -193,6 +193,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
@@ -490,7 +492,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;
}
+1 -1
View File
@@ -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;
+11 -1
View File
@@ -348,7 +348,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 };
}
+12 -3
View File
@@ -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(); }
+35
View File
@@ -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" });
+74
View File
@@ -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);
});
+65
View File
@@ -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"));
});
+2 -3
View File
@@ -90,10 +90,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>);
+145
View File
@@ -0,0 +1,145 @@
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, useI18n } from "../components/i18n.tsx";
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "../components/menu-shell.tsx";
import { translate, type GeneralSettings } from "../src/core/index.ts";
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;
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() {
general = { language: "ru", sttLanguage: "en", autoUpdate: false };
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;
return <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /></>;
}
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, 3);
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 });
});
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(); 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);
});
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, "ru"); 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);
});