From 8c564946effbed52c143c62571214e7e5f9b635c Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 25 Oct 2026 21:19:48 +0300 Subject: [PATCH] feat: refine language controls and add per-tier ChatGPT Fast mode --- components/i18n.tsx | 72 ++++++++-- components/settings/agent-section.tsx | 53 ++++++-- components/settings/controls.tsx | 6 +- components/settings/settings-dialog.tsx | 2 +- src/agent/chatgpt-stream.ts | 16 ++- src/agent/models.ts | 3 +- src/core/locales/en.json | 8 +- src/core/locales/ru.json | 8 +- src/core/settings-resolution.ts | 9 +- src/core/settings.ts | 10 +- src/server/model-catalog.ts | 2 +- src/server/settings-body.ts | 12 +- tests/chatgpt-stream.test.ts | 35 +++++ tests/fast-mode-interaction.test.tsx | 74 +++++++++++ tests/fast-mode.test.ts | 65 +++++++++ tests/i18n.test.tsx | 5 +- tests/language-select-interaction.test.tsx | 145 +++++++++++++++++++++ 17 files changed, 488 insertions(+), 37 deletions(-) create mode 100644 tests/fast-mode-interaction.test.tsx create mode 100644 tests/fast-mode.test.ts create mode 100644 tests/language-select-interaction.test.tsx diff --git a/components/i18n.tsx b/components/i18n.tsx index 86806c0..3dc251b 100644 --- a/components/i18n.tsx +++ b/components/i18n.tsx @@ -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(null); + const [open, setOpen] = useState(false); + const trigger = useRef(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 - + {options.find((option) => option.value === selected)?.label} + ▾ + + {menuOpen && + + } {error && {t(error)}} ; } diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index 9c58b23..8b5b9df 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -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; /** Writes the whole compaction section (replace-PUT discipline). */ onCompactionChange: (next: CompactionSettings) => void; }) { const { t } = useI18n(); const [catalog, setCatalog] = useState(null); + const [savingModels, setSavingModels] = useState(false); + const [modelsError, setModelsError] = useState(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(null); @@ -617,6 +638,7 @@ export function AgentSection({ return ( <> + {modelsError &&

{t(modelsError)}

} {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. */} {slot !== "strong" && <>} setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })} widthClass="min-w-20" + disabled={savingModels} /> setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })} widthClass="w-20" + disabled={savingModels} /> + + + + + {thisMenuOpen === "model" && ( { - 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" >
{(["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", )} > diff --git a/components/settings/controls.tsx b/components/settings/controls.tsx index c4d6091..7f3f397 100644 --- a/components/settings/controls.tsx +++ b/components/settings/controls.tsx @@ -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 ( -
+
{t(label)} {hint && {t(hint)}} @@ -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" /> ); -} \ No newline at end of file +} diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index f631869..ea5f066 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -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) diff --git a/src/agent/chatgpt-stream.ts b/src/agent/chatgpt-stream.ts index c48a2a9..cb8ca9f 100644 --- a/src/agent/chatgpt-stream.ts +++ b/src/agent/chatgpt-stream.ts @@ -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 { +export function chatGPTPayload(raw: unknown, selectedModelId: string, fastMode = false): Record { const payload = raw as Record; const out: Record = { 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): 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 { +export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDispatchLifecycle, fastMode = false): StreamFunction { 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); }, }); diff --git a/src/agent/models.ts b/src/agent/models.ts index 75c0b7b..6aa3dfb 100644 --- a/src/agent/models.ts +++ b/src/agent/models.ts @@ -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 }); }, }; diff --git a/src/core/locales/en.json b/src/core/locales/en.json index 34ce5ba..752521a 100644 --- a/src/core/locales/en.json +++ b/src/core/locales/en.json @@ -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." } diff --git a/src/core/locales/ru.json b/src/core/locales/ru.json index ae99ff1..baa0c3b 100644 --- a/src/core/locales/ru.json +++ b/src/core/locales/ru.json @@ -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. Отключите быстрый режим или выберите другую модель." } diff --git a/src/core/settings-resolution.ts b/src/core/settings-resolution.ts index 6184801..da8534f 100644 --- a/src/core/settings-resolution.ts +++ b/src/core/settings-resolution.ts @@ -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 } : {}), }; } diff --git a/src/core/settings.ts b/src/core/settings.ts index 3265b20..608576a 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -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): 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; + out[slot] = { + ...cleaned, + ...(cleaned.model?.provider === "chatgpt" && typeof value.fastMode === "boolean" ? { fastMode: value.fastMode } : {}), + }; + } } return out; } diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index 0aa0b2a..ad41dca 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -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; diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index 8a1347e..ede9c56 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -348,7 +348,17 @@ export function parseModelsSettingsBody(body: unknown): SettingsBodyResult { + 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" }); diff --git a/tests/fast-mode-interaction.test.tsx b/tests/fast-mode-interaction.test.tsx new file mode 100644 index 0000000..782587a --- /dev/null +++ b/tests/fast-mode-interaction.test.tsx @@ -0,0 +1,74 @@ +import React, { act } from "react"; +(globalThis as Record).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(""); +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) | undefined; +let failSave = false; +function button(slot: string, kind = "fast"): HTMLButtonElement { + const found = document.querySelector(`[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 = { + "/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 {}} onCompactionChange={() => {}} onModelsChange={async next => { + writes.push(next); await waitForSave?.(); + if (failSave) throw new Error("offline save failure"); + saved = next; setModels(next); + }} />; + } + await act(async () => { root!.render(); }); +} +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(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); +}); diff --git a/tests/fast-mode.test.ts b/tests/fast-mode.test.ts new file mode 100644 index 0000000..e7904c5 --- /dev/null +++ b/tests/fast-mode.test.ts @@ -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")); +}); diff --git a/tests/i18n.test.tsx b/tests/i18n.test.tsx index d366075..adaa903 100644 --- a/tests/i18n.test.tsx +++ b/tests/i18n.test.tsx @@ -90,10 +90,9 @@ describe("translation quality and rendering", () => { it("offers independent saved language choices with localized accessible labels", () => { const html = renderToStaticMarkup(); 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( {}} onConfirm={() => {}} />); diff --git a/tests/language-select-interaction.test.tsx b/tests/language-select-interaction.test.tsx new file mode 100644 index 0000000..c4a1c5b --- /dev/null +++ b/tests/language-select-interaction.test.tsx @@ -0,0 +1,145 @@ +import React, { act } from "react"; +(globalThis as Record).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(""); +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) | undefined; +let recording: (active: boolean) => void; + +function element(selector: string): HTMLElement { + const found = document.querySelector(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('[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 <>; + } + await act(async () => { root!.render(); }); +} +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((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); +});