"use client"; /** * The model + thinking control (model-control wave, 2026-09-12) — the stats * bar's right side. ONE controller for the conversation agents (prober + * teacher are functionally one agent); background sub-agents stay * config-pinned. Writes go to PUT /api/chats/[id]/settings (validated * server-side against pi-ai's catalog) and take effect on the next turn by * construction — the agents are stateless per turn. * * The data is pi-ai's catalog (GET /api/models): the model menu is a fuzzy * search over "provider id name" (core/fuzzy.ts — pi's /model pattern), and * the thinking menu lists EXACTLY the chosen model's supported levels * (getSupportedThinkingLevels — GLM-5.3-flash shows low|high|max, never a * level it would silently clamp). Menus open ABOVE the buttons (the * composer owns everything below). */ import { useCallback, useEffect, useRef, useState } from "react"; import { fuzzyScore, type ChatSettings, type ModelCatalogEntry, type ModelCatalogResponse, } from "@/src/core"; import { formatTokens, prettyModel } from "@/components/usage-format"; /** GET /api/models is fetched once per page load and shared by every * picker instance (the catalog is static within a pi-ai version). */ let catalogCache: ModelCatalogResponse | undefined; let catalogInflight: Promise | undefined; function fetchCatalog(): Promise { catalogInflight ??= fetch("/api/models") .then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise; }) .then((c) => { catalogCache = c; return c; }) .catch((err) => { catalogInflight = undefined; // retry on next mount throw err; }); return catalogInflight; } function useModelCatalog(): ModelCatalogResponse | undefined { const [catalog, setCatalog] = useState(catalogCache); useEffect(() => { if (catalogCache) return; let alive = true; fetchCatalog() .then((c) => alive && setCatalog(c)) .catch(() => { /* the picker stays closed on failure; the buttons still display the effective model from props */ }); return () => { alive = false; }; }, []); return catalog; } const LEVEL_LABEL: Record = { off: "Off", minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Xhigh", max: "Max", }; /** USD per 1M tokens, compact: "$0.08 in · $0.25 out". */ function priceLabel(cost: ModelCatalogEntry["cost"]): string { const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`; return `${fmt(cost.input)}/${fmt(cost.output)}`; } export function ModelControl({ chatId, settings, onSaved, fallbackModelId, }: { chatId: string; /** The record's current settings (undefined = config pin). */ settings?: ChatSettings; /** Lifts the persisted settings into the chat view's state. */ onSaved: (settings: ChatSettings) => void; /** The last turn's actual model (from the turns log) — the label until * the catalog (with config's defaults) finishes loading. */ fallbackModelId?: string; }) { const catalog = useModelCatalog(); const [open, setOpen] = useState<"model" | "thinking" | null>(null); const [query, setQuery] = useState(""); const [error, setError] = useState(null); const searchRef = useRef(null); const containerRef = useRef(null); const defaults = catalog?.defaults; const effectiveId = settings?.model?.id ?? defaults?.model.id ?? fallbackModelId; const effectiveLevel = settings?.thinkingLevel ?? defaults?.thinkingLevel; const entry = catalog?.models.find((m) => m.id === effectiveId); const levels = entry?.supportedLevels ?? []; // Focus the search box when the model menu opens (pi's /model feel). useEffect(() => { if (open === "model") searchRef.current?.focus(); }, [open]); // The intuitive dropdown contract (owner, stats-control round 1): any // click outside the open menu — or an Esc keypress — closes it. Only the // menu's own surface keeps it open (the toggle buttons live inside the // same container and handle their own toggling in onClick). useEffect(() => { if (open === null) return; const onPointerDown = (e: PointerEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(null); } }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(null); }; document.addEventListener("pointerdown", onPointerDown); document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("keydown", onKeyDown); }; }, [open]); const save = useCallback( async (next: ChatSettings) => { setError(null); // Home view (pre-chat): defer — the selection rides along on the // chat POST and is PUT after creation (onSaved is still lifted). if (!chatId) { onSaved(next); setOpen(null); return; } try { const res = await fetch(`/api/chats/${chatId}/settings`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(next), }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; setError(body.error ?? `settings rejected (${res.status})`); return; } const { settings: saved } = (await res.json()) as { settings: ChatSettings }; onSaved(saved); setOpen(null); } catch { setError("Could not reach the server — try again."); } }, [chatId, onSaved], ); // The filtered, ranked model list. const results = (catalog?.models ?? []) .map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) })) .filter((r) => r.s !== null) .sort((a, b) => (b.s! - a.s!) || a.m.id.localeCompare(b.m.id)) .map((r) => r.m); return (
{open === "model" && (
setQuery(e.target.value)} placeholder="Search models…" className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring" /> {error &&
{error}
} {!catalog && (
Loading models…
)} {catalog && results.length === 0 && (
No models match.
)}
    {results.map((m) => (
  • ))}
Applies to the next turn · background agents stay config-pinned
)} {open === "thinking" && (
{!catalog &&
Loading…
} {catalog && levels.length === 0 && (
{effectiveId ? `${prettyModel(effectiveId)} has no thinking levels.` : "Pick a model first."}
)} {levels.map((level) => ( ))} {error &&
{error}
}
Levels this model actually supports
)}
); }