2026-09-22 13:54:28 +03:00
|
|
|
|
"use client";
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The first-run setup flow (model-slots wave, 2026-09-20, owner design
|
|
|
|
|
|
* "A — composer CTA + send interception"): chats cannot start until the
|
|
|
|
|
|
* user has picked all three model slots — there are no defaults for 33
|
|
|
|
|
|
* providers. The graph view's new-session dock (the app's home, where
|
|
|
|
|
|
* chats start) shows a "Set up your models" CTA while unconfigured;
|
|
|
|
|
|
* submitting a topic — or sending on /chat/new — opens THIS dialog with
|
2026-10-09 04:54:24 +03:00
|
|
|
|
* the draft preserved; Save stores the slots — the draft WAITS in the
|
|
|
|
|
|
* composer, the learner sends it themselves (owner ruling 2026-10-09:
|
|
|
|
|
|
* saving models must never send the message).
|
2026-09-22 13:54:28 +03:00
|
|
|
|
*
|
|
|
|
|
|
* STAGED BY DEPENDENCY (owner, 2026-09-20: "first keys, then models —
|
|
|
|
|
|
* there's no point presenting the model picker before the user has set a
|
|
|
|
|
|
* key because there are no models to pick then"):
|
2026-10-26 19:43:09 +02:00
|
|
|
|
* Unknown catalog — neutral loading (or error/Retry), never a key screen.
|
|
|
|
|
|
* Stage 1 — no keyed provider or saved selection: a SELF-CONTAINED first-key screen
|
2026-09-24 23:20:59 +03:00
|
|
|
|
* (zero-keys wave, owner 2026-09-25): provider picker + one key
|
|
|
|
|
|
* input + save via PUT /api/keys/:id. The dialog STAYS MOUNTED —
|
|
|
|
|
|
* saving dispatches the "keys" signal, the fetch effect re-runs on
|
|
|
|
|
|
* it, and the stage flips to the pickers on its own. No Settings
|
|
|
|
|
|
* round-trip anymore; the draft lives in the caller's state,
|
|
|
|
|
|
* untouched throughout.
|
2026-09-22 13:54:28 +03:00
|
|
|
|
* Stage 2 — Strong → Cheap → Vision pickers over the FEDERATED catalog
|
|
|
|
|
|
* (keyed providers only), the vision slot filtered to image-capable
|
|
|
|
|
|
* models, in the app's shared dropdown language (ModelMenuList).
|
2026-10-27 16:12:13 +02:00
|
|
|
|
* Saved disconnected selections and edits are retained internally in
|
|
|
|
|
|
* the exclusive provider stage. Connecting with all slots already saved
|
|
|
|
|
|
* closes setup; otherwise the pickers require only the missing slots.
|
2026-09-22 13:54:28 +03:00
|
|
|
|
* All three required (owner: no skip, no defaults); Save PUTs the
|
|
|
|
|
|
* models section and reports onDone(models).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Closable without configuring (Esc/backdrop/✕) — browsing the app stays
|
|
|
|
|
|
* possible; the GATE is the send interception, not the dialog. The
|
|
|
|
|
|
* settings-dialog visual language: dim backdrop, border rounded-md card,
|
|
|
|
|
|
* CMU title, z-50.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-09-22 16:56:44 +03:00
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
2026-09-22 13:54:28 +03:00
|
|
|
|
import { createPortal } from "react-dom";
|
2026-10-17 00:38:57 +03:00
|
|
|
|
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
2026-09-22 13:54:28 +03:00
|
|
|
|
import type React from "react";
|
|
|
|
|
|
import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings } from "@/src/core";
|
|
|
|
|
|
import { prettyModel } from "@/components/usage-format";
|
|
|
|
|
|
import { ModelMenuList } from "@/components/settings/model-menu";
|
2026-09-22 16:56:44 +03:00
|
|
|
|
import { MenuShell } from "@/components/menu-shell";
|
2026-09-24 23:20:59 +03:00
|
|
|
|
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
2026-10-26 19:43:09 +02:00
|
|
|
|
import { useModelCatalog } from "@/components/model-catalog";
|
2026-10-27 16:12:13 +02:00
|
|
|
|
import { savedSlotsConfigured } from "@/components/setup-cta";
|
2026-09-25 01:52:20 +03:00
|
|
|
|
import { Hint } from "@/components/ui/hint";
|
2026-09-24 23:20:59 +03:00
|
|
|
|
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
|
|
|
|
|
|
import { ProviderMenuList } from "@/components/settings/model-menu";
|
2026-10-25 00:27:52 +03:00
|
|
|
|
import { ChatGPTBackgroundAccess } from "@/components/settings/chatgpt-connection";
|
|
|
|
|
|
import { NativeSubscriptionBackgroundAccess, SubscriptionConnections } from "@/components/settings/subscription-connections";
|
2026-09-22 22:05:27 +03:00
|
|
|
|
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
|
|
|
|
|
import { ThinkingMenuList } from "@/components/settings/model-menu";
|
|
|
|
|
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
2026-09-22 13:54:28 +03:00
|
|
|
|
|
|
|
|
|
|
/** The pickers' slot identity — same roster language as the Agent
|
|
|
|
|
|
* section's slot rows. */
|
|
|
|
|
|
const SLOT_META: Record<ModelSlotName, { label: string; roster: string }> = {
|
2026-10-04 21:34:24 +03:00
|
|
|
|
strong: { label: "Strong", roster: "Prober, teacher" },
|
2026-09-23 15:37:34 +03:00
|
|
|
|
cheap: { label: "Cheap", roster: "Researcher, demo builder, extractor, auditor" },
|
2026-09-22 13:54:28 +03:00
|
|
|
|
vision: { label: "Vision", roster: "Visual maker + PDF reader" },
|
|
|
|
|
|
};
|
|
|
|
|
|
const SLOT_ORDER: readonly ModelSlotName[] = ["strong", "cheap", "vision"];
|
|
|
|
|
|
|
|
|
|
|
|
/** The GET /api/models response fields the flow consumes. */
|
|
|
|
|
|
interface ModelsBody {
|
|
|
|
|
|
models: ModelCatalogEntry[];
|
|
|
|
|
|
providers: { id: string; keyPresent: boolean }[];
|
2026-09-22 18:12:57 +03:00
|
|
|
|
/** The three tiers' RESOLVED pins (BYOK: NULL per unset tier) — the
|
|
|
|
|
|
* PREFILL + "needed" marks (owner, 2026-09-22): pinned tiers open
|
|
|
|
|
|
* with their pick, unpinned tiers carry the mark. */
|
2026-09-22 22:05:27 +03:00
|
|
|
|
slots?: Record<ModelSlotName, { model: { provider: string; id: string }; thinkingLevel?: ThinkingLevel } | null>;
|
2026-09-22 13:54:28 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Pure: which stage the flow opens at — no keyed provider = keys first
|
|
|
|
|
|
* (owner's dependency ordering). Exported for tests. */
|
|
|
|
|
|
export function setupStage(
|
|
|
|
|
|
providers: { id: string; keyPresent: boolean }[] | null,
|
2026-10-26 19:43:09 +02:00
|
|
|
|
): "loading" | "keys" | "pickers" {
|
|
|
|
|
|
if (providers === null) return "loading";
|
2026-09-22 13:54:28 +03:00
|
|
|
|
return providers.some((p) => p.keyPresent) ? "pickers" : "keys";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-26 19:43:09 +02:00
|
|
|
|
type ModelSelection = Pick<ModelCatalogEntry, "provider" | "id">;
|
|
|
|
|
|
|
|
|
|
|
|
/** Saved identity is independent of selectable metadata. A disconnected
|
|
|
|
|
|
* pin still prefills; only the menu is restricted to the live catalog. */
|
2026-09-22 18:12:57 +03:00
|
|
|
|
export function prefillFromSlots(
|
|
|
|
|
|
models: ModelCatalogEntry[],
|
|
|
|
|
|
slots: Record<ModelSlotName, { model: { provider: string; id: string } } | null> | undefined,
|
2026-10-26 19:43:09 +02:00
|
|
|
|
): Partial<Record<ModelSlotName, ModelSelection>> {
|
|
|
|
|
|
const pre: Partial<Record<ModelSlotName, ModelSelection>> = {};
|
2026-09-22 18:12:57 +03:00
|
|
|
|
for (const slot of SLOT_ORDER) {
|
|
|
|
|
|
const pin = slots?.[slot];
|
|
|
|
|
|
const entry = pin?.model
|
|
|
|
|
|
? models.find((m) => m.provider === pin.model!.provider && m.id === pin.model!.id)
|
|
|
|
|
|
: undefined;
|
2026-10-26 19:43:09 +02:00
|
|
|
|
if (pin?.model) pre[slot] = entry ?? pin.model;
|
2026-09-22 18:12:57 +03:00
|
|
|
|
}
|
|
|
|
|
|
return pre;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 22:05:27 +03:00
|
|
|
|
/** Level prefill (chunk 2): a pinned tier opens with its STORED level
|
|
|
|
|
|
* as-is (Save clamps server-side if the ladder lapsed), else the
|
|
|
|
|
|
* ordering's level for that model — the level a fresh pick would
|
|
|
|
|
|
* populate (owner: the ordering's only purpose is first assignment).
|
|
|
|
|
|
* Exported pure for tests. */
|
|
|
|
|
|
export function prefillLevelFromPin(
|
|
|
|
|
|
entry: ModelCatalogEntry,
|
|
|
|
|
|
pin: { thinkingLevel?: ThinkingLevel } | null | undefined,
|
|
|
|
|
|
): ThinkingLevel {
|
|
|
|
|
|
return populateLevel(pin?.thinkingLevel, entry.supportedLevels);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 23:20:59 +03:00
|
|
|
|
/** Stage 1's pure body — exported for SSR pins (the delete/settings
|
|
|
|
|
|
* body pattern). The wrapper owns state + effects + the portal.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The first-key screen (owner, zero-keys wave): provider picker (the
|
|
|
|
|
|
* app's shared dropdown language, ProviderMenuList), one paste input,
|
|
|
|
|
|
* Save. A save that 400s keeps the line inline (the keys section's
|
|
|
|
|
|
* shape-gates write user-facing error text). */
|
|
|
|
|
|
export function SetupKeysBody({
|
|
|
|
|
|
providers,
|
|
|
|
|
|
provider,
|
|
|
|
|
|
value,
|
|
|
|
|
|
saving,
|
|
|
|
|
|
error,
|
|
|
|
|
|
menuOpen,
|
|
|
|
|
|
onToggleMenu,
|
|
|
|
|
|
onPick,
|
|
|
|
|
|
onValue,
|
|
|
|
|
|
onSave,
|
|
|
|
|
|
menuAnchor,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
/** The full provider roster (GET /api/keys providers) — undefined
|
|
|
|
|
|
* while it loads; the picker renders an honest empty state then. */
|
|
|
|
|
|
providers: ProviderKeyInfo[] | undefined;
|
|
|
|
|
|
/** The chosen provider; null = the picker trigger reads "Pick a
|
|
|
|
|
|
* provider…". */
|
|
|
|
|
|
provider: ProviderKeyInfo | null;
|
|
|
|
|
|
value: string;
|
|
|
|
|
|
saving: boolean;
|
|
|
|
|
|
error: string | null;
|
|
|
|
|
|
menuOpen: boolean;
|
|
|
|
|
|
onToggleMenu: () => void;
|
|
|
|
|
|
onPick: (p: ProviderKeyInfo) => void;
|
|
|
|
|
|
onValue: (v: string) => void;
|
|
|
|
|
|
onSave: () => void;
|
|
|
|
|
|
/** The MenuShell anchor — wired by the wrapper (its triggerRef). */
|
|
|
|
|
|
menuAnchor: React.RefObject<HTMLDivElement | null>;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
2026-10-17 00:38:57 +03:00
|
|
|
|
Choose your AI provider and paste its API key. You’ll pick your models next.
|
2026-09-24 23:20:59 +03:00
|
|
|
|
</p>
|
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
|
<div ref={menuAnchor} className="relative max-w-96">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label="Provider"
|
|
|
|
|
|
data-testid="setup-key-provider"
|
|
|
|
|
|
aria-expanded={menuOpen}
|
|
|
|
|
|
onClick={onToggleMenu}
|
|
|
|
|
|
className="flex h-7 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"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="min-w-0 flex-1 truncate text-left">{provider ? provider.label : "Pick a provider…"}</span>
|
|
|
|
|
|
<span aria-hidden className="text-muted-foreground">
|
|
|
|
|
|
▾
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{menuOpen && (
|
|
|
|
|
|
<MenuShell
|
|
|
|
|
|
anchorRef={menuAnchor}
|
|
|
|
|
|
testid="setup-key-provider-menu"
|
|
|
|
|
|
preferredSide="down"
|
|
|
|
|
|
align="left"
|
|
|
|
|
|
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ProviderMenuList providers={providers ?? []} onPick={onPick} />
|
|
|
|
|
|
</MenuShell>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{provider && (
|
|
|
|
|
|
<div className="flex max-w-96 items-center gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={value}
|
|
|
|
|
|
onChange={(e) => onValue(e.target.value)}
|
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
if (e.key === "Enter" && value.trim()) onSave();
|
|
|
|
|
|
}}
|
|
|
|
|
|
placeholder="Paste key…"
|
|
|
|
|
|
aria-label={`${provider.label} key`}
|
|
|
|
|
|
data-testid="setup-key-input"
|
|
|
|
|
|
disabled={saving}
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onSave}
|
|
|
|
|
|
disabled={saving || !value.trim()}
|
|
|
|
|
|
data-testid="setup-key-save"
|
|
|
|
|
|
className="shrink-0 rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
|
|
|
|
|
|
>
|
|
|
|
|
|
{saving ? "Saving…" : "Save key"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{error && (
|
|
|
|
|
|
<p role="alert" data-testid="setup-key-error" className="text-xs text-red-400">
|
|
|
|
|
|
{error}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-10-25 00:27:52 +03:00
|
|
|
|
<div className="mt-2 border-t border-border/60 pt-4"><h3 className="mb-3 font-ui text-xs text-muted-foreground">Subscriptions</h3><SubscriptionConnections setup /></div>
|
2026-09-24 23:20:59 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-26 19:43:09 +02:00
|
|
|
|
function prefillLevels(body: ModelsBody | undefined): Partial<Record<ModelSlotName, ThinkingLevel>> {
|
|
|
|
|
|
const levels: Partial<Record<ModelSlotName, ThinkingLevel>> = {};
|
|
|
|
|
|
for (const slot of SLOT_ORDER) {
|
|
|
|
|
|
const pin = body?.slots?.[slot];
|
|
|
|
|
|
const entry = body?.models.find((m) => m.provider === pin?.model.provider && m.id === pin.model.id);
|
|
|
|
|
|
if (pin?.thinkingLevel !== undefined) levels[slot] = pin.thinkingLevel;
|
|
|
|
|
|
else if (entry) levels[slot] = prefillLevelFromPin(entry, pin);
|
|
|
|
|
|
}
|
|
|
|
|
|
return levels;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface SetupDialogProps {
|
2026-09-22 13:54:28 +03:00
|
|
|
|
open: boolean;
|
|
|
|
|
|
onClose: () => void;
|
2026-10-26 19:43:09 +02:00
|
|
|
|
/** Saving never sends the caller's draft. */
|
2026-09-22 13:54:28 +03:00
|
|
|
|
onDone: (models: ModelsSettings) => void;
|
2026-10-26 19:43:09 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function SetupDialog(props: SetupDialogProps) {
|
|
|
|
|
|
// An open owns its draft. Mount-time cache seeding avoids a key-screen
|
|
|
|
|
|
// first frame, and reopening cannot reuse a previous open's edits.
|
|
|
|
|
|
return props.open ? <OpenSetupDialog {...props} /> : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
2026-10-17 00:38:57 +03:00
|
|
|
|
const backdropDismiss = useBackdropDismiss(onClose);
|
2026-10-26 19:43:09 +02:00
|
|
|
|
const { catalog, failed, refresh } = useModelCatalog();
|
2026-10-27 16:12:13 +02:00
|
|
|
|
const providerStageSeen = useRef(false);
|
|
|
|
|
|
const connectionCloseRequested = useRef(false);
|
2026-10-26 19:43:09 +02:00
|
|
|
|
const [picked, setPicked] = useState(() => prefillFromSlots(catalog?.models ?? [], catalog?.slots));
|
|
|
|
|
|
const [levels, setLevels] = useState(() => prefillLevels(catalog));
|
|
|
|
|
|
const editedSlots = useRef(new Set<ModelSlotName>());
|
2026-09-22 13:54:28 +03:00
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
2026-09-22 22:05:27 +03:00
|
|
|
|
const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(null);
|
2026-09-24 23:20:59 +03:00
|
|
|
|
// Stage 1's first-key screen (zero-keys wave): the provider roster,
|
|
|
|
|
|
// the chosen provider + pasted value, its busy/error pair, and its own
|
|
|
|
|
|
// menu-open bit (the slot menus' dismissal machinery covers it too —
|
|
|
|
|
|
// see the trigger testid branch below).
|
|
|
|
|
|
const [keyProviders, setKeyProviders] = useState<ProviderKeyInfo[] | undefined>(undefined);
|
|
|
|
|
|
const [keyProvider, setKeyProvider] = useState<ProviderKeyInfo | null>(null);
|
|
|
|
|
|
const [keyValue, setKeyValue] = useState("");
|
|
|
|
|
|
const [keySaving, setKeySaving] = useState(false);
|
|
|
|
|
|
const [keyError, setKeyError] = useState<string | null>(null);
|
|
|
|
|
|
const [keyMenuOpen, setKeyMenuOpen] = useState(false);
|
2026-10-09 04:54:24 +03:00
|
|
|
|
// Stage 1's provider-picker trigger wrapper — the shared shell's anchor.
|
2026-10-26 19:43:09 +02:00
|
|
|
|
// Stage 2's slot menus anchor by anchorTestid instead — see the slot
|
|
|
|
|
|
// MenuShell below for why a freshly attached ref cannot work there.
|
2026-09-24 23:20:59 +03:00
|
|
|
|
const keyTriggerRef = useRef<HTMLDivElement>(null);
|
2026-09-22 13:54:28 +03:00
|
|
|
|
|
2026-09-24 23:20:59 +03:00
|
|
|
|
// The keys signal (zero-keys wave): keys-section saves/deletes AND
|
|
|
|
|
|
// THIS dialog's own first-key save dispatch it; the fetch effect below
|
2026-10-27 16:12:13 +02:00
|
|
|
|
// re-runs on it, so saving a key widens the catalog. Incomplete saved
|
|
|
|
|
|
// slots advance to pickers; complete saved slots just close setup.
|
2026-09-24 23:20:59 +03:00
|
|
|
|
const [keysTick, setKeysTick] = useState(0);
|
|
|
|
|
|
useSettingsSaved((section) => {
|
|
|
|
|
|
if (section === "keys") setKeysTick((t) => t + 1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-22 13:54:28 +03:00
|
|
|
|
useEffect(() => {
|
2026-10-24 12:17:08 +03:00
|
|
|
|
let cancelled = false;
|
2026-09-24 23:20:59 +03:00
|
|
|
|
fetch("/api/keys")
|
|
|
|
|
|
.then((r) => r.json())
|
2026-10-24 12:17:08 +03:00
|
|
|
|
.then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); })
|
|
|
|
|
|
.catch(() => { if (!cancelled) setKeyProviders([]); });
|
|
|
|
|
|
return () => { cancelled = true; };
|
2026-10-26 19:43:09 +02:00
|
|
|
|
}, [keysTick]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!catalog) return;
|
|
|
|
|
|
const pre = prefillFromSlots(catalog.models, catalog.slots);
|
|
|
|
|
|
const preLevels = prefillLevels(catalog);
|
|
|
|
|
|
// Fresh metadata is always used, but a response cannot reset an
|
|
|
|
|
|
// edited slot, including a level chosen while the request was pending.
|
|
|
|
|
|
setPicked((prev) => {
|
|
|
|
|
|
const next = { ...prev };
|
|
|
|
|
|
for (const slot of SLOT_ORDER) if (!editedSlots.current.has(slot)) {
|
|
|
|
|
|
if (pre[slot]) next[slot] = pre[slot]; else delete next[slot];
|
|
|
|
|
|
}
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
setLevels((prev) => {
|
|
|
|
|
|
const next = { ...prev };
|
|
|
|
|
|
for (const slot of SLOT_ORDER) if (!editedSlots.current.has(slot)) {
|
|
|
|
|
|
if (preLevels[slot] !== undefined) next[slot] = preLevels[slot]; else delete next[slot];
|
|
|
|
|
|
}
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [catalog]);
|
2026-09-22 13:54:28 +03:00
|
|
|
|
|
2026-10-27 16:12:13 +02:00
|
|
|
|
const stage = setupStage(catalog?.providers ?? null);
|
|
|
|
|
|
const closeAfterConnection = stage === "pickers" && providerStageSeen.current && savedSlotsConfigured(catalog?.slots);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (stage === "keys") providerStageSeen.current = true;
|
|
|
|
|
|
if (closeAfterConnection && !connectionCloseRequested.current) {
|
|
|
|
|
|
connectionCloseRequested.current = true;
|
|
|
|
|
|
onClose(); // Connection recovery is not a model-settings save.
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [stage, closeAfterConnection, onClose]);
|
|
|
|
|
|
|
2026-09-22 13:54:28 +03:00
|
|
|
|
// Esc + outside-click — layered dismissal, closable WITHOUT
|
|
|
|
|
|
// configuring (browsing stays possible; the gate is the send
|
|
|
|
|
|
// interception, not this dialog). An open slot menu eats Esc first
|
|
|
|
|
|
// (and any click outside it); only a bare Esc closes the dialog.
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
|
if (e.key !== "Escape") return;
|
2026-10-07 00:08:49 +03:00
|
|
|
|
// THE ONE-ESC LAW (owner, keyboard round 2026-10-06): claim at
|
|
|
|
|
|
// the document level before the layered dismissal — every branch
|
|
|
|
|
|
// below acts, so the window chain (dictation → abort) must
|
|
|
|
|
|
// never also fire. One Esc, one action.
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
e.stopPropagation();
|
2026-09-24 23:20:59 +03:00
|
|
|
|
if (keyMenuOpen) {
|
|
|
|
|
|
setKeyMenuOpen(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-22 22:05:27 +03:00
|
|
|
|
if (openMenu !== null) setOpenMenu(null);
|
2026-09-22 13:54:28 +03:00
|
|
|
|
else onClose();
|
|
|
|
|
|
};
|
|
|
|
|
|
const onPointerDown = (e: PointerEvent) => {
|
2026-10-17 00:38:57 +03:00
|
|
|
|
if (!(e.target instanceof Element)) return;
|
|
|
|
|
|
const t = e.target;
|
|
|
|
|
|
if (keyMenuOpen) {
|
|
|
|
|
|
if (t.closest("[data-lh-menu]") || t.closest('[data-testid="setup-key-provider"]')) return;
|
|
|
|
|
|
setKeyMenuOpen(false);
|
|
|
|
|
|
}
|
2026-09-22 22:05:27 +03:00
|
|
|
|
if (openMenu === null) return;
|
|
|
|
|
|
const triggerTestid =
|
|
|
|
|
|
openMenu.kind === "model" ? `setup-trigger-${openMenu.slot}` : `setup-thinking-${openMenu.slot}`;
|
|
|
|
|
|
if (t.closest("[data-lh-menu]") || t.closest(`[data-testid="${triggerTestid}"]`)) return;
|
|
|
|
|
|
setOpenMenu(null);
|
2026-09-22 13:54:28 +03:00
|
|
|
|
};
|
2026-10-07 00:08:49 +03:00
|
|
|
|
document.addEventListener("keydown", onKey);
|
2026-09-22 13:54:28 +03:00
|
|
|
|
document.addEventListener("pointerdown", onPointerDown);
|
|
|
|
|
|
return () => {
|
2026-10-07 00:08:49 +03:00
|
|
|
|
document.removeEventListener("keydown", onKey);
|
2026-09-22 13:54:28 +03:00
|
|
|
|
document.removeEventListener("pointerdown", onPointerDown);
|
|
|
|
|
|
};
|
2026-10-26 19:43:09 +02:00
|
|
|
|
}, [onClose, openMenu, keyMenuOpen]);
|
2026-09-22 13:54:28 +03:00
|
|
|
|
|
2026-10-27 16:12:13 +02:00
|
|
|
|
// Do not briefly paint model pickers between successful connection and
|
|
|
|
|
|
// closing an already-configured setup. No saved model/thinking writes.
|
|
|
|
|
|
if (closeAfterConnection) return null;
|
|
|
|
|
|
|
2026-09-22 13:54:28 +03:00
|
|
|
|
const allPicked = SLOT_ORDER.every((s) => picked[s]);
|
|
|
|
|
|
|
2026-09-24 23:20:59 +03:00
|
|
|
|
/** Stage 1's save: PUT the pasted key, dispatch the keys signal, and
|
2026-10-27 16:12:13 +02:00
|
|
|
|
* CLEAN the local key field. The fresh catalog decides whether to
|
|
|
|
|
|
* close recovery or advance to the missing model slots. Shape-gate
|
|
|
|
|
|
* 400s keep their user-facing line inline. */
|
2026-09-24 23:20:59 +03:00
|
|
|
|
async function saveKey() {
|
|
|
|
|
|
if (!keyProvider || !keyValue.trim() || keySaving) return;
|
|
|
|
|
|
setKeySaving(true);
|
|
|
|
|
|
setKeyError(null);
|
|
|
|
|
|
const res = await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, {
|
|
|
|
|
|
method: "PUT",
|
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ value: keyValue }),
|
|
|
|
|
|
}).catch(() => null);
|
|
|
|
|
|
setKeySaving(false);
|
|
|
|
|
|
if (!res || !res.ok) {
|
|
|
|
|
|
const body = (await res?.json().catch(() => ({}))) as { error?: string } | undefined;
|
2026-10-17 00:38:57 +03:00
|
|
|
|
setKeyError(res ? (body?.error ?? `Save failed (${res.status})`) : "Save failed. Check your connection and try again.");
|
2026-09-24 23:20:59 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setKeyProvider(null);
|
|
|
|
|
|
setKeyValue("");
|
|
|
|
|
|
notifySettingsSaved("keys");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 13:54:28 +03:00
|
|
|
|
async function save() {
|
|
|
|
|
|
if (!allPicked || saving) return;
|
|
|
|
|
|
setSaving(true);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
const models: ModelsSettings = {};
|
2026-09-22 22:05:27 +03:00
|
|
|
|
for (const slot of SLOT_ORDER) {
|
|
|
|
|
|
const level = levels[slot];
|
|
|
|
|
|
models[slot] = {
|
|
|
|
|
|
model: { provider: picked[slot]!.provider, id: picked[slot]!.id },
|
|
|
|
|
|
...(level ? { thinkingLevel: level } : {}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-09-22 13:54:28 +03:00
|
|
|
|
const res = await fetch("/api/settings/models", {
|
|
|
|
|
|
method: "PUT",
|
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify(models),
|
|
|
|
|
|
}).catch(() => null);
|
|
|
|
|
|
if (!res || !res.ok) {
|
|
|
|
|
|
setSaving(false);
|
2026-10-17 00:38:57 +03:00
|
|
|
|
setError(res ? `Save failed: ${(await res.json().catch(() => ({}))).error ?? res.status}` : "Save failed. Check your connection and try again.");
|
2026-09-22 13:54:28 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-22 21:55:18 +03:00
|
|
|
|
notifySettingsSaved("models");
|
2026-09-22 13:54:28 +03:00
|
|
|
|
onDone(models);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-26 19:43:09 +02:00
|
|
|
|
const keysBody = <SetupKeysBody
|
|
|
|
|
|
providers={keyProviders}
|
|
|
|
|
|
provider={keyProvider}
|
|
|
|
|
|
value={keyValue}
|
|
|
|
|
|
saving={keySaving}
|
|
|
|
|
|
error={keyError}
|
|
|
|
|
|
menuOpen={keyMenuOpen}
|
|
|
|
|
|
menuAnchor={keyTriggerRef}
|
|
|
|
|
|
onToggleMenu={() => setKeyMenuOpen((v) => !v)}
|
|
|
|
|
|
onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }}
|
|
|
|
|
|
onValue={setKeyValue}
|
|
|
|
|
|
onSave={() => void saveKey()}
|
|
|
|
|
|
/>;
|
|
|
|
|
|
|
2026-09-22 13:54:28 +03:00
|
|
|
|
return createPortal(
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
|
|
|
|
|
|
data-testid="setup-backdrop"
|
2026-10-17 00:38:57 +03:00
|
|
|
|
{...backdropDismiss}
|
2026-09-22 13:54:28 +03:00
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
role="dialog"
|
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
aria-label="Set up your models"
|
|
|
|
|
|
data-testid="setup-dialog"
|
|
|
|
|
|
className="settings-dialog-pop flex max-h-[85vh] w-[640px] max-w-[90vw] flex-col overflow-hidden rounded-md border border-border bg-card shadow-xl"
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<header className="flex h-15 shrink-0 items-center gap-3 border-b border-border px-5">
|
|
|
|
|
|
<h2 className="font-title text-2xl tracking-tight">Set up your models</h2>
|
2026-09-25 01:52:20 +03:00
|
|
|
|
<Hint label="Close" binding="escape" side="left">
|
|
|
|
|
|
<button
|
2026-09-22 13:54:28 +03:00
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label="Close setup"
|
|
|
|
|
|
data-testid="setup-close"
|
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
|
className="ml-auto text-muted-foreground transition-colors hover:text-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
✕
|
|
|
|
|
|
</button>
|
2026-09-25 01:52:20 +03:00
|
|
|
|
</Hint>
|
2026-09-22 13:54:28 +03:00
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-10-26 19:43:09 +02:00
|
|
|
|
{failed && (
|
|
|
|
|
|
<p role="alert" data-testid="setup-catalog-error" className="px-6 py-3 text-sm text-foreground">
|
|
|
|
|
|
Model catalog could not be loaded. Check the connection and retry. <button type="button" data-testid="setup-retry" className="underline" onClick={refresh}>Retry</button>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{stage === "loading" ? (
|
|
|
|
|
|
!failed && <p data-testid="setup-stage-loading" role="status" className="px-6 py-5 text-xs text-muted-foreground">Loading models…</p>
|
|
|
|
|
|
) : stage === "keys" ? (
|
|
|
|
|
|
<div className="flex flex-col px-6 py-5">{keysBody}</div>
|
2026-09-22 13:54:28 +03:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex flex-col gap-4 overflow-y-auto px-6 py-5" data-testid="setup-stage-pickers">
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
2026-10-17 00:38:57 +03:00
|
|
|
|
Pick the model for each tier. All three are required before chats can start.
|
2026-09-22 13:54:28 +03:00
|
|
|
|
</p>
|
|
|
|
|
|
{SLOT_ORDER.map((slot) => {
|
|
|
|
|
|
const entry = picked[slot];
|
2026-09-22 22:05:27 +03:00
|
|
|
|
const level = levels[slot];
|
2026-10-26 19:43:09 +02:00
|
|
|
|
const metadata = catalog?.models.find((m) => m.id === entry?.id && m.provider === entry.provider);
|
|
|
|
|
|
// Unavailable metadata cannot erase or clamp a saved preference.
|
|
|
|
|
|
const levelsForEntry = metadata?.supportedLevels ?? (entry ? Object.keys(THINKING_LABEL) : []);
|
|
|
|
|
|
const available = catalog?.models ?? (failed ? [] : undefined);
|
|
|
|
|
|
const slotCatalog = slot === "vision" ? available?.filter((m) => m.input.includes("image")) : available;
|
2026-09-22 13:54:28 +03:00
|
|
|
|
return (
|
|
|
|
|
|
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
|
2026-09-22 18:12:57 +03:00
|
|
|
|
<span className="flex items-center gap-1.5">
|
|
|
|
|
|
<span className="font-ui text-sm text-foreground">{SLOT_META[slot].label}</span>
|
2026-10-27 16:12:13 +02:00
|
|
|
|
{!catalog?.slots?.[slot]?.model && !picked[slot] && (
|
2026-09-22 18:12:57 +03:00
|
|
|
|
<span
|
|
|
|
|
|
data-testid={`setup-needed-${slot}`}
|
|
|
|
|
|
className="rounded-sm border border-border px-1 py-0.5 text-[10px] font-normal tracking-wide text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
needed
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
2026-09-22 13:54:28 +03:00
|
|
|
|
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</span>
|
2026-10-25 00:27:52 +03:00
|
|
|
|
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={entry?.provider} /><NativeSubscriptionBackgroundAccess provider={entry?.provider} /></>}
|
2026-09-22 22:05:27 +03:00
|
|
|
|
<div className="flex items-center gap-2">
|
2026-10-09 04:54:24 +03:00
|
|
|
|
<div className="relative flex-1">
|
2026-10-26 19:43:09 +02:00
|
|
|
|
<Hint label={entry ? `${entry.provider}:${entry.id}` : "Pick a model…"}>
|
2026-09-22 22:05:27 +03:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label={`${SLOT_META[slot].label} model`}
|
|
|
|
|
|
data-testid={`setup-trigger-${slot}`}
|
|
|
|
|
|
aria-expanded={openMenu?.slot === slot && openMenu.kind === "model"}
|
|
|
|
|
|
onClick={() => setOpenMenu(openMenu?.slot === slot && openMenu.kind === "model" ? null : { slot, kind: "model" })}
|
|
|
|
|
|
className="flex h-7 min-w-40 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"
|
2026-09-22 13:54:28 +03:00
|
|
|
|
>
|
2026-09-22 22:05:27 +03:00
|
|
|
|
<span className="min-w-0 flex-1 truncate">{entry ? prettyModel(entry.id) : "Pick a model…"}</span>
|
|
|
|
|
|
<span aria-hidden className="text-muted-foreground">
|
|
|
|
|
|
▾
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
2026-10-26 19:43:09 +02:00
|
|
|
|
</Hint>
|
2026-10-09 04:54:24 +03:00
|
|
|
|
{/* Anchor by TESTID, not a ref (the settings-section pattern):
|
|
|
|
|
|
React attaches a host element's ref AFTER its descendants'
|
|
|
|
|
|
layout effects run, so a ref freshly attached in this same
|
|
|
|
|
|
commit (menu opening = the ref condition turning true) is
|
|
|
|
|
|
still null when MenuShell measures — place() gives up, the
|
|
|
|
|
|
menu stays invisible, and only a window resize (which re-runs
|
|
|
|
|
|
place()) shows it. querySelector at place time has no such
|
|
|
|
|
|
ordering hazard. Live-caught by the owner 2026-10-09: every
|
|
|
|
|
|
setup picker opened dead until a window resize. */}
|
2026-09-22 22:05:27 +03:00
|
|
|
|
{openMenu?.slot === slot && openMenu.kind === "model" && (
|
|
|
|
|
|
<MenuShell
|
2026-10-09 04:54:24 +03:00
|
|
|
|
anchorTestid={`setup-trigger-${slot}`}
|
2026-09-22 22:05:27 +03:00
|
|
|
|
testid={`setup-menu-${slot}`}
|
|
|
|
|
|
preferredSide="down"
|
|
|
|
|
|
align="left"
|
|
|
|
|
|
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ModelMenuList
|
2026-10-26 19:43:09 +02:00
|
|
|
|
models={slotCatalog}
|
2026-09-22 22:05:27 +03:00
|
|
|
|
effectiveId={entry?.id}
|
|
|
|
|
|
effectiveProvider={entry?.provider}
|
|
|
|
|
|
onPick={(m) => {
|
2026-10-26 19:43:09 +02:00
|
|
|
|
editedSlots.current.add(slot);
|
2026-09-22 22:05:27 +03:00
|
|
|
|
setPicked((prev) => ({ ...prev, [slot]: m }));
|
|
|
|
|
|
// Auto-populate (chunk 2, owner: "populate
|
|
|
|
|
|
// everywhere"): no explicit level → the
|
|
|
|
|
|
// ordering's level for the fresh pick; an
|
|
|
|
|
|
// explicit one rides (Save clamps server-side).
|
|
|
|
|
|
setLevels((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[slot]: populateLevel(prev[slot], m.supportedLevels),
|
|
|
|
|
|
}));
|
|
|
|
|
|
setOpenMenu(null);
|
|
|
|
|
|
}}
|
2026-10-26 19:43:09 +02:00
|
|
|
|
footer={slotCatalog && slotCatalog.length === 0 && slot === "vision" ? "No image-capable models. Add a provider key with access to a vision model." : undefined}
|
2026-09-22 22:05:27 +03:00
|
|
|
|
/>
|
|
|
|
|
|
</MenuShell>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/* Thinking selector (chunk 2): editable, pre-populated from
|
|
|
|
|
|
the ordering per picked model — no model, no levels. */}
|
2026-10-09 04:54:24 +03:00
|
|
|
|
<div className="relative">
|
2026-09-22 22:05:27 +03:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label={`${SLOT_META[slot].label} thinking`}
|
|
|
|
|
|
data-testid={`setup-thinking-${slot}`}
|
|
|
|
|
|
aria-expanded={openMenu?.slot === slot && openMenu.kind === "thinking"}
|
|
|
|
|
|
disabled={!entry}
|
|
|
|
|
|
onClick={() => setOpenMenu(openMenu?.slot === slot && openMenu.kind === "thinking" ? null : { slot, kind: "thinking" })}
|
|
|
|
|
|
className="flex h-7 w-24 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-40"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="min-w-0 flex-1 truncate text-left">
|
|
|
|
|
|
{entry ? (level ? THINKING_LABEL[level] ?? level : "…") : "—"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span aria-hidden className="text-muted-foreground">
|
|
|
|
|
|
▾
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{openMenu?.slot === slot && openMenu.kind === "thinking" && (
|
|
|
|
|
|
<MenuShell
|
2026-10-09 04:54:24 +03:00
|
|
|
|
anchorTestid={`setup-thinking-${slot}`}
|
2026-09-22 22:05:27 +03:00
|
|
|
|
testid={`setup-thinking-menu-${slot}`}
|
|
|
|
|
|
preferredSide="down"
|
|
|
|
|
|
align="left"
|
|
|
|
|
|
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ThinkingMenuList
|
|
|
|
|
|
levels={levelsForEntry.map(String)}
|
|
|
|
|
|
effectiveLevel={level}
|
|
|
|
|
|
onPick={(lv) => {
|
2026-10-26 19:43:09 +02:00
|
|
|
|
editedSlots.current.add(slot);
|
2026-09-22 22:05:27 +03:00
|
|
|
|
setLevels((prev) => ({ ...prev, [slot]: lv as ThinkingLevel }));
|
|
|
|
|
|
setOpenMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</MenuShell>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-09-22 13:54:28 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
data-testid="setup-save"
|
|
|
|
|
|
disabled={!allPicked || saving}
|
|
|
|
|
|
onClick={() => void save()}
|
|
|
|
|
|
className="rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
|
|
|
|
|
|
>
|
|
|
|
|
|
{saving ? "Saving…" : "Save models"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>,
|
|
|
|
|
|
document.body,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|