feat: refine language controls and add per-tier ChatGPT Fast mode

This commit is contained in:
2026-10-25 21:19:48 +03:00
parent f8ce0f21ef
commit 8c564946ef
17 changed files with 488 additions and 37 deletions
+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"
>
<div
role="radiogroup"
aria-label={t("Quizzing mechanics")}
className="flex rounded-lg border border-border/60 bg-muted/30 p-0.5"
className="ml-auto 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)
+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 };
}
+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);
});