Refactor: ModelsView in Cockpit/AddModel/Discover-Dateien zerlegen (Phase 4c)

ModelsView 1455 -> 46 Zeilen reiner Orchestrator. Die drei großen Komponenten
in eigene Dateien verschoben (verbatim, kein Verhaltenswechsel):
- views/models/Cockpit.tsx (~1007 Z., VRAM-HUD, Gateway-Graph, Modell-Bibliothek)
- views/models/Discover.tsx (~285 Z., Empfehlungen) inkl. ROLE_METADATA
- views/models/AddModel.tsx (~131 Z., HF-Download/Suche)

Verifiziert: tsc grün, Build grün, Cockpit- und Discover-Tab rendern ohne
Konsolenfehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-26 15:03:24 +02:00
parent f1b0d61ada
commit eafeaf333d
6 changed files with 1431 additions and 1416 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-DSczZxZr.js"></script> <script type="module" crossorigin src="/assets/index-paq9nNtl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DiSNgbNY.css"> <link rel="stylesheet" crossorigin href="/assets/index-DiSNgbNY.css">
</head> </head>
<body> <body>
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
import { useState } from "react"
import { Download, Search } from "lucide-react"
import { api } from "@/lib/api"
export function AddModel() {
const [repo, setRepo] = useState("")
const [quants, setQuants] = useState<string[]>([])
const [quant, setQuant] = useState("Q4_K_M")
const [msg, setMsg] = useState("")
const [q, setQ] = useState("")
const [results, setResults] = useState<{ repo: string; downloads: number }[]>([])
async function loadQuants(r?: string) {
const rr = r ?? repo
if (!rr.trim()) return
setMsg("Analysiere HuggingFace Repository...")
try {
const d = await api<{ repo: string; quants: string[] }>(`/api/hf/quants?repo=${encodeURIComponent(rr)}`)
setRepo(d.repo)
setQuants(d.quants)
if (d.quants.length) setQuant(d.quants.includes("Q4_K_M") ? "Q4_K_M" : d.quants[0])
setMsg(d.quants.length ? "" : "Keine GGUF-Dateien in diesem Repository gefunden.")
} catch (e) {
setMsg(`Fehler: ${e}`)
}
}
async function search() {
if (!q.trim()) return
setMsg("Durchsuche HuggingFace...")
try {
const d = await api<{ results: { repo: string; downloads: number }[] }>(`/api/hf/search?q=${encodeURIComponent(q)}`)
setResults(d.results)
setMsg(d.results.length ? "" : "Keine Ergebnisse gefunden.")
} catch (e) {
setMsg(`Suche fehlgeschlagen: ${e}`)
}
}
async function install() {
if (!repo.trim()) return
setMsg("Download-Job wird initiiert...")
try {
await api("/api/models/install", {
method: "POST",
body: JSON.stringify({ repo, quant, jinja: true }),
})
setMsg(`Download gestartet: ${repo} (${quant}). Fortschritt wird oben angezeigt.`)
} catch (e) {
setMsg(`Download-Fehler: ${e}`)
}
}
return (
<div className="space-y-4 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10">
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">HF Download &amp; Suche</div>
<div className="flex flex-col sm:flex-row gap-2">
<input
value={repo}
onChange={(e) => setRepo(e.target.value)}
placeholder="HF-URL oder org/repo (z.B. unsloth/Qwen2.5-Coder-7B-Instruct-GGUF)"
className="flex-1 h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
/>
<div className="flex gap-2">
<button
onClick={() => loadQuants()}
className="h-9 px-4 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer whitespace-nowrap"
>
Quants laden
</button>
{quants.length > 0 && (
<>
<select
value={quant}
onChange={(e) => setQuant(e.target.value)}
className="h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none text-foreground font-semibold"
>
{quants.map((qq) => <option key={qq} value={qq} className="bg-popover text-foreground">{qq}</option>)}
</select>
<button
onClick={install}
className="h-9 px-4 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all cursor-pointer flex items-center gap-1.5"
>
<Download className="h-3.5 w-3.5" /> Herunterladen
</button>
</>
)}
</div>
</div>
<div className="flex gap-2 border-t border-border/20 pt-4">
<div className="relative flex-1">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && search()}
placeholder="HuggingFace durchsuchen (z.B. Llama-3.1)..."
className="w-full h-9 pl-9 pr-3 rounded-lg border border-border/60 bg-background/40 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
/>
<Search className="absolute left-3 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
</div>
<button
onClick={search}
className="h-9 px-4 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer"
>
Suchen
</button>
</div>
{results.length > 0 && (
<div className="max-h-48 space-y-1.5 overflow-y-auto border border-border/40 bg-background/20 p-2 rounded-xl scrollbar-thin">
{results.map((r) => (
<button
key={r.repo}
onClick={() => { setRepo(r.repo); setResults([]); setQ(""); loadQuants(r.repo) }}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-xs bg-background/10 border border-border/10 hover:border-primary/30 hover:bg-background/30 transition-all"
>
<span className="font-semibold truncate">{r.repo}</span>
<span className="text-[10px] font-mono text-muted-foreground flex items-center gap-1 shrink-0">
<Download className="h-3 w-3" /> {r.downloads.toLocaleString()}
</span>
</button>
))}
</div>
)}
{msg && <div className="text-[10px] font-medium text-primary font-mono">{msg}</div>}
</div>
)
}
File diff suppressed because it is too large Load Diff
+285
View File
@@ -0,0 +1,285 @@
import { useState } from "react"
import { Download, Search, Star, Layers, Check, Bot, Eye, Code, Brain, Compass, ChevronDown, ChevronUp } from "lucide-react"
import { api } from "@/lib/api"
import { useModels, useUpdates, useDiscover } from "@/lib/queries"
import { cn } from "@/lib/utils"
import { fmtBytes } from "@/lib/format"
import { FitBadge } from "@/components/models/ModelBadges"
import { AddModel } from "./AddModel"
const ROLE_METADATA: Record<string, { title: string; desc: string; icon: any }> = {
vision: {
title: "Bilder & Vision",
desc: "Verarbeitet Bilder, Screenshots und visuelle Diagramme in multimodalen Chats.",
icon: Eye
},
coder: {
title: "Coden & Entwicklung",
desc: "Autovervollständigung, Refactoring und Codegenerierung direkt in deiner IDE.",
icon: Code
},
reasoning: {
title: "Logik & Nachdenken",
desc: "Komplexe logische Gedankengänge, Mathematik und tiefgründiges Planen (Reasoning).",
icon: Brain
},
agent: {
title: "Autonomer Agent (Hermes)",
desc: "Führt selbstständig Terminalbefehle aus und interagiert mit deinem Homelab.",
icon: Bot
},
scout: {
title: "Allrounder & Chat",
desc: "Schnelle Antworten, Zusammenfassungen, Übersetzungen und alltägliche Fragen.",
icon: Compass
}
}
export function Discover() {
const { data, isLoading: loading, error: loadErr } = useDiscover()
const { data: modelsResp } = useModels()
const { data: updates } = useUpdates()
const models = modelsResp?.models ?? []
const error = loadErr ? String(loadErr) : ""
const [installing, setInstalling] = useState<Record<string, string>>({})
const [expandedAlternatives, setExpandedAlternatives] = useState<Record<string, boolean>>({})
const [showExpert, setShowExpert] = useState(false)
async function install(repo: string, role: string, quant: string, toolCapable: boolean) {
setInstalling((s) => ({ ...s, [repo]: "Starte..." }))
try {
await api("/api/models/install", {
method: "POST",
body: JSON.stringify({ repo, role, quant, jinja: toolCapable }),
})
setInstalling((s) => ({ ...s, [repo]: "Download läuft" }))
} catch (e) {
setInstalling((s) => ({ ...s, [repo]: "Fehler" }))
}
}
if (loading) return <div className="text-xs text-muted-foreground py-12 text-center">Analysiere Hardware und suche passende GGUF-Empfehlungen</div>
if (error || !data)
return (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
Empfehlungsdienst temporär nicht erreichbar ({error}).
</div>
)
return (
<div className="space-y-8">
{/* Informational Header */}
<div className="text-[10px] font-mono text-muted-foreground bg-background/25 border border-border/40 p-4 rounded-xl flex flex-col sm:flex-row sm:items-center justify-between gap-3 shadow-sm">
<div>
Modell-Registry geladen für <span className="text-foreground font-bold">{data.sys_ram_gb} GB</span> System-RAM.
</div>
<div className="flex items-center gap-1.5">
<Star className="h-3.5 w-3.5 text-primary fill-primary/20" />
<span>Empfehlungen sind automatisch auf deine Box-Hardware optimiert.</span>
</div>
</div>
{/* Sockets/Slots Grid */}
<div className="grid gap-6 md:grid-cols-2">
{data.categories.map((cat) => {
const meta = ROLE_METADATA[cat.role] || {
title: cat.title || cat.role,
desc: "Spezifisches Modell für diese Systemrolle.",
icon: Layers
}
const IconComponent = meta.icon
// Check if a model is installed for this role
const installedModel = models.find((m) => m.role === cat.role)
// Check if an upgrade is available for this role
const hasUpgrade = updates?.model_list.find((u) => u.role === cat.role)
// Get the primary recommended model
const recommendedModel = cat.models.find((m) => m.repo === cat.recommended) || cat.models[0]
if (!recommendedModel) return null
const isInstallingRecommended = installing[recommendedModel.repo]
const alternativeModels = cat.models.filter((m) => m.repo !== cat.recommended)
const isExpanded = !!expandedAlternatives[cat.role]
return (
<div
key={cat.role}
className={cn(
"rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-5 transition-all duration-300 hover:border-primary/40",
installedModel ? "border-border/60" : "border-primary/20 shadow-primary/5"
)}
>
<div className="space-y-4">
{/* Socket Header */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shadow-sm shrink-0">
<IconComponent className="h-5.5 w-5.5" />
</div>
<div>
<h3 className="text-sm font-bold tracking-tight text-foreground">{meta.title}</h3>
<span className="text-[9px] font-mono text-muted-foreground uppercase bg-background/50 px-1.5 py-0.5 rounded border border-border/30 inline-block mt-0.5">
Rolle: {cat.role}
</span>
</div>
</div>
{/* Status Badges */}
{installedModel ? (
<span className="flex items-center gap-1.5 text-[9px] font-bold text-emerald-400 uppercase tracking-wider bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-1 rounded-lg">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
Aktiviert
</span>
) : (
<span className="text-[9px] font-bold text-primary/70 uppercase tracking-wider bg-primary/10 border border-primary/20 px-2.5 py-1 rounded-lg">
Frei
</span>
)}
</div>
{/* Role Description */}
<p className="text-xs text-muted-foreground leading-relaxed">
{meta.desc}
</p>
{/* Current vs Recommended model card */}
<div className="p-3.5 rounded-xl bg-background/35 border border-border/30 space-y-2.5">
{installedModel ? (
<div className="space-y-1.5">
<div className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/60">Aktive GGUF-Belegung</div>
<div className="text-xs font-mono font-bold text-foreground truncate" title={installedModel.name}>
{installedModel.name.split("/").pop()}
</div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2">
<span>Größe: {fmtBytes(installedModel.size_bytes || 0)}</span>
<span></span>
<span>Quant: {installedModel.quant || "GGUF"}</span>
</div>
</div>
) : (
<div className="space-y-1.5">
<div className="text-[9px] font-bold uppercase tracking-wider text-primary/80">Empfohlenes Modell</div>
<div className="text-xs font-mono font-bold text-foreground truncate" title={recommendedModel.name}>
{recommendedModel.name}
</div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2 flex-wrap">
<span>Ersteller: {recommendedModel.author}</span>
<span></span>
<span>Quant: {recommendedModel.quant}</span>
</div>
<div className="flex items-center gap-1.5 pt-0.5">
<FitBadge fit={recommendedModel.fit} />
</div>
</div>
)}
</div>
{/* Main Action Button */}
<div className="pt-1">
{installedModel ? (
hasUpgrade ? (
<div className="space-y-2">
<div className="text-[9px] font-semibold text-amber-400 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping shrink-0" />
<span>Bessere Version in der Registry: {hasUpgrade.repo.split("/").pop()}</span>
</div>
<button
onClick={() => install(hasUpgrade.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
disabled={!!installing[hasUpgrade.repo]}
className="h-8 w-full flex items-center justify-center gap-1.5 rounded-lg bg-amber-500 text-black text-xs font-bold hover:bg-amber-400 disabled:opacity-50 transition-all cursor-pointer shadow-md shadow-amber-500/10"
>
<Download className="h-3.5 w-3.5" />
{installing[hasUpgrade.repo] || "Auf neue Version aktualisieren"}
</button>
</div>
) : (
<div className="h-8 flex items-center justify-center gap-1.5 text-[10px] font-bold text-emerald-400 uppercase tracking-wide bg-emerald-500/5 border border-emerald-500/10 rounded-lg select-none">
<Check className="h-4 w-4" /> Auf neuestem Stand
</div>
)
) : (
<button
onClick={() => install(recommendedModel.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
disabled={!!isInstallingRecommended}
className={cn(
"h-8 w-full flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer border",
isInstallingRecommended
? "border-primary/40 bg-primary/5 text-primary"
: "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/10"
)}
>
<Download className="h-3.5 w-3.5" />
{isInstallingRecommended || "Optimales Modell einsetzen"}
</button>
)}
</div>
</div>
{/* Collapsible alternatives list */}
{alternativeModels.length > 0 && (
<div className="border-t border-border/20 pt-3">
<button
onClick={() => setExpandedAlternatives((s) => ({ ...s, [cat.role]: !isExpanded }))}
className="flex items-center gap-1.5 text-[10px] text-muted-foreground/80 hover:text-foreground transition-colors cursor-pointer"
>
{isExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
<span>Alternative Empfehlungen anzeigen ({alternativeModels.length})</span>
</button>
{isExpanded && (
<div className="mt-2.5 space-y-2 max-h-36 overflow-y-auto pr-1 scrollbar-thin">
{alternativeModels.map((alt) => (
<div key={alt.repo} className="p-2 rounded-lg bg-background/25 border border-border/20 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-[10px] font-mono font-bold text-foreground truncate" title={alt.name}>
{alt.name}
</div>
<div className="text-[9px] text-muted-foreground flex items-center gap-1.5 mt-0.5">
<span>Quant: {alt.quant}</span>
<span></span>
<span>{alt.fit.text}</span>
</div>
</div>
<button
onClick={() => install(alt.repo, cat.role, alt.quant || "Q4_K_M", alt.caps.tools !== "no")}
disabled={!!installing[alt.repo]}
className="h-6 px-2.5 rounded bg-background/40 hover:bg-background/80 border border-border/40 text-[9px] font-bold text-foreground transition-all cursor-pointer shrink-0 disabled:opacity-50"
>
{installing[alt.repo] || "Installieren"}
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
)
})}
</div>
{/* Collapsible Custom Hugging Face Downloader */}
<div className="border border-border/50 bg-card/20 rounded-2xl overflow-hidden shadow-md mt-4">
<button
onClick={() => setShowExpert(!showExpert)}
className="w-full px-5 py-4 flex items-center justify-between text-xs font-bold uppercase tracking-wider text-muted-foreground hover:bg-card/30 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-primary" />
<span>Eigenes Modell von Hugging Face laden (Erweiterte Ansicht)</span>
</div>
<span className="text-[10px] text-primary hover:underline">
{showExpert ? "Ausblenden ▲" : "Anzeigen ▼"}
</span>
</button>
{showExpert && (
<div className="p-5 border-t border-border/20 bg-card/10">
<AddModel />
</div>
)}
</div>
</div>
)
}