26224804ed
- Gedaechtnis: 'Im Terminal starten' navigierte zu nicht existenter View 'terminal'
-> 'konsole'; interne Duplikate (AUTO-Set, CAT_LABEL_MAP) entfernt
- Konsole: box-console jetzt in Dienste-Liste + Restart-Allowlist; Offline-Overlay
bekommt Ein-Klick-'Dienst neu starten' statt SSH-Anweisung
- Cockpit Werkzeuge: Doppel-Eintrag ('Box-Shell' + 'Interaktives Terminal', beide
-> Konsole) zu EINEM Eintrag 'Konsole (Box-Shell)' zusammengelegt
- Auftragsbuch: 'Nichts zu entscheiden'-Banner beruecksichtigt blockierte
Ideen-Karten; Abschnitt heisst neutral 'Patch-Vorschlaege'
- Hermes-Diagnose: Klickweg (Dienste-Schublade oeffnen) statt veraltetem
Drawer-Namen + systemctl-Kommandos
- Entdecken: Upgrade-Knopf uebernimmt fremde Quant nicht mehr blind
- Skills: Status-Badge zeigt Klartext (abgeschaltet/defekt/...) statt rohem State
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
310 lines
15 KiB
TypeScript
310 lines
15 KiB
TypeScript
import { useState } from "react"
|
|
import { Download, Star, Layers, Check, Zap, Eye, Code, Brain, BrainCircuit, Compass, ChevronDown, ChevronUp, Scale, Search } 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 { ModelBrowse } from "./ModelBrowse"
|
|
|
|
// Die kanonischen Rollen (identisch zu sources.py / ModelBadges.ROLES).
|
|
const ROLE_METADATA: Record<string, { title: string; desc: string; icon: any }> = {
|
|
fast: {
|
|
title: "Schnelles Alltags-Hirn",
|
|
desc: "Schnelle MoE-Antworten für Chat, Zusammenfassungen und alltägliche Aufgaben.",
|
|
icon: Zap
|
|
},
|
|
heavy: {
|
|
title: "Schweres Reasoning",
|
|
desc: "Große Modelle für komplexe Logik, Mathematik und tiefgründiges Planen.",
|
|
icon: Brain
|
|
},
|
|
coder: {
|
|
title: "Coden & Entwicklung",
|
|
desc: "Autovervollständigung, Refactoring und Codegenerierung direkt in deiner IDE.",
|
|
icon: Code
|
|
},
|
|
vision: {
|
|
title: "Bilder & Vision",
|
|
desc: "Verarbeitet Bilder, Screenshots und visuelle Diagramme in multimodalen Chats.",
|
|
icon: Eye
|
|
},
|
|
hermes: {
|
|
title: "Lucys Hirn (Agent)",
|
|
desc: "Lucys dauer-warmes Agent-Hirn mit Tool-Calling — bleibt ko-resident, lädt nie kalt nach.",
|
|
icon: BrainCircuit
|
|
},
|
|
scout: {
|
|
title: "Multimodal-Allrounder",
|
|
desc: "Multimodaler Allrounder für schnelle Antworten, Übersetzungen und Alltag.",
|
|
icon: Compass
|
|
},
|
|
kritiker: {
|
|
title: "Kritisches Prüf-Hirn",
|
|
desc: "Schärfer Blick für Fehler, Schwachstellen und Sicherheitslücken — denkt skeptisch, bevor du deployst.",
|
|
icon: Scale
|
|
},
|
|
reranker: {
|
|
title: "Gedächtnis-Sortierer",
|
|
desc: "Sortiert und gewichtet gespeicherte Fakten nach Relevanz — damit die richtige Info sofort da ist.",
|
|
icon: Search
|
|
}
|
|
}
|
|
|
|
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 [mode, setMode] = useState<"recommended" | "browse">("recommended")
|
|
|
|
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" }))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Modus-Umschalter: geführt (Empfohlen) vs. Stöbern & Suchen */}
|
|
<div className="flex mc-card-sm p-1 w-fit">
|
|
{([["recommended", "Empfohlen"], ["browse", "Stöbern & Suchen"]] as const).map(([m, label]) => (
|
|
<button
|
|
key={m}
|
|
onClick={() => setMode(m)}
|
|
className={cn(
|
|
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide transition-all cursor-pointer",
|
|
mode === m ? "bg-primary text-primary-foreground shadow-md shadow-primary/10" : "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{mode === "browse" ? (
|
|
<ModelBrowse />
|
|
) : loading ? (
|
|
<div className="text-xs text-muted-foreground py-12 text-center">Analysiere Hardware und suche passende GGUF-Empfehlungen…</div>
|
|
) : (error || !data) ? (
|
|
<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>
|
|
) : (
|
|
<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 — per Rolle ODER Alias:
|
|
// ein Modell kann eine Kategorie über einen Alias bedienen (z.B. Qwen3.6 hat
|
|
// role="hermes" + Alias "fast" → sonst zeigte die Fast-Karte fälschlich „Frei").
|
|
const installedModel = models.find(
|
|
(m) => m.role === cat.role || (m.aliases || []).includes(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(
|
|
"mc-card p-5 flex flex-col justify-between gap-5",
|
|
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
|
|
// Quant des Empfehlungsmodells nur übernehmen, wenn das Upgrade DASSELBE
|
|
// Repo ist — sonst neutraler Default (fremde Quant könnte dort fehlen).
|
|
onClick={() => install(hasUpgrade.repo, cat.role,
|
|
hasUpgrade.repo === recommendedModel.repo ? (recommendedModel.quant || "Q4_K_M") : "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>
|
|
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|