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
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { LiveAreaChart } from "./LiveAreaChart"
|
||||
import type { ChartSeries } from "./chartTypes"
|
||||
|
||||
const SERIEN: ChartSeries[] = [{ key: "cpu", label: "CPU", color: "#2dd4bf" }]
|
||||
const DATEN = [
|
||||
{ t: 1_700_000_000_000, cpu: 12 },
|
||||
{ t: 1_700_000_003_000, cpu: 34 },
|
||||
]
|
||||
|
||||
// Die Lazy-Huelle ist der Grund, warum Recharts nicht mehr im Startbuendel liegt (P0).
|
||||
// Wichtig ist dabei nur eines: Der Platzhalter muss GENAU so hoch sein wie das spaetere
|
||||
// Diagramm — sonst springt die Karte beim Nachladen.
|
||||
describe("LiveAreaChart (Lazy-Huelle)", () => {
|
||||
it("haelt vor dem Nachladen die volle Diagrammhoehe frei", () => {
|
||||
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
|
||||
const platzhalter = container.querySelector<HTMLElement>('[aria-hidden="true"]')
|
||||
expect(platzhalter).not.toBeNull()
|
||||
expect(platzhalter!.style.height).toBe("176px")
|
||||
})
|
||||
|
||||
it("uebernimmt eine abweichende Hoehe", () => {
|
||||
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={96} />)
|
||||
expect(container.querySelector<HTMLElement>('[aria-hidden="true"]')!.style.height).toBe("96px")
|
||||
})
|
||||
|
||||
it("laedt die Recharts-Umsetzung nach und zeigt sie an", async () => {
|
||||
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".recharts-responsive-container")).not.toBeNull()
|
||||
})
|
||||
// Platzhalter ist verschwunden, sobald das Diagramm da ist.
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
|
||||
})
|
||||
|
||||
it("ist fuer Screenreader stumm — der Platzhalter ist reine Optik", () => {
|
||||
render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
|
||||
expect(screen.queryByText(/l(ä|ae)dt/i)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,123 +1,182 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Eye, EyeOff, Key, Shield } from "lucide-react"
|
||||
|
||||
// Einstellungen-Tab des SystemDrawers (Review P2-14: extrahiert). Hält seinen State komplett
|
||||
// selbst (localStorage-Zugangsdaten) — der Drawer liefert nur `open` (zum Neu-Laden) und
|
||||
// `showAlert` (gemeinsamer Dialog).
|
||||
export function SettingsTab({ open, showAlert }: {
|
||||
open: boolean
|
||||
showAlert: (title: string, message: string) => void
|
||||
}) {
|
||||
const [sudoPasswordInput, setSudoPasswordInput] = useState("")
|
||||
const [hfTokenInput, setHfTokenInput] = useState("")
|
||||
const [showSudo, setShowSudo] = useState(false)
|
||||
const [showHf, setShowHf] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSudoPasswordInput(localStorage.getItem("mc_sudo_password") || "")
|
||||
setHfTokenInput(localStorage.getItem("mc_hf_token") || "")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
Diese Daten werden ausschließlich lokal in deinem Browser (localStorage) gespeichert und bei Bedarf an das Backend übertragen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sudo Passwort */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Shield className="h-4 w-4 text-amber-400" />
|
||||
Host Sudo-Passwort
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSudo ? "text" : "password"}
|
||||
value={sudoPasswordInput}
|
||||
onChange={(e) => setSudoPasswordInput(e.target.value)}
|
||||
aria-label="Host Sudo-Passwort"
|
||||
name="sudo-password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="Sudo-Passwort für System-Operationen"
|
||||
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSudo(!showSudo)}
|
||||
aria-label={showSudo ? "Passwort verbergen" : "Passwort anzeigen"}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showSudo ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground leading-normal">
|
||||
Wird benötigt für systemd-Dienste (Llama Swap logs/restart), OS-Update (apt) und Reboot.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* HuggingFace Token */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Key className="h-4 w-4 text-violet-400" />
|
||||
HuggingFace API Token
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showHf ? "text" : "password"}
|
||||
value={hfTokenInput}
|
||||
onChange={(e) => setHfTokenInput(e.target.value)}
|
||||
aria-label="HuggingFace API Token"
|
||||
name="hf-token"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="hf_…"
|
||||
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHf(!showHf)}
|
||||
aria-label={showHf ? "Token verbergen" : "Token anzeigen"}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showHf ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground leading-normal">
|
||||
Erlaubt das Herunterladen von geschützten oder Gate-restricted Modellen direkt über Mission Control.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.setItem("mc_sudo_password", sudoPasswordInput);
|
||||
localStorage.setItem("mc_hf_token", hfTokenInput);
|
||||
showAlert("Erfolgreich", "Einstellungen erfolgreich lokal gespeichert.");
|
||||
}}
|
||||
className="flex-1 h-9 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors shadow shadow-primary/10 cursor-pointer"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSudoPasswordInput("");
|
||||
setHfTokenInput("");
|
||||
localStorage.removeItem("mc_sudo_password");
|
||||
localStorage.removeItem("mc_hf_token");
|
||||
showAlert("Gelöscht", "Zugangsdaten gelöscht.");
|
||||
}}
|
||||
className="h-9 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useEffect, useState } from "react"
|
||||
import { Eye, EyeOff, Key, Loader2, ShieldCheck } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
|
||||
// Einstellungen-Tab des SystemDrawers.
|
||||
//
|
||||
// v3-Umbau P1 (28.08.2026): Hier standen zwei Geheimnisse, die im localStorage des
|
||||
// Browsers lagen und bei jedem mutierenden Request mitreisten.
|
||||
//
|
||||
// · Das Box-Sudo-Passwort ist ERSATZLOS weg. Auf der Box gemessen: `sudo -n true`
|
||||
// laeuft durch, weil /etc/sudoers den Dienst-Nutzer mit `NOPASSWD: ALL` fuehrt.
|
||||
// Das Feld hat also nie etwas bewirkt — es war reines Risiko (ein XSS in dieser
|
||||
// SPA haette Root bedeutet).
|
||||
// · Der HuggingFace-Token liegt jetzt auf der Box (services/geheimnisse.py, 0600).
|
||||
// Diese Ansicht erfaehrt nur noch, OB einer gesetzt ist — nie seinen Wert. Deshalb
|
||||
// gibt es kein "Anzeigen" fuer einen gespeicherten Token, sondern nur Ersetzen
|
||||
// oder Loeschen.
|
||||
|
||||
interface GeheimnisStatus {
|
||||
hf_token_gesetzt: boolean
|
||||
hf_token_aus_env: boolean
|
||||
schreibbar: boolean
|
||||
}
|
||||
|
||||
export function SettingsTab({ open, showAlert }: {
|
||||
open: boolean
|
||||
showAlert: (title: string, message: string) => void
|
||||
}) {
|
||||
const [status, setStatus] = useState<GeheimnisStatus | null>(null)
|
||||
const [eingabe, setEingabe] = useState("")
|
||||
const [sichtbar, setSichtbar] = useState(false)
|
||||
const [laedt, setLaedt] = useState(false)
|
||||
const [arbeitet, setArbeitet] = useState(false)
|
||||
|
||||
async function statusLaden() {
|
||||
setLaedt(true)
|
||||
try {
|
||||
setStatus(await api<GeheimnisStatus>("/api/maintenance/geheimnisse"))
|
||||
} catch (e: any) {
|
||||
showAlert("Nicht erreichbar", `Der Geheimnis-Zustand konnte nicht geladen werden: ${e?.message || e}`)
|
||||
} finally {
|
||||
setLaedt(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setEingabe("")
|
||||
setSichtbar(false)
|
||||
void statusLaden()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
async function setzen(wert: string | null) {
|
||||
setArbeitet(true)
|
||||
try {
|
||||
const r = await api<GeheimnisStatus>("/api/maintenance/geheimnisse", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ schluessel: "hf_token", wert }),
|
||||
})
|
||||
setStatus(r)
|
||||
setEingabe("")
|
||||
showAlert(wert ? "Gespeichert" : "Gelöscht",
|
||||
wert ? "Der Token liegt jetzt auf der Box — der Browser behält ihn nicht."
|
||||
: "Der Token wurde von der Box entfernt.")
|
||||
} catch (e: any) {
|
||||
showAlert("Fehlgeschlagen", e?.message || String(e))
|
||||
} finally {
|
||||
setArbeitet(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3>
|
||||
<p className="text-[10px] leading-normal text-muted-foreground">
|
||||
Geheimnisse liegen auf der Box (Datei mit Rechten 0600), nicht im Browser. Diese Seite
|
||||
zeigt nur, ob etwas gesetzt ist — den Wert bekommt sie nie zurück.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sudo: bewusst nur noch eine Erklaerung, kein Eingabefeld. */}
|
||||
<div className="mc-card-sm space-y-1.5 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold">
|
||||
<ShieldCheck className="h-4 w-4 text-emerald-400" aria-hidden="true" />
|
||||
Box-Sudo-Passwort — nicht mehr nötig
|
||||
</div>
|
||||
<p className="text-[10px] leading-normal text-muted-foreground">
|
||||
Systemdienste, OS-Update und Neustart laufen passwortlos (<code className="font-mono text-primary">NOPASSWD</code> in
|
||||
<code className="font-mono"> /etc/sudoers</code>). Das frühere Feld hat nie etwas bewirkt und lag dabei
|
||||
im Browser-Speicher — es ist am 28.08.2026 ersatzlos entfallen. Verlangt <code className="font-mono">sudo</code> auf
|
||||
der Box je doch ein Passwort, sagt die Wartung das im Klartext; das ist dann dort zu lösen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* HuggingFace-Token */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="hf-token" className="flex items-center gap-1.5 text-xs font-semibold">
|
||||
<Key className="h-4 w-4 text-violet-400" aria-hidden="true" />
|
||||
HuggingFace-Token
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
{laedt ? (
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" /> Zustand wird geladen …
|
||||
</span>
|
||||
) : status?.hf_token_gesetzt ? (
|
||||
<span className="flex items-center gap-1.5 font-semibold text-emerald-400">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" />
|
||||
Gesetzt{status.hf_token_aus_env ? " (aus der Dienst-Umgebung)" : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<span className="h-2 w-2 rounded-full bg-muted-foreground/50" aria-hidden="true" />
|
||||
Nicht gesetzt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status?.hf_token_aus_env && (
|
||||
<p className="text-[9px] leading-normal text-amber-400/90">
|
||||
Der Token kommt aus der Umgebung des Dienstes und hat Vorrang. Hier gespeicherte Werte
|
||||
bleiben wirkungslos, solange <code className="font-mono">HF_TOKEN</code> gesetzt ist.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status && !status.schreibbar && (
|
||||
<p className="text-[9px] leading-normal text-amber-400/90">
|
||||
Die Ablage ist nicht beschreibbar — auf einem Entwicklungsrechner ohne den Modell-Ordner
|
||||
der Box ist das normal. Speichern würde hier ins Leere laufen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
id="hf-token"
|
||||
type={sichtbar ? "text" : "password"}
|
||||
value={eingabe}
|
||||
onChange={(e) => setEingabe(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={status?.hf_token_gesetzt ? "Neuen Token eingeben, um zu ersetzen" : "hf_…"}
|
||||
className="h-10 w-full rounded-lg border border-border/60 bg-background/40 pl-3 pr-10 text-xs text-foreground outline-none transition-colors focus:border-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSichtbar(!sichtbar)}
|
||||
aria-label={sichtbar ? "Token verbergen" : "Token anzeigen"}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{sichtbar ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[9px] leading-normal text-muted-foreground">
|
||||
Nötig für Modelle mit Zugangsschranke. Wird beim Download als
|
||||
<code className="font-mono"> HF_TOKEN</code> an den Job gereicht und verlässt die Box nicht.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => setzen(eingabe.trim())}
|
||||
disabled={arbeitet || !eingabe.trim()}
|
||||
className="flex h-9 flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg bg-primary text-xs font-semibold text-primary-foreground shadow shadow-primary/10 transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{arbeitet && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
|
||||
Auf der Box speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setzen(null)}
|
||||
disabled={arbeitet || !status?.hf_token_gesetzt}
|
||||
className="flex h-9 cursor-pointer items-center justify-center rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { ApiError, api } from "./api"
|
||||
|
||||
/** Ruft api() auf und gibt den erwarteten ApiError typisiert zurueck.
|
||||
* Kommt kein Fehler, scheitert der Test hier statt spaeter an einer Zusicherung. */
|
||||
async function fehlerVon(pfad: string, init?: RequestInit): Promise<ApiError> {
|
||||
try {
|
||||
await api(pfad, init)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return e
|
||||
throw e
|
||||
}
|
||||
throw new Error(`api(${pfad}) hat keinen Fehler geworfen`)
|
||||
}
|
||||
|
||||
// Antwort-Attrappe: `text()` liefert den Rumpf, den fehlerText() lesen soll.
|
||||
function antwort(status: number, rumpf: string, ok = status < 400): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
statusText: status === 500 ? "Internal Server Error" : "Bad Request",
|
||||
text: async () => rumpf,
|
||||
json: async () => JSON.parse(rumpf),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function fetchAttrappe(res: Response) {
|
||||
const spion = vi.fn(async () => res)
|
||||
vi.stubGlobal("fetch", spion)
|
||||
return spion
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe("api()", () => {
|
||||
it("gibt den JSON-Rumpf zurueck, wenn alles gut geht", async () => {
|
||||
fetchAttrappe(antwort(200, '{"ok":true,"wert":42}'))
|
||||
await expect(api<{ wert: number }>("/api/test")).resolves.toEqual({ ok: true, wert: 42 })
|
||||
})
|
||||
|
||||
// Der Kern von B-14: frueher warf der Helfer "500 Internal Server Error" und verwarf
|
||||
// den Rumpf — der eigentliche Grund erreichte die Oberflaeche nie.
|
||||
it("hebt FastAPIs `detail` in die Fehlermeldung", async () => {
|
||||
fetchAttrappe(antwort(400, '{"detail":"Dienst \'llama-swap\' nicht erlaubt."}'))
|
||||
await expect(api("/api/maintenance/restart", { method: "POST" }))
|
||||
.rejects.toThrowError("Dienst 'llama-swap' nicht erlaubt.")
|
||||
})
|
||||
|
||||
it("fasst FastAPI-Validierungslisten zu einem Satz zusammen", async () => {
|
||||
fetchAttrappe(antwort(422, '{"detail":[{"msg":"repo fehlt"},{"msg":"quant ungueltig"}]}'))
|
||||
await expect(api("/api/models/install", { method: "POST" }))
|
||||
.rejects.toThrowError("repo fehlt; quant ungueltig")
|
||||
})
|
||||
|
||||
it("faellt auf Status + Statustext zurueck, wenn der Rumpf leer ist", async () => {
|
||||
fetchAttrappe(antwort(500, ""))
|
||||
await expect(api("/api/test")).rejects.toThrowError("500 Internal Server Error")
|
||||
})
|
||||
|
||||
it("laesst sich von einer HTML-Fehlerseite nicht aus dem Tritt bringen", async () => {
|
||||
fetchAttrappe(antwort(502, "<html><body>Bad Gateway</body></html>"))
|
||||
const fehler = await fehlerVon("/api/test")
|
||||
expect(fehler.status).toBe(502)
|
||||
// Kein zweiter Fehler beim Parsen — irgendeine brauchbare Zeile kommt heraus.
|
||||
expect(typeof fehler.message).toBe("string")
|
||||
expect(fehler.message.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("traegt status und detail am Fehlerobjekt", async () => {
|
||||
fetchAttrappe(antwort(400, '{"detail":"kaputt"}'))
|
||||
const fehler = await fehlerVon("/api/test")
|
||||
expect(fehler.status).toBe(400)
|
||||
expect(fehler.detail).toBe("kaputt")
|
||||
})
|
||||
})
|
||||
|
||||
// ── Sicherheits-Wache (B-01) ────────────────────────────────────────────────
|
||||
// Bis zum 28.08.2026 las api() das Box-Sudo-Passwort und den HF-Token aus dem
|
||||
// localStorage und haengte sie an jeden mutierenden Request — als Header UND in den
|
||||
// JSON-Rumpf. Da der Dienst-Nutzer auf der Box mit NOPASSWD: ALL laeuft, war ein XSS
|
||||
// in dieser SPA gleichbedeutend mit Root. Diese Tests sind der Zaun davor: Wer die
|
||||
// Bequemlichkeit je wieder einbaut, macht sie rot.
|
||||
describe("api() traegt keine Geheimnisse", () => {
|
||||
it("schickt keine Kopfzeilen und keinen Rumpf aus dem localStorage", async () => {
|
||||
localStorage.setItem("mc_sudo_password", "geheim123")
|
||||
localStorage.setItem("mc_hf_token", "hf_geheim")
|
||||
|
||||
const spion = fetchAttrappe(antwort(200, '{"ok":true}'))
|
||||
await api("/api/maintenance/restart", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ service: "llama-swap" }),
|
||||
})
|
||||
|
||||
const [, init] = spion.mock.calls[0] as unknown as [string, RequestInit]
|
||||
const kopfzeilen = JSON.stringify(init.headers ?? {})
|
||||
const rumpf = String(init.body ?? "")
|
||||
|
||||
expect(kopfzeilen.toLowerCase()).not.toContain("sudo")
|
||||
expect(kopfzeilen).not.toContain("geheim123")
|
||||
expect(rumpf).not.toContain("geheim123")
|
||||
expect(rumpf).not.toContain("hf_geheim")
|
||||
expect(rumpf).not.toContain("sudo_password")
|
||||
expect(rumpf).not.toContain("hf_token")
|
||||
// Der Rumpf des Aufrufers bleibt unveraendert — nichts wird heimlich angereichert.
|
||||
expect(JSON.parse(rumpf)).toEqual({ service: "llama-swap" })
|
||||
})
|
||||
|
||||
it("erfindet auch ohne Rumpf keinen", async () => {
|
||||
localStorage.setItem("mc_sudo_password", "geheim123")
|
||||
const spion = fetchAttrappe(antwort(200, '{"ok":true}'))
|
||||
await api("/api/maintenance/reboot", { method: "POST" })
|
||||
|
||||
const [, init] = spion.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(init.body ?? null).toBeNull()
|
||||
})
|
||||
})
|
||||
+647
-646
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { act, renderHook } from "@testing-library/react"
|
||||
|
||||
// Der Verlaufsspeicher ist modul-global (er soll Tab-Wechsel ueberleben). Fuer jeden Test
|
||||
// eine frische Modul-Instanz, sonst tragen sich die Faelle gegenseitig Daten ein.
|
||||
async function frischerStore() {
|
||||
vi.resetModules()
|
||||
return await import("./metricsStore")
|
||||
}
|
||||
|
||||
describe("metricsStore", () => {
|
||||
let store: Awaited<ReturnType<typeof frischerStore>>
|
||||
|
||||
beforeEach(async () => {
|
||||
store = await frischerStore()
|
||||
})
|
||||
|
||||
it("beginnt leer", () => {
|
||||
const { result } = renderHook(() => store.useSysHistory())
|
||||
expect(result.current).toEqual([])
|
||||
})
|
||||
|
||||
it("meldet neue Messpunkte an die Abonnenten", () => {
|
||||
const { result } = renderHook(() => store.useSysHistory())
|
||||
act(() => store.pushSys({ t: 1, cpu: 10, ram: 20, gpu: 30, disk: 40 }))
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0].cpu).toBe(10)
|
||||
})
|
||||
|
||||
// Ohne Deckel waechst der Verlauf unbegrenzt — bei 3-s-Takt sind das ~28 800 Punkte
|
||||
// pro Tag in einem Dauerlaeufer-Tab.
|
||||
it("deckelt den Verlauf und behaelt die JUENGSTEN Punkte", () => {
|
||||
const { result } = renderHook(() => store.useSysHistory())
|
||||
act(() => {
|
||||
for (let i = 0; i < 60; i++) store.pushSys({ t: i, cpu: i, ram: 0, gpu: null, disk: null })
|
||||
})
|
||||
expect(result.current).toHaveLength(40)
|
||||
expect(result.current[0].t).toBe(20)
|
||||
expect(result.current[39].t).toBe(59)
|
||||
})
|
||||
|
||||
it("haelt System- und Token-Verlauf auseinander", () => {
|
||||
const { result: sys } = renderHook(() => store.useSysHistory())
|
||||
const { result: tok } = renderHook(() => store.useTokHistory())
|
||||
act(() => store.pushTok({ t: 1, prompt: 5, completion: 7 }))
|
||||
expect(sys.current).toHaveLength(0)
|
||||
expect(tok.current).toHaveLength(1)
|
||||
expect(tok.current[0].completion).toBe(7)
|
||||
})
|
||||
|
||||
// useSyncExternalStore vergleicht per Referenz: wuerde pushSys das Array an Ort und
|
||||
// Stelle veraendern, bliebe die Anzeige stehen, obwohl Daten da sind.
|
||||
it("liefert bei jeder Aenderung ein NEUES Array (Referenzwechsel)", () => {
|
||||
const { result } = renderHook(() => store.useSysHistory())
|
||||
const vorher = result.current
|
||||
act(() => store.pushSys({ t: 1, cpu: 1, ram: 1, gpu: null, disk: null }))
|
||||
expect(result.current).not.toBe(vorher)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
// Umgang mit einem veralteten Bündel (v3-Umbau P1).
|
||||
//
|
||||
// DAS PROBLEM, hier gemessen am 28.08.2026: Ein Deploy ersetzt `frontend/dist` und startet
|
||||
// den Dienst neu. Offene Browser-Tabs behalten aber ihr altes, bereits geladenes
|
||||
// Start-Bündel — und dessen Nachlade-Chunks (`IdeenView-J4hS7xB2.js` …) tragen den Hash
|
||||
// des ALTEN Builds. Die Dateien sind weg. Beim nächsten Ansichtswechsel wirft der
|
||||
// dynamische Import, die Fehlergrenze schlägt zu und der Nutzer sieht eine kaputte Seite,
|
||||
// obwohl nur sein Tab alt ist.
|
||||
//
|
||||
// Vite meldet genau diesen Fall als `vite:preloadError`. Wir laden dann EINMAL neu — der
|
||||
// Browser holt `index.html` (das nie gecacht wird, siehe backend/app.py) und damit das
|
||||
// aktuelle Bündel. Die Sperre in sessionStorage verhindert eine Schleife, falls der Chunk
|
||||
// aus einem anderen Grund fehlt (Netz weg, Datei wirklich kaputt): Dann bleibt es beim
|
||||
// ehrlichen Fehler statt bei einer endlos neuladenden Seite.
|
||||
|
||||
const SPERRE = "mc_neuladen_wegen_version"
|
||||
|
||||
export function veralteteVersionAbfangen() {
|
||||
window.addEventListener("vite:preloadError", (e) => {
|
||||
if (sessionStorage.getItem(SPERRE)) return // schon versucht — Fehler durchlassen
|
||||
e.preventDefault() // verhindert, dass der Import zusätzlich als Absturz hochblubbert
|
||||
sessionStorage.setItem(SPERRE, "1")
|
||||
window.location.reload()
|
||||
})
|
||||
|
||||
// Hat der Neustart geklappt, ist die Sperre nach dem nächsten erfolgreichen Start wieder
|
||||
// frei — sonst wäre der Schutz nach dem ersten Deploy für diese Sitzung verbraucht.
|
||||
window.addEventListener("load", () => {
|
||||
window.setTimeout(() => sessionStorage.removeItem(SPERRE), 5_000)
|
||||
})
|
||||
}
|
||||
+32
-27
@@ -1,27 +1,32 @@
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import App from "./App"
|
||||
import { AppErrorBoundary } from "./components/AppErrorBoundary"
|
||||
import "./index.css"
|
||||
|
||||
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5_000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<AppErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</AppErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import App from "./App"
|
||||
import { AppErrorBoundary } from "./components/AppErrorBoundary"
|
||||
import { veralteteVersionAbfangen } from "./lib/veralteteVersion"
|
||||
import "./index.css"
|
||||
|
||||
// Muss VOR dem ersten Rendern hängen: Nach einem Deploy sucht ein offener Tab
|
||||
// Nachlade-Chunks des alten Builds, die es nicht mehr gibt (siehe lib/veralteteVersion.ts).
|
||||
veralteteVersionAbfangen()
|
||||
|
||||
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5_000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<AppErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</AppErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Test-Aufbau (v3-Umbau P1). Laeuft vor jeder Testdatei.
|
||||
import "@testing-library/jest-dom/vitest"
|
||||
import { cleanup } from "@testing-library/react"
|
||||
import { afterEach } from "vitest"
|
||||
|
||||
// Nach jedem Test das DOM leeren — sonst finden Abfragen Reste des Vorgaengers.
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
// jsdom rechnet kein Layout: JEDES Element ist 0x0, und es gibt keinen ResizeObserver.
|
||||
// Messende Komponenten (Recharts' ResponsiveContainer, der Kraftgraph) zeichnen daraufhin
|
||||
// gar nichts und warnen "width(0) and height(0)".
|
||||
//
|
||||
// Diese Attrappe meldet den Beobachtern EINMAL eine plausible Groesse, damit solche
|
||||
// Komponenten ueberhaupt rendern. Sie ist bewusst konstant: Tests hier pruefen Logik und
|
||||
// Struktur, nie gemessene Pixel — auf echte Layout-Werte darf sich kein Test stuetzen.
|
||||
const TEST_BREITE = 800
|
||||
const TEST_HOEHE = 400
|
||||
|
||||
class TestResizeObserver implements ResizeObserver {
|
||||
constructor(private rueckruf: ResizeObserverCallback) {}
|
||||
|
||||
observe(ziel: Element) {
|
||||
const rect = {
|
||||
width: TEST_BREITE, height: TEST_HOEHE, top: 0, left: 0,
|
||||
right: TEST_BREITE, bottom: TEST_HOEHE, x: 0, y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRectReadOnly
|
||||
const eintrag = {
|
||||
target: ziel,
|
||||
contentRect: rect,
|
||||
borderBoxSize: [{ inlineSize: TEST_BREITE, blockSize: TEST_HOEHE }],
|
||||
contentBoxSize: [{ inlineSize: TEST_BREITE, blockSize: TEST_HOEHE }],
|
||||
devicePixelContentBoxSize: [{ inlineSize: TEST_BREITE, blockSize: TEST_HOEHE }],
|
||||
} as unknown as ResizeObserverEntry
|
||||
// Asynchron wie das Original — ein synchroner Rueckruf im Konstruktor-Pfad wuerde
|
||||
// React mitten im Rendern aktualisieren.
|
||||
queueMicrotask(() => this.rueckruf([eintrag], this))
|
||||
}
|
||||
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver
|
||||
|
||||
// Denselben Dienst fuer Komponenten, die direkt messen statt zu beobachten.
|
||||
for (const [name, wert] of [["offsetWidth", TEST_BREITE], ["offsetHeight", TEST_HOEHE]] as const) {
|
||||
Object.defineProperty(HTMLElement.prototype, name, { configurable: true, value: wert })
|
||||
}
|
||||
@@ -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