"use client"; /** * The Agent section (thin settings wave): background-role model overrides, * the tool toggles, and the prompt override switches — three blocks, ONE * section (PUT /api/settings/agent replaces the whole agent object, so * every change composes models+tools+prompts together from the record). * * Tools — the owner's final list (2026-09-19): research, demo, visual, pdf. * Absent key = enabled in the schema; the UI writes explicit booleans for * all four (simpler to reason about, sanitize-equivalent). * * Models: each of the six background roles gets a Default option (absent * override = config.ts's pin) plus the /api/models catalog. The thinking * select shows the CHOSEN model's supported levels when a model override * exists; without one the factory model is unknown client-side, so the * standard levels list (the server sanitizer accepts any valid level). * * Prompts: GET /api/settings/prompts segments; the switch requires an * override FILE (route-enforced), the text writes through the segment * route. Switch state lives in this section's PUT; text does not. */ import { useEffect, useState } from "react"; import { BACKGROUND_MODEL_ROLES, type AgentSettings, type BackgroundModelRole, } from "@/src/core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ModelCatalogEntry } from "@/src/core"; import { SettingsRow, ThinSelect, Toggle } from "./controls.tsx"; /** The role's display label — schema ids are camelCase, the label is human. */ const ROLE_LABEL: Record = { researcher: "Researcher", demoBuilder: "Demo builder", visualMaker: "Visual maker", extractor: "Extractor", auditor: "Auditor", labeler: "Labeler", }; /** The owner's final toggleable-tool list (confirm with the user before * changing — the Teacher stream settled this 2026-09-19). */ export const TOGGLE_TOOLS = ["research", "demo", "visual", "pdf"] as const; const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; /** SegmentInfo as GET /api/settings/prompts returns it. */ interface SegmentInfo { path: string; sourceText: string; overrideText: string | null; enabled: boolean; shared: boolean; } /** Compose: one tool toggled, the rest of the section carried verbatim. * Pure — exported for tests. */ export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings { return { ...agent, tools: { ...agent?.tools, [id]: on } }; } /** Compose: one role's model/thinking override. Empty model = absent * override (Default); empty thinking = thinking falls back. Pure — tests. */ export function composeRoleModel( agent: AgentSettings | undefined, role: BackgroundModelRole, modelId: string, thinking: string, ): AgentSettings { const prev = agent?.models?.[role]; const models = { ...agent?.models }; if (!modelId && !thinking) delete models[role]; else { models[role] = { ...(modelId ? { model: { provider: "openrouter", id: modelId } } : {}), ...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}), }; } return { ...agent, models }; } /** Compose: one prompt switch. on = explicit true; off = key REMOVED * (absent = off is the schema's resting state; explicit false is the * same thing but noisier). Pure — tests. */ export function composePromptSwitch(agent: AgentSettings | undefined, path: string, on: boolean): AgentSettings { const prompts = { ...agent?.prompts }; if (on) prompts[path] = true; else delete prompts[path]; return { ...agent, prompts }; } export function AgentSection({ agent, onSectionChange, }: { agent: AgentSettings | undefined; /** Writes the composed WHOLE agent section (replace-PUT discipline). */ onSectionChange: (next: AgentSettings) => void; }) { const [catalog, setCatalog] = useState(null); const [prompts, setPrompts] = useState<{ prober: SegmentInfo[]; teacher: SegmentInfo[] } | null>(null); const [openSegment, setOpenSegment] = useState(null); const [segmentDraft, setSegmentDraft] = useState>({}); const [segmentStatus, setSegmentStatus] = useState(null); // The catalog + the prompt segments — one fetch each per page load. useEffect(() => { fetch("/api/models") .then((r) => r.json()) .then((body: { models: ModelCatalogEntry[] }) => setCatalog(body.models)) .catch(() => setCatalog(null)); fetch("/api/settings/prompts") .then((r) => r.json()) .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)) .catch(() => setPrompts(null)); }, []); async function saveSegment(path: string, text: string) { setSegmentStatus(null); const res = await fetch(`/api/settings/prompts/${path}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }), }); if (!res.ok) { setSegmentStatus(`Save failed: ${(await res.json().catch(() => ({}))).error ?? res.status}`); return; } // Refetch: file presence gates the switch, so the view must track it. fetch("/api/settings/prompts") .then((r) => r.json()) .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)); setSegmentStatus(`Saved ${path}`); } async function deleteSegment(path: string) { setSegmentStatus(null); const res = await fetch(`/api/settings/prompts/${path}`, { method: "DELETE" }); if (!res.ok) { setSegmentStatus(`Delete failed: ${res.status}`); return; } fetch("/api/settings/prompts") .then((r) => r.json()) .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)); } const segmentRow = (info: SegmentInfo) => { const fileExists = info.overrideText !== null; const open = openSegment === info.path; return (
onSectionChange(composePromptSwitch(agent, info.path, on))} /> {open && (
{info.shared && (

Shared base — appears in both agents.

)}

Source