import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckCircle2, CircleAlert, Clipboard, ClipboardCheck, FlaskConical, LoaderCircle, ScrollText, X } from "lucide-react"; import { asPiManagementApiError, getPiManagementLogs, getPiManagementOptions, getPiManagementStatus, runPiManagementTest, savePiManagementConfig, type PiInstallationConfig, type PiManagementOptions, type PiManagementStatus, } from "../api/pi-management"; import { Button } from "../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog"; const UPDATE_COMMAND = "thothctl pi update"; const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-60"; type Feedback = { tone: "success" | "error"; message: string } | undefined; type SmokeState = "passed" | "failed" | undefined; function suggestedConfig(status: { config: Partial }, options: PiManagementOptions): PiInstallationConfig | undefined { const provider = status.config.provider && options.providers.includes(status.config.provider) ? status.config.provider : options.providers[0]; const model = status.config.model && options.models.some((item) => item.provider === provider && item.id === status.config.model) ? status.config.model : options.models.find((item) => item.provider === provider)?.id; const reasoning = status.config.reasoning && options.reasoning.includes(status.config.reasoning) ? status.config.reasoning : options.reasoning[0]; return provider && model && reasoning ? { provider, model, reasoning } : undefined; } function isSupportedConfig( config: Partial | undefined, options: PiManagementOptions | undefined, ): config is PiInstallationConfig { return Boolean( config?.provider && config.model && config.reasoning && options?.providers.includes(config.provider) && options.models.some((model) => model.provider === config.provider && model.id === config.model) && options.reasoning.includes(config.reasoning), ); } function sameConfig(a: Partial | undefined, b: PiInstallationConfig | undefined): boolean { return Boolean( a?.provider === b?.provider && a?.model === b?.model && a?.reasoning === b?.reasoning, ); } function errorMessage(error: unknown, fallback: string): string { return asPiManagementApiError(error)?.message ?? fallback; } function ReadinessRail({ ready, configured, credentials, smokeState }: { ready: boolean; configured: boolean; credentials: "present" | "missing"; smokeState: SmokeState; }) { return (
); } function RailItem({ label, value, state }: { label: string; value: string; state: "ready" | "attention" | "idle" }) { const iconClass = state === "ready" ? "text-[oklch(var(--success))]" : state === "attention" ? "text-amber-700 dark:text-amber-400" : "text-muted-foreground"; return

{label}

{state === "ready" ? : state === "attention" ? : } {value}

; } function Field({ label, children }: { label: string; children: React.ReactNode }) { return ; } export function PiManagement({ open, onClose }: { open: boolean; onClose: () => void }) { const queryClient = useQueryClient(); const [draft, setDraft] = useState(); const [feedback, setFeedback] = useState(); const [smokeState, setSmokeState] = useState(); const [logsRequested, setLogsRequested] = useState(false); const statusQuery = useQuery({ queryKey: ["pi-management", "status"], queryFn: getPiManagementStatus, enabled: open }); const optionsQuery = useQuery({ queryKey: ["pi-management", "options"], queryFn: getPiManagementOptions, enabled: open }); const logsQuery = useQuery({ queryKey: ["pi-management", "logs"], queryFn: getPiManagementLogs, enabled: open && logsRequested }); const models = useMemo( () => optionsQuery.data?.models.filter((model) => model.provider === draft?.provider) ?? [], [draft?.provider, optionsQuery.data?.models], ); const initialDraft = useMemo( () => statusQuery.data && optionsQuery.data ? suggestedConfig(statusQuery.data, optionsQuery.data) : undefined, [optionsQuery.data, statusQuery.data], ); const persistedReady = isSupportedConfig(statusQuery.data?.config, optionsQuery.data); const validDraft = isSupportedConfig(draft, optionsQuery.data); const dirty = Boolean(draft && !sameConfig(statusQuery.data?.config, draft)); useEffect(() => { if (!open) { setDraft(undefined); setFeedback(undefined); setSmokeState(undefined); setLogsRequested(false); queryClient.removeQueries({ queryKey: ["pi-management"] }); } }, [open, queryClient]); useEffect(() => { if (draft || !initialDraft) return; setDraft(initialDraft); }, [draft, initialDraft]); const saveMutation = useMutation({ mutationFn: savePiManagementConfig, onSuccess: (saved) => { const config = { provider: saved.provider, model: saved.model, reasoning: saved.reasoning }; queryClient.setQueryData(["pi-management", "status"], (current) => ( current ? { ...current, config } : current )); setDraft(config); setSmokeState(undefined); setFeedback({ tone: "success", message: "Defaults saved." }); }, onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not save Pi defaults.") }), }); const smokeMutation = useMutation({ mutationFn: runPiManagementTest, onSuccess: (result) => { setSmokeState(result.ready ? "passed" : "failed"); setFeedback({ tone: result.ready ? "success" : "error", message: result.ready ? "Saved configuration test passed." : `Saved configuration test failed.${result.message ? ` ${result.message}` : ""}`, }); }, onError: (error) => { setSmokeState("failed"); setFeedback({ tone: "error", message: errorMessage(error, "Could not test the saved Pi defaults.") }); }, }); async function copyUpdateCommand() { try { if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable"); await navigator.clipboard.writeText(UPDATE_COMMAND); setFeedback({ tone: "success", message: "Update command copied." }); } catch { setFeedback({ tone: "error", message: "Could not copy the update command." }); } } function saveDefaults() { if (!draft || !validDraft) { setFeedback({ tone: "error", message: "Choose a supported provider, model, and reasoning level." }); return; } saveMutation.mutate(draft); } function updateDraft(update: (current: PiInstallationConfig) => PiInstallationConfig) { setDraft((current) => current ? update(current) : current); setSmokeState(undefined); setFeedback(undefined); } function testSavedDefaults() { if (!persistedReady || dirty) { setFeedback({ tone: "error", message: "Save supported defaults before running the test." }); return; } smokeMutation.mutate(); } const forbidden = asPiManagementApiError(statusQuery.error)?.code === "pi_management_forbidden"; const loading = statusQuery.isLoading || optionsQuery.isLoading || Boolean(!draft && initialDraft); const unavailable = statusQuery.isError || optionsQuery.isError; return ( { if (!nextOpen) onClose(); }}>

Installation controls

Pi management Review the bundled runtime, set safe defaults, and test the saved provider configuration.
{forbidden ? (

Pi management is not permitted

Ask an installation administrator to manage Pi defaults and diagnostics.

) : unavailable ? (

{errorMessage(statusQuery.error ?? optionsQuery.error, "Pi management is unavailable")}

) : loading ?

Loading Pi management…

: statusQuery.data && optionsQuery.data && (

Bundled runtime

Pi {statusQuery.data.version ?? "version unavailable"}

{statusQuery.data.ready ? "Ready" : "Needs attention"}

{statusQuery.data.message &&

{statusQuery.data.message}

} {feedback &&

{feedback.tone === "error" ? : } {feedback.message}

}

Installation defaults

These choices apply to new Pi work. Credentials remain outside this browser.

{draft ? ( <>
{dirty &&

{persistedReady ? "Changes are not saved yet." : "Suggested choices are not saved yet."}

}
{dirty &&

Save these changes before testing. The test always uses saved defaults.

} {!dirty && !persistedReady &&

Save supported defaults before testing. The test always uses saved defaults.

} ) : (

No enabled provider and model choices are available.

Check the host-managed Pi model configuration, then retry this panel.

)}

Sanitized diagnostics

Inspect up to the latest 200 sanitized lines. Raw runtime output is never shown here.

{logsQuery.isError &&

Could not load sanitized Pi diagnostics.

} {logsQuery.data &&
{logsQuery.data.lines.join("\n") || "No diagnostic lines are available."}
}

Update Pi on the host

Pi is updated through the host-side control command so image changes remain reproducible and recoverable. This page cannot update Docker or the running image.

{UPDATE_COMMAND}
)}
); }