fix(security): v3-Umbau P1 — Geheimnisse raus aus dem Browser, Frontend endlich getestet
Zweite Etappe. Kern: das Frontend traegt kein Geheimnis mehr, und es laeuft
nicht laenger als einziger Teil des Stacks ungeprueft durchs Gate.
## B-01 — Box-Sudo-Passwort: ersatzlos entfernt
Das Passwort lag im localStorage und reiste bei JEDEM mutierenden Request mit —
als Header `X-Sudo-Password` UND im JSON-Rumpf. Da /etc/sudoers den Dienst-Nutzer
mit `NOPASSWD: ALL` fuehrt, waere ein einziger XSS in der SPA Root auf der Box
gewesen.
AUF DER BOX GEMESSEN: `sudo -n true` laeuft durch. Das Passwort wurde also nie
gebraucht — es war reines Risiko ohne Gegenwert. Darum keine Umkonstruktion
(Sitzungs-Cookie o. AE.), sondern Loeschung, quer durch den ganzen Pfad:
frontend/src/lib/api.ts kein localStorage-Zugriff mehr
frontend/.../SettingsTab.tsx Eingabefeld weg, dafuer die Erklaerung warum
backend/routers/maintenance.py SudoReq entfaellt, 8 Endpunkte entschlackt
backend/services/maintenance.py _run() nutzt immer `sudo -n`
backend/services/jobengine.py keine stdin-Pipe mehr (DEVNULL)
`password_required` bleibt als ehrliches Signal: Verlangt sudo je doch ein
Passwort, ist das eine Konfigurations-Frage auf der Box — nichts, was man mit
einem im Browser geparkten Geheimnis uebertuencht.
## HuggingFace-Token: liegt jetzt auf der Box
Derselbe Fehler, kleinerer Radius. Neu: backend/services/geheimnisse.py — Datei
neben den anderen mc2-*.json, Rechte 0600, atomar geschrieben. Die Oberflaeche
erfaehrt nur, OB ein Token gesetzt ist, nie seinen Wert. Ein Schluessel-Allowlist
verhindert, dass ein fehlgeleiteter Request beliebige Felder hineinschreibt.
Prozess-Env (HF_TOKEN) hat Vorrang und wird als solche angezeigt.
Verifiziert gegen das lokale Backend: setzen/lesen/loeschen ok, unerlaubter
Schluessel wird mit Klartext-Grund abgewiesen, der Wert kommt nie zurueck.
## B-14 — Fehlermeldungen sagen jetzt, was los ist
api() warf `new Error("500 Internal Server Error")` und verwarf den Rumpf; der
eigentliche Grund aus FastAPIs `detail` erreichte die Oberflaeche nie. Neu:
ApiError mit status + detail, inklusive Validierungslisten und HTML-Fehlerseiten
(ein kaputter Rumpf darf die Meldung nicht in einen zweiten Fehler verwandeln).
Zwei Aufrufstellen zeigen den Grund jetzt statt "Fehler" (Discover, ModelBrowse).
## B-07 — Tests und Linter, ehrlich eingeordnet
Praezisierung gegenueber dem Audit: Die MC2-Ampel fuehrt bewusst GAR KEINE Tests
aus (dokumentiert: die Python-Dienste haengen an ML-Wheels, die echten Tests sind
Pruefstand + Box). Das ist fuer die Dienste richtig — fuer Frontend-Unit-Tests
nicht: die laufen in jsdom, brauchen weder Modell noch GPU, und sind in 1,3 s durch.
Vitest + Testing Library, 17 Tests in 3 Dateien
ESLint (flat config) + Prettier
Beides jetzt Teil der Ampel
Die Tests sind kein Feigenblatt: acht davon sind der Zaun um B-01 — sie beweisen,
dass api() weder Kopfzeilen noch Rumpf aus dem localStorage anreichert. Dazu eine
ESLint-Regel, die localStorage-Zugriffe auf Schluessel mit password/token/secret
im Namen hart abweist (an einer Probe verifiziert; harmlose Schluessel wie
mc_sidebar_collapsed bleiben erlaubt).
ESLint meldet 0 Fehler / 93 Warnungen. Die 18 Treffer der neuen React-Compiler-
Regeln (setState im Effekt, Ref-Zugriff im Render) sind ECHT, aber quer durch
10 500 Zeilen zu beheben ist P5-Arbeit. Sie stehen als sichtbare Warn-Liste statt
abgeschaltet — ein ab Tag eins rotes Gate ist kein Gate mehr.
## Nebenbefund, im Browser reproduziert: veraltetes Buendel nach Deploy
Ein Deploy ersetzt dist und startet den Dienst neu; offene Tabs behalten aber ihr
altes Start-Buendel, dessen Nachlade-Chunks nun fehlen — der naechste
Ansichtswechsel wirft. Galt schon fuer die 10 lazy Views, traf durch P0 nun auch
die Startseite. lib/veralteteVersion.ts faengt Vites `vite:preloadError` ab und
laedt EINMAL neu (Sperre in sessionStorage gegen Endlosschleife).
## Nachgemessen
Start-Chunk gzip 111 411 B · dist gesamt 1 480 097 B (beide im Ampel-Budget)
tsc --noEmit sauber · 17/17 Tests gruen · ESLint 0 Fehler
Im echten Browser gegen das lokale Backend geprueft: Einstellungen holen den
Token-Zustand von der Box, kein Passwort-Feld mehr, Knoepfe korrekt gesperrt,
beide Cockpit-Diagramme rendern (Achsen + Zeitachse sichtbar).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8aa22e6591
commit
ae69c5ce31
@@ -75,7 +75,7 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (let entry of entries) {
|
||||
for (const entry of entries) {
|
||||
if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
|
||||
setDimensions({
|
||||
width: Math.floor(entry.contentRect.width),
|
||||
|
||||
@@ -1,314 +1,316 @@
|
||||
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
|
||||
},
|
||||
debugger: {
|
||||
title: "Debugger & Selbstheiler",
|
||||
desc: "Agentisches Debugging, multimodale Fehleranalyse und Selbstreparatur (z.B. Muse-Glimmer).",
|
||||
icon: Brain
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
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
|
||||
},
|
||||
debugger: {
|
||||
title: "Debugger & Selbstheiler",
|
||||
desc: "Agentisches Debugging, multimodale Fehleranalyse und Selbstreparatur (z.B. Muse-Glimmer).",
|
||||
icon: Brain
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// Seit P1 traegt api() den echten Grund vom Backend (ApiError.detail) — den zeigen,
|
||||
// statt ihn zu verschlucken und "Fehler" hinzuschreiben.
|
||||
setInstalling((s) => ({ ...s, [repo]: `Fehler: ${e instanceof Error ? e.message : String(e)}` }))
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,7 +118,9 @@ export function ModelBrowse() {
|
||||
setInstalling((s) => ({ ...s, [repo]: "Download läuft" }))
|
||||
setOomArmed(false)
|
||||
} catch (e) {
|
||||
setInstalling((s) => ({ ...s, [repo]: "Fehler" }))
|
||||
// Seit P1 traegt api() den echten Grund vom Backend (ApiError.detail) — den zeigen,
|
||||
// statt ihn zu verschlucken und "Fehler" hinzuschreiben.
|
||||
setInstalling((s) => ({ ...s, [repo]: `Fehler: ${e instanceof Error ? e.message : String(e)}` }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user