import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckCircle2, CircleAlert, ClipboardCheck, FlaskConical, LoaderCircle, ScrollText, X } from "lucide-react"; import { asPiManagementApiError, getPiManagementLogs, getPiManagementOptions, getPiManagementStatus, runPiManagementTest, savePiManagementConfig, type PiInstallationConfig, type PiManagementOptions, } from "../api/pi-management"; import { Button } from "../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog"; 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 ; } type PiPlatform = "linux" | "macos" | "windows"; type PiPlatformDetails = { modelsPath: string; settingsPath: string; terminal: string; credentialProtection: string; buildCommand: string; restartCommand: string; updateCommand: string; pullCommand: string; recoveryCommands: string; }; const piPlatforms: Array<{ id: PiPlatform; label: string; details: PiPlatformDetails }> = [ { id: "linux", label: "Linux", details: { modelsPath: "deploy/pi/models.json", settingsPath: "deploy/pi/settings.json", terminal: "a terminal", credentialProtection: "a protected host file with mode 0600", buildCommand: "mkdir -p bin\ngo -C tools/thothctl build -o ../../bin/thothctl ./cmd/thothctl", restartCommand: "./bin/thothctl pi restart --yes --drain", updateCommand: "./bin/thothctl pi update", pullCommand: "./bin/thothctl pi update --version --source pull --image @sha256: --yes --drain", recoveryCommands: "./bin/thothctl pi maintenance status\n./bin/thothctl pi logs\n./bin/thothctl pi rollback --yes\n./bin/thothctl pi maintenance recover --yes", }, }, { id: "macos", label: "macOS", details: { modelsPath: "deploy/pi/models.json", settingsPath: "deploy/pi/settings.json", terminal: "Terminal", credentialProtection: "a protected host file with mode 0600", buildCommand: "mkdir -p bin\ngo -C tools/thothctl build -o ../../bin/thothctl ./cmd/thothctl", restartCommand: "./bin/thothctl pi restart --yes --drain", updateCommand: "./bin/thothctl pi update", pullCommand: "./bin/thothctl pi update --version --source pull --image @sha256: --yes --drain", recoveryCommands: "./bin/thothctl pi maintenance status\n./bin/thothctl pi logs\n./bin/thothctl pi rollback --yes\n./bin/thothctl pi maintenance recover --yes", }, }, { id: "windows", label: "Windows", details: { modelsPath: "deploy\\pi\\models.json", settingsPath: "deploy\\pi\\settings.json", terminal: "PowerShell", credentialProtection: "a protected host file with a user-only ACL", buildCommand: "New-Item -ItemType Directory -Force bin | Out-Null\ngo -C tools/thothctl build -o ../../bin/thothctl.exe ./cmd/thothctl", restartCommand: ".\\bin\\thothctl.exe pi restart --yes --drain", updateCommand: ".\\bin\\thothctl.exe pi update", pullCommand: ".\\bin\\thothctl.exe pi update --version --source pull --image @sha256: --yes --drain", recoveryCommands: ".\\bin\\thothctl.exe pi maintenance status\n.\\bin\\thothctl.exe pi logs\n.\\bin\\thothctl.exe pi rollback --yes\n.\\bin\\thothctl.exe pi maintenance recover --yes", }, }, ]; function PiCodeBlock({ children, className = "" }: { children: string; className?: string }) { return
{children}
; } function PiInstructionSteps({ details }: { details: PiPlatformDetails }) { return
  1. Open the project root

    Using {details.terminal}, open the current ThothII checkout or worktree root. All commands below start in this project root. The deploy directory is beside compose.yaml. If this checkout has no bin directory, create it and build the local CLI once:

    {details.buildCommand}

    The commands below use the executable built in this checkout, so they cannot accidentally target another worktree.

  2. Edit the provider catalog

    Edit {details.modelsPath}. It is the provider catalog.

    baseUrl
    is the provider API endpoint.
    api
    selects the provider API format.
    models
    lists that provider's available models.
    id
    is the model identifier.
    name
    is the model name shown to operators.
  3. Enable the model

    Edit {details.settingsPath}. It is the enabled-model policy, not the provider catalog.

    enabledModels
    uses provider/model identifiers to choose the models available for new Pi work.
  4. Check the provider credential

    Pi reads the provider API key from a protected file on the host. PI_AUTH_FILE tells this installation which file to use; Docker mounts it read-only into the core container. Do not put the key in models.json or settings.json. Keep {details.credentialProtection}.

  5. Reload Pi configuration

    After changing the catalog, policy, or selected credential file, reload Pi configuration.

    {details.restartCommand}
  6. Update the Pi version

    The command installs the Pi version pinned in docker/core.Dockerfile. Use --version <VERSION> only when you deliberately want another version.

    {details.updateCommand}

    Advanced: pull an immutable, digest-pinned image.

    {details.pullCommand}
  7. Recover a failed update

    Check maintenance status and bounded sanitized Pi logs first. For a failed update, use rollback. For restart or update maintenance recovery after repairing the reported problem, use maintenance recovery.

    {details.recoveryCommands}
; } function PiPlatformInstructions() { const [activePlatform, setActivePlatform] = useState(); const [focusedPlatform, setFocusedPlatform] = useState("linux"); const tabRefs = useRef>({ linux: null, macos: null, windows: null }); function activateAndFocus(platform: PiPlatform) { setFocusedPlatform(platform); setActivePlatform(platform); tabRefs.current[platform]?.focus(); } function handleTabKeyDown(event: React.KeyboardEvent, platform: PiPlatform) { const index = piPlatforms.findIndex((item) => item.id === platform); const nextPlatform = event.key === "ArrowRight" ? piPlatforms[(index + 1) % piPlatforms.length] : event.key === "ArrowLeft" ? piPlatforms[(index - 1 + piPlatforms.length) % piPlatforms.length] : event.key === "Home" ? piPlatforms[0] : event.key === "End" ? piPlatforms[piPlatforms.length - 1] : undefined; if (!nextPlatform) return; event.preventDefault(); activateAndFocus(nextPlatform.id); } return (

Using the host terminal:

{piPlatforms.map((platform) => ( ))}
{piPlatforms.map((platform) => ( ))}
); } 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: async (saved) => { const config = { provider: saved.provider, model: saved.model, reasoning: saved.reasoning }; setDraft(config); setSmokeState(undefined); await queryClient.invalidateQueries({ queryKey: ["pi-management", "status"] }); 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.") }); }, }); 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

Select the provider, model, and reasoning used for new Pi work. Credentials stay in protected host files.

{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

Shows at most 200 recent lines with declared secret values removed.

{logsQuery.isError &&

Could not load sanitized Pi diagnostics.

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