import { useEffect, useState } from "react"
import { useLocation } from "@tanstack/react-router"
import { Search } from "lucide-react"
import { Auftraege } from "@/components/cockpit/Auftraege"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button"
import {
useAufraeumen, useAufraeumenLoeschen, useJobs, useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion,
useRadarSuche, useStart,
} from "@/lib/abfragen"
import { messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige"
import { ApiFehler } from "@/lib/api"
import { useLetzteMetrik } from "@/lib/strom"
import type { AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen"
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
import { ModellSuche } from "./ModellSuche"
/** Grenze aus der Erfahrung (Memory „llama-swap: nur EINE Gruppe resident“): darüber OOM. */
const GRENZE_GB = 115
function Speicher({ modelle, laufend }: { modelle: Modell[]; laufend: string[] }) {
const m = useLetzteMetrik()
const start = useStart()
const gesamt = gb(m?.ram_total ?? start.data?.box.ram_total) ?? 122.7
const belegt = gb(m?.ram_used ?? start.data?.box.ram_used) ?? 0
const geladen = modelle.filter((x) => laufend.includes(x.name))
const dateien = geladen.reduce((s, x) => s + (gb(x.size_bytes) ?? 0), 0)
const rest = Math.max(0, belegt - dateien)
const pct = (g: number) => `${Math.min(100, (g / gesamt) * 100)}%`
return (
{Math.round(belegt)} von {Math.round(gesamt)} GB belegt · Grenze ~{GRENZE_GB} GB}>
Geladene Modelle: {Math.round(dateien)} GB Dateien
Cache, Dienste, System: {Math.round(rest)} GB
Ab hier wird es eng
)
}
function Wert({ name, wert }: { name: string; wert: string }) {
return (
{name}
{wert}
)
}
function Rolle({ titel, modell, zwilling, laeuft, nutzer }: {
titel: string
modell?: Modell
zwilling?: Modell
laeuft: boolean
nutzer: string
}) {
const laden = useModellLaden()
if (!modell) {
return (
{titel} Frei
Nicht belegt
Nur belegen, wenn ein Modell hier echten Mehrwert bringt. Das Radar meldet Kandidaten.
)
}
const kontext = modell.ctx ? Math.round(modell.ctx / Math.max(1, modell.parallel_slots) / 1024) : null
// Seit 24.09.: Hirn und Coder sehen über ihren Bild-Zwilling (gleiche Gewichte, ohne Draft).
const bilder = Boolean(modell.capabilities?.vision) || Boolean(zwilling)
return (
{titel}
{laeuft ? "Geladen" : "Auf Abruf"}
{modell.name}
Genutzt von {nutzer}.{modell.spec_active ? " Mit Draft-Beschleunigung." : ""}
{zwilling ? " Bilder sieht es über seinen Bild-Zwilling, der nur bei Bedarf lädt." : ""}
{!laeuft && (
laden.mutate(modell.name)} disabled={laden.isPending}>
Jetzt laden
)}
)
}
function NutzungsPanel({ nutzung, laedt, fehler }: { nutzung: Nutzung | undefined; laedt: boolean; fehler: string | null }) {
if (!nutzung) {
return (
{laedt ? (
Wird gelesen …
) : (
Die Nutzung lässt sich gerade nicht lesen{fehler ? `: ${fehler}` : "."}
)}
)
}
const max = Math.max(1, ...nutzung.absender.map((a) => a.anfragen))
const stunden = nutzung.letzte_24h.map((s) => Object.values(s.je_absender).reduce((a, b) => a + b, 0))
const maxStunde = Math.max(1, ...stunden)
return (
letzte {nutzung.tage} Tage}>
{nutzung.absender.length === 0 && Keine Anfragen aufgezeichnet.
}
Anfragen je Stunde, letzte 24 h
{stunden.map((n, i) => {
const h = n ? Math.max(3, (n / maxStunde) * 50) : 2
return
})}
{[0, 6, 12, 18].map((s) => (
{s}
))}
Anfragen an llama-swap. NerdQuiz geht direkt dorthin, alles andere über MC2.
)
}
const STATUS_ART: Record = {
bestanden: "gruen", durchgefallen: "rot", wartet: "cyan", neu: "cyan", getestet: "grau", uebernommen: "gruen", verworfen: "grau",
}
const URTEIL_FARBE: Record = { besser: "text-gruen", schlechter: "text-rot-text", gleich: "text-text-2" }
type Vergleichszeile = { kandidat: unknown; baseline: unknown; einheit?: string; urteil?: string }
/** Die Messwerte hinter dem Urteil: Kandidat gegen das heutige Modell der Rolle. */
function Vergleich({ test }: { test: RadarTest }) {
const zeilen = Object.entries(test.vergleich ?? {}) as [string, Vergleichszeile][]
if (zeilen.length === 0) return null
return (
Messwert
Kandidat
Heute
{zeilen.map(([name, z]) => (
{name}
{messwert(z.kandidat, z.einheit)}
{messwert(z.baseline, z.einheit)}
))}
)
}
function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | undefined; heute: string | undefined }) {
const aktion = useRadarAktion()
const [frage, setFrage] = useState(null)
const offen = ["bestanden", "wartet", "neu", "getestet"].includes(k.status)
const hirn = k.rolle === "hirn"
return (
{k.name}
{hirn ? "fürs Hirn" : "fürs Coden"}
{k.groesse_gb != null && ( // übersprungene Einträge wurden nie vermessen
{k.eng ? "passt knapp" : k.passt ? "passt" : "passt nicht"}
)}
{RADAR_STATUS_TEXT[k.status] ?? k.status}
{k.groesse_gb != null ? `${k.groesse_gb.toLocaleString("de-DE")} GB. ` : ""}
{k.begruendung}
{test && ["bestanden", "durchgefallen"].includes(k.status) && }
{offen && (
{k.status === "bestanden" && (
setFrage("uebernehmen")}>
Übernehmen
)}
setFrage("verwerfen")}>
Verwerfen
)}
setFrage(null)}
onJa={() => {
if (frage !== "uebernehmen") return // zweiter Klick eines Doppelklicks, der Dialog schließt schon
aktion.mutate({ id: k.id, aktion: "uebernehmen", name: k.name })
setFrage(null)
}}
/>
setFrage(null)}
onJa={() => {
if (frage !== "verwerfen") return
aktion.mutate({ id: k.id, aktion: "verwerfen", name: k.name })
setFrage(null)
}}
/>
)
}
function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string | undefined }) {
const radar = useRadar()
const suche = useRadarSuche()
if (radar.isError && !radar.data) {
// 404 heißt: Diese Box hat kein Radar. Alles andere ist ein Fehler, kein fehlendes Radar.
const fehlt = radar.error instanceof ApiFehler && radar.error.status === 404
return (
{fehlt ? (
Das Radar ist auf dieser Box noch nicht eingerichtet.
) : (
Das Radar antwortet gerade nicht: {radar.error.message}
)}
)
}
const d = radar.data
return (
suche.mutate(undefined)} disabled={suche.isPending}>
{suche.isPending ? "Sucht …" : "Jetzt suchen"}
}
>
Testet nachts zwischen 00:30 und 02:30 selbst, höchstens einen Kandidaten pro Woche.
{d?.naechster_test ? ` Nächster Test: ${flugplanZeit(d.naechster_test)}.` : ""}
{!d ? (
Wird gelesen …
) : d.kandidaten.length === 0 ? (
Nichts Besseres in Sicht.
) : (
{d.kandidaten.map((k) => (
t.kandidat === k.id)}
heute={k.rolle === "hirn" ? hirn : coder}
/>
))}
)}
{d && d.tests.length > 0 && (
Letzte Tests
{d.tests.slice(0, 3).map((t) => (
{flugplanZeit(t.datum)} · {t.zusammenfassung}
))}
)}
)
}
/** Was ungenutzt Platz belegt. Gelöscht wird nur auf Knopfdruck, mit Rückfrage — Löschen ist endgültig. */
function AufraeumenPanel() {
const liste = useAufraeumen()
const loeschen = useAufraeumenLoeschen()
const [frage, setFrage] = useState(null)
const kandidaten = liste.data?.kandidaten ?? []
const summe = kandidaten.reduce((s, k) => s + k.groesse_gb, 0)
if (liste.isError && !liste.data) {
return (
Was sich aufräumen ließe, lässt sich gerade nicht lesen: {liste.error.message}
)
}
return (
0
? bis zu {summe.toLocaleString("de-DE", { maximumFractionDigits: 0 })} GB frei
: undefined}
>
Modelle und Ordner, die gerade niemand nutzt. Nichts davon wird von selbst gelöscht, das entscheidest du je Eintrag.
{liste.isPending ? (
Wird gelesen …
) : kandidaten.length === 0 ? (
Nichts aufzuräumen.
) : (
{kandidaten.map((k) => (
{k.name}
{k.art === "eintrag" ? "Eintrag" : "Ordner"}
{k.hinweis}
{k.groesse_gb.toLocaleString("de-DE")} GB
setFrage(k)}>
Löschen
))}
)}
setFrage(null)}
onJa={() => {
if (frage) loeschen.mutate(frage)
setFrage(null)
}}
/>
)
}
export function ModelleSeite() {
const modelle = useModelle()
const nutzung = useNutzung()
const radar = useRadar()
const jobs = useJobs()
const [suche, setSuche] = useState(false)
// „Ansehen“ im Radar-Kasten der Startseite führt nach /modelle#radar. Der Router springt beim
// Rendern selbst zum Anker — da steht hier aber noch „wird gelesen“. Also springen wir, sobald
// alles über dem Radar geladen ist und die Seite nicht mehr wächst.
const hash = useLocation({ select: (l) => l.hash })
const bereit = !!modelle.data && !nutzung.isPending && !radar.isPending && !jobs.isPending
useEffect(() => {
if (bereit && hash === "radar") document.getElementById("radar")?.scrollIntoView({ block: "start" })
}, [bereit, hash])
if (modelle.isPending) return
// Scheitert nur ein späteres Nachladen, bleibt der letzte Stand stehen.
if (!modelle.data) return
const liste = modelle.data.models
const laufend = modelle.data.running
const hirn = liste.find((m) => rolleVon(m) === "hirn")
const coder = liste.find((m) => rolleVon(m) === "coder")
// Bild-Zwilling: gleiche Gewichte, eigener Alias (vision bzw. coder-bild).
const zwilling = (rolle: Modell | undefined, alias: string) =>
rolle ? liste.find((m) => m !== rolle && m.aliases.includes(alias) && m.filename === rolle.filename) : undefined
const hirnZwilling = zwilling(hirn, "vision")
const coderZwilling = zwilling(coder, "coder-bild")
const weitere = liste.filter((m) => ![hirn, coder, hirnZwilling, coderZwilling].includes(m))
const zuletzt = nutzung.data?.zuletzt_geladen ?? {}
return (
<>
setSuche(true)}> Modelle selbst suchen}
>
{weitere.length === 0 && Keine weiteren Einträge.
}
{weitere.map((m) => (
{m.name}
{m.aliases.length ? m.aliases.join(", ") : "ohne Rolle"}
{gbText(m.size_bytes)}
{laufend.includes(m.name) ? "geladen" : zuletzt[m.name] ? `zuletzt ${flugplanZeit(zuletzt[m.name])}` : "länger nicht geladen"}
))}
setSuche(false)}
rollenJetzt={{
coder: coder
? { name: coder.name, zwilling: !!coderZwilling, weitere: coder.aliases.filter((a) => a.toLowerCase() !== "coder") }
: undefined,
}}
/>
>
)
}