Compare commits
3 Commits
a16287e19c
...
7704ffddab
| Author | SHA1 | Date | |
|---|---|---|---|
| 7704ffddab | |||
| 588d032335 | |||
| e84386b03e |
+1
-1
File diff suppressed because one or more lines are too long
+663
File diff suppressed because one or more lines are too long
-663
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -7,7 +7,7 @@
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Mission Control 2.0</title>
|
||||
<script type="module" crossorigin src="/assets/index-CrYzXlx6.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-B-6TT-6s.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-_Rap01H_.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useState, useRef } from "react"
|
||||
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, AlertTriangle, Camera, Bot, Box, FileText, Package, GitCommit, ExternalLink, ArrowRight, Shuffle } from "lucide-react"
|
||||
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, AlertTriangle, Camera, Bot, Box, Shuffle } from "lucide-react"
|
||||
import { api, type Job, type UpdatesResp, type ServicesResp, type UpdateDetails } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CustomDialog } from "./CustomDialog"
|
||||
import { SERVICES, ServiceRow, UpdateRow, formatBytes } from "./system/rows"
|
||||
import { SettingsTab } from "./system/SettingsTab"
|
||||
import { UpdateDetailModal, type UpdateDetailState } from "./system/UpdateDetailModal"
|
||||
|
||||
|
||||
interface SystemDrawerProps {
|
||||
@@ -11,58 +14,6 @@ interface SystemDrawerProps {
|
||||
defaultTab?: "maintenance" | "logs" | "settings"
|
||||
}
|
||||
|
||||
// systemd-Units (restart/logs) + reach = Stichwort zum Mappen auf /api/system/services.
|
||||
const SERVICES = [
|
||||
{ id: "mission-control-2", label: "Mission Control", type: "user", reach: "gateway (integr" },
|
||||
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user", reach: "hermes-gateway" },
|
||||
{ id: "mem0-service", label: "Mem0 (Gedächtnis)", type: "user", reach: "mem0" },
|
||||
{ id: "hermes-terminal", label: "Hermes Terminal", type: "user", reach: "hermes-terminal" },
|
||||
{ id: "llama-swap", label: "Llama Swap", type: "system", reach: "llama-swap" },
|
||||
]
|
||||
|
||||
function UpdateRow({ icon: Icon, iconClass, name, status, available, busy, actionLabel, onAction }: {
|
||||
icon: any; iconClass: string; name: string; status: string; available: boolean; busy?: boolean; actionLabel: string; onAction: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3 rounded-lg border px-3 py-2",
|
||||
available ? "border-amber-500/30 bg-amber-500/5" : "border-border/50 bg-background/20")}>
|
||||
<Icon className={cn("h-4 w-4 shrink-0", iconClass)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-foreground truncate">{name}</div>
|
||||
<div className={cn("text-[10px] truncate", available ? "text-amber-400/90" : "text-muted-foreground")}>{status}</div>
|
||||
</div>
|
||||
<button onClick={onAction} disabled={!available || busy}
|
||||
className={cn("text-[11px] font-semibold rounded-lg px-2.5 py-1 transition-colors shrink-0",
|
||||
available ? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer" : "border border-border/50 text-muted-foreground/40 cursor-default")}>
|
||||
{busy ? "…" : actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceRow({ label, ok, system, busy, onRestart, onLogs }: {
|
||||
label: string; ok?: boolean; system?: boolean; busy?: boolean; onRestart: () => void; onLogs: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-border/50 bg-background/20 px-3 py-2">
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0",
|
||||
ok === true ? "bg-emerald-500" : ok === false ? "bg-red-500" : "bg-muted-foreground/40")} />
|
||||
<span className="flex-1 text-xs text-foreground truncate">{label}{system && <span className="text-[9px] text-muted-foreground"> (root)</span>}</span>
|
||||
<button onClick={onRestart} disabled={busy} title="Neu starten" className="text-muted-foreground hover:text-primary transition-colors disabled:opacity-50">
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", busy && "animate-spin")} />
|
||||
</button>
|
||||
<button onClick={onLogs} title="Logs ansehen" className="text-muted-foreground hover:text-primary transition-colors">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatBytes(bytes?: number) {
|
||||
if (bytes == null) return ""
|
||||
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
|
||||
return `${(bytes / 1024 ** 2).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: SystemDrawerProps) {
|
||||
const [updates, setUpdates] = useState<UpdatesResp | null>(null)
|
||||
@@ -81,7 +32,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
||||
const [hermesUpdating, setHermesUpdating] = useState(false)
|
||||
|
||||
// Update-Detail-Fenster: zeigt VOR dem Anwenden, was genau aktualisiert wird.
|
||||
const [detail, setDetail] = useState<{ kind: "os" | "engine" | "swap" | "hermes"; loading: boolean; data: UpdateDetails | null } | null>(null)
|
||||
const [detail, setDetail] = useState<UpdateDetailState | null>(null)
|
||||
|
||||
// Custom Dialog State
|
||||
const [dialog, setDialog] = useState<{
|
||||
@@ -128,18 +79,6 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
||||
})
|
||||
}
|
||||
|
||||
// Credentials State
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && defaultTab) {
|
||||
@@ -702,223 +641,14 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "settings" && (
|
||||
<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>
|
||||
)}
|
||||
{activeTab === "settings" && <SettingsTab open={open} showAlert={showAlert} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detail && (() => {
|
||||
const d = detail.data
|
||||
const meta = {
|
||||
os: { icon: Shield, cls: "text-cyan-400", title: "OS-Pakete (apt)" },
|
||||
engine: { icon: Server, cls: "text-violet-400", title: "Inferenz-Engine (llama.cpp)" },
|
||||
swap: { icon: Shuffle, cls: "text-fuchsia-400", title: "Router (llama-swap)" },
|
||||
hermes: { icon: Bot, cls: "text-amber-400", title: "Hermes-Agent" },
|
||||
}[detail.kind]
|
||||
const Icon = meta.icon
|
||||
const nothing = !d ? true
|
||||
: detail.kind === "os" ? (d.count ?? 0) === 0
|
||||
: detail.kind === "hermes" ? (d.behind ?? 0) === 0
|
||||
: (d.installed_build != null && d.latest_build != null && d.latest_build <= d.installed_build)
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={() => setDetail(null)} />
|
||||
<div className="relative w-full max-w-lg max-h-[80vh] flex flex-col rounded-2xl border border-border/60 bg-card shadow-2xl font-sans text-foreground">
|
||||
{/* Header */}
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={cn("h-4.5 w-4.5", meta.cls)} />
|
||||
<h3 className="text-sm font-semibold">{meta.title}</h3>
|
||||
</div>
|
||||
<button onClick={() => setDetail(null)} className="flex h-7 w-7 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-5 text-xs space-y-3 scrollbar-thin">
|
||||
{detail.loading ? (
|
||||
<div className="flex h-24 items-center justify-center gap-2 text-muted-foreground">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" /> Details werden geladen…
|
||||
</div>
|
||||
) : d?.error ? (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-red-400">{d.error}</div>
|
||||
) : detail.kind === "os" ? (
|
||||
(d?.count ?? 0) === 0 ? (
|
||||
<div className="text-muted-foreground">Keine Pakete zu aktualisieren — System ist aktuell.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground">{d!.count} Paket(e) werden aktualisiert:</div>
|
||||
<div className="space-y-1">
|
||||
{d!.packages!.map((p) => (
|
||||
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
|
||||
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
|
||||
<Package className="h-3 w-3 text-cyan-400 shrink-0" />{p.name}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 font-mono text-[10px] text-muted-foreground shrink-0">
|
||||
<span>{p.current}</span><ArrowRight className="h-3 w-3" /><span className="text-emerald-400">{p.candidate}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : detail.kind === "engine" || detail.kind === "swap" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 font-mono text-[11px]">
|
||||
<span className="rounded-md border border-border/40 bg-background/30 px-2 py-1">Build {d?.installed_build ?? "?"}</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-2 py-1 text-emerald-400">Build {d?.latest_build ?? "?"}</span>
|
||||
</div>
|
||||
{(d?.name || d?.latest_tag) && (
|
||||
<div className="text-muted-foreground">Release: <span className="text-foreground">{d?.name}</span>{d?.latest_tag ? ` (${d.latest_tag})` : ""}</div>
|
||||
)}
|
||||
{d?.url && (
|
||||
<a href={d.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-primary hover:underline">
|
||||
Release-Notes auf GitHub <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
{d?.body && (
|
||||
<pre className="whitespace-pre-wrap rounded-lg border border-border/40 bg-background/30 p-3 text-[10px] leading-relaxed text-muted-foreground max-h-60 overflow-y-auto scrollbar-thin">{d.body}</pre>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// hermes
|
||||
(d?.commits?.length ?? 0) === 0 ? (
|
||||
<div className="text-muted-foreground">Keine neuen Commits — Hermes-Agent ist bereits aktuell.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground">{d!.behind} neue Commit(s) auf <span className="font-mono text-foreground">origin/{d!.branch}</span>:</div>
|
||||
<div className="space-y-1">
|
||||
{d!.commits!.map((c) => (
|
||||
<div key={c.hash} className="flex items-start gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
|
||||
<GitCommit className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] truncate">{c.subject}</div>
|
||||
<div className="font-mono text-[9px] text-muted-foreground">{c.hash} · {c.when}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">Vor dem Update wird automatisch ein Backup erstellt; danach startet der Hermes-Gateway neu.</p>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-3 border-t border-border/40 p-4 shrink-0">
|
||||
<button onClick={() => setDetail(null)} className="h-9 flex-1 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold text-muted-foreground hover:bg-accent transition-colors cursor-pointer">
|
||||
Schließen
|
||||
</button>
|
||||
<button onClick={applyFromDetail} disabled={detail.loading || nothing || !!maintenanceJob}
|
||||
title={maintenanceJob ? `Update läuft bereits: ${maintenanceJob.label}` : undefined}
|
||||
className="h-9 flex-1 rounded-lg bg-primary text-xs font-semibold text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-default">
|
||||
{maintenanceJob ? "Update läuft…" : "Jetzt aktualisieren"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{detail && (
|
||||
<UpdateDetailModal detail={detail} maintenanceJob={maintenanceJob}
|
||||
onClose={() => setDetail(null)} onApply={applyFromDetail} />
|
||||
)}
|
||||
|
||||
{dialog && (
|
||||
<CustomDialog
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { ArrowRight, Bot, ExternalLink, GitCommit, Package, RefreshCw, Server, Shield, Shuffle, X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Job, UpdateDetails } from "@/lib/api"
|
||||
|
||||
export type UpdateDetailState = { kind: "os" | "engine" | "swap" | "hermes"; loading: boolean; data: UpdateDetails | null }
|
||||
|
||||
// Update-Detail-Fenster des SystemDrawers (Review P2-14: extrahiert): zeigt VOR dem
|
||||
// Anwenden, was genau aktualisiert wird (apt-Pakete, Engine-Builds, Hermes-Commits).
|
||||
export function UpdateDetailModal({ detail, maintenanceJob, onClose, onApply }: {
|
||||
detail: UpdateDetailState
|
||||
maintenanceJob?: Job
|
||||
onClose: () => void
|
||||
onApply: () => void
|
||||
}) {
|
||||
const d = detail.data
|
||||
const meta = {
|
||||
os: { icon: Shield, cls: "text-cyan-400", title: "OS-Pakete (apt)" },
|
||||
engine: { icon: Server, cls: "text-violet-400", title: "Inferenz-Engine (llama.cpp)" },
|
||||
swap: { icon: Shuffle, cls: "text-fuchsia-400", title: "Router (llama-swap)" },
|
||||
hermes: { icon: Bot, cls: "text-amber-400", title: "Hermes-Agent" },
|
||||
}[detail.kind]
|
||||
const Icon = meta.icon
|
||||
const nothing = !d ? true
|
||||
: detail.kind === "os" ? (d.count ?? 0) === 0
|
||||
: detail.kind === "hermes" ? (d.behind ?? 0) === 0
|
||||
: (d.installed_build != null && d.latest_build != null && d.latest_build <= d.installed_build)
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative w-full max-w-lg max-h-[80vh] flex flex-col rounded-2xl border border-border/60 bg-card shadow-2xl font-sans text-foreground">
|
||||
{/* Header */}
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={cn("h-4.5 w-4.5", meta.cls)} />
|
||||
<h3 className="text-sm font-semibold">{meta.title}</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="flex h-7 w-7 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-5 text-xs space-y-3 scrollbar-thin">
|
||||
{detail.loading ? (
|
||||
<div className="flex h-24 items-center justify-center gap-2 text-muted-foreground">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" /> Details werden geladen…
|
||||
</div>
|
||||
) : d?.error ? (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-red-400">{d.error}</div>
|
||||
) : detail.kind === "os" ? (
|
||||
(d?.count ?? 0) === 0 ? (
|
||||
<div className="text-muted-foreground">Keine Pakete zu aktualisieren — System ist aktuell.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground">{d!.count} Paket(e) werden aktualisiert:</div>
|
||||
<div className="space-y-1">
|
||||
{d!.packages!.map((p) => (
|
||||
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
|
||||
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
|
||||
<Package className="h-3 w-3 text-cyan-400 shrink-0" />{p.name}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 font-mono text-[10px] text-muted-foreground shrink-0">
|
||||
<span>{p.current}</span><ArrowRight className="h-3 w-3" /><span className="text-emerald-400">{p.candidate}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : detail.kind === "engine" || detail.kind === "swap" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 font-mono text-[11px]">
|
||||
<span className="rounded-md border border-border/40 bg-background/30 px-2 py-1">Build {d?.installed_build ?? "?"}</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-2 py-1 text-emerald-400">Build {d?.latest_build ?? "?"}</span>
|
||||
</div>
|
||||
{(d?.name || d?.latest_tag) && (
|
||||
<div className="text-muted-foreground">Release: <span className="text-foreground">{d?.name}</span>{d?.latest_tag ? ` (${d.latest_tag})` : ""}</div>
|
||||
)}
|
||||
{d?.url && (
|
||||
<a href={d.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-primary hover:underline">
|
||||
Release-Notes auf GitHub <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
{d?.body && (
|
||||
<pre className="whitespace-pre-wrap rounded-lg border border-border/40 bg-background/30 p-3 text-[10px] leading-relaxed text-muted-foreground max-h-60 overflow-y-auto scrollbar-thin">{d.body}</pre>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// hermes
|
||||
(d?.commits?.length ?? 0) === 0 ? (
|
||||
<div className="text-muted-foreground">Keine neuen Commits — Hermes-Agent ist bereits aktuell.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground">{d!.behind} neue Commit(s) auf <span className="font-mono text-foreground">origin/{d!.branch}</span>:</div>
|
||||
<div className="space-y-1">
|
||||
{d!.commits!.map((c) => (
|
||||
<div key={c.hash} className="flex items-start gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
|
||||
<GitCommit className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] truncate">{c.subject}</div>
|
||||
<div className="font-mono text-[9px] text-muted-foreground">{c.hash} · {c.when}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">Vor dem Update wird automatisch ein Backup erstellt; danach startet der Hermes-Gateway neu.</p>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-3 border-t border-border/40 p-4 shrink-0">
|
||||
<button onClick={onClose} className="h-9 flex-1 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold text-muted-foreground hover:bg-accent transition-colors cursor-pointer">
|
||||
Schließen
|
||||
</button>
|
||||
<button onClick={onApply} disabled={detail.loading || nothing || !!maintenanceJob}
|
||||
title={maintenanceJob ? `Update läuft bereits: ${maintenanceJob.label}` : undefined}
|
||||
className="h-9 flex-1 rounded-lg bg-primary text-xs font-semibold text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-default">
|
||||
{maintenanceJob ? "Update läuft…" : "Jetzt aktualisieren"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { RefreshCw, FileText } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Präsentations-Atome des SystemDrawers (Review P2-14: aus dem 934-Z-Monolithen extrahiert).
|
||||
|
||||
// systemd-Units (restart/logs) + reach = Stichwort zum Mappen auf /api/system/services.
|
||||
export const SERVICES = [
|
||||
{ id: "mission-control-2", label: "Mission Control", type: "user", reach: "gateway (integr" },
|
||||
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user", reach: "hermes-gateway" },
|
||||
{ id: "mem0-service", label: "Mem0 (Gedächtnis)", type: "user", reach: "mem0" },
|
||||
{ id: "hermes-terminal", label: "Hermes Terminal", type: "user", reach: "hermes-terminal" },
|
||||
{ id: "llama-swap", label: "Llama Swap", type: "system", reach: "llama-swap" },
|
||||
]
|
||||
|
||||
export function UpdateRow({ icon: Icon, iconClass, name, status, available, busy, actionLabel, onAction }: {
|
||||
icon: any; iconClass: string; name: string; status: string; available: boolean; busy?: boolean; actionLabel: string; onAction: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3 rounded-lg border px-3 py-2",
|
||||
available ? "border-amber-500/30 bg-amber-500/5" : "border-border/50 bg-background/20")}>
|
||||
<Icon className={cn("h-4 w-4 shrink-0", iconClass)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-foreground truncate">{name}</div>
|
||||
<div className={cn("text-[10px] truncate", available ? "text-amber-400/90" : "text-muted-foreground")}>{status}</div>
|
||||
</div>
|
||||
<button onClick={onAction} disabled={!available || busy}
|
||||
className={cn("text-[11px] font-semibold rounded-lg px-2.5 py-1 transition-colors shrink-0",
|
||||
available ? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer" : "border border-border/50 text-muted-foreground/40 cursor-default")}>
|
||||
{busy ? "…" : actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ServiceRow({ label, ok, system, busy, onRestart, onLogs }: {
|
||||
label: string; ok?: boolean; system?: boolean; busy?: boolean; onRestart: () => void; onLogs: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-border/50 bg-background/20 px-3 py-2">
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0",
|
||||
ok === true ? "bg-emerald-500" : ok === false ? "bg-red-500" : "bg-muted-foreground/40")} />
|
||||
<span className="flex-1 text-xs text-foreground truncate">{label}{system && <span className="text-[9px] text-muted-foreground"> (root)</span>}</span>
|
||||
<button onClick={onRestart} disabled={busy} title="Neu starten" className="text-muted-foreground hover:text-primary transition-colors disabled:opacity-50">
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", busy && "animate-spin")} />
|
||||
</button>
|
||||
<button onClick={onLogs} title="Logs ansehen" className="text-muted-foreground hover:text-primary transition-colors">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function formatBytes(bytes?: number) {
|
||||
if (bytes == null) return ""
|
||||
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
|
||||
return `${(bytes / 1024 ** 2).toFixed(1)} MB`
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react"
|
||||
import { Download, Trash2, Edit3, Activity, HardDrive, X, Check, Zap, Bot } from "lucide-react"
|
||||
import { Download, Trash2, Edit3, Activity, HardDrive, Check, Zap, Bot } from "lucide-react"
|
||||
import { api, setGroup, type ModelInfo, type RoleRecResp } from "@/lib/api"
|
||||
import { useModels, useGroups, useUpdates, useHermesBrain, useSystemStatus, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils"
|
||||
import { fmtSize, fmtCtx } from "@/lib/format"
|
||||
import { getBrandInfo, ROLES, RoleLabel, roleMeta } from "@/components/models/ModelBadges"
|
||||
import { SpecDraftModal } from "@/components/models/SpecDraftModal"
|
||||
import { RoleAssignModal } from "./cockpit/RoleAssignModal"
|
||||
import { LaneEditor } from "@/components/models/LaneEditor"
|
||||
import { WarmSetManager } from "@/components/models/WarmSetManager"
|
||||
import { useExpertMode } from "@/lib/useExpertMode"
|
||||
@@ -887,94 +888,11 @@ export function Cockpit() {
|
||||
</div>
|
||||
|
||||
{/* Role Assignment Modal */}
|
||||
{activeRoleForAssign && (() => {
|
||||
const rec = roleRec && roleRec.role === activeRoleForAssign ? roleRec : null
|
||||
const recByName: Record<string, RoleRecResp["models"][number]> = {}
|
||||
rec?.models.forEach((r) => { recByName[r.name] = r })
|
||||
// Empfohlene Reihenfolge (nach Score) wenn vorhanden, sonst Bibliotheks-Reihenfolge.
|
||||
const ordered = rec
|
||||
? rec.models.map((r) => models.find((m) => m.name === r.name)).filter(Boolean) as ModelInfo[]
|
||||
: models
|
||||
const assign = (name: string) => { handleRoleChange(activeRoleForAssign, name); setActiveRoleForAssign(null) }
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label={`${roleMeta(activeRoleForAssign).label} konfigurieren`}>
|
||||
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between border-b border-border/20 pb-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
|
||||
<RoleLabel role={activeRoleForAssign} /> festlegen
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setActiveRoleForAssign(null)}
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Wähle ein Modell für <strong className="text-foreground">{roleMeta(activeRoleForAssign).label}</strong>
|
||||
<span className="block text-[10px] text-muted-foreground/70 mt-0.5">{roleMeta(activeRoleForAssign).desc}</span>
|
||||
</p>
|
||||
{rec?.recommended && (
|
||||
<button
|
||||
onClick={() => assign(rec.recommended!)}
|
||||
title={recByName[rec.recommended]?.reason}
|
||||
className="shrink-0 h-7 px-3 text-[11px] font-semibold text-primary border border-primary/50 bg-primary/10 hover:bg-primary/20 rounded-lg cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<Zap className="h-3 w-3" /> Auto: {rec.recommended.split("/").pop()?.replace(/\.gguf$/i, "")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
|
||||
{/* Schutzgeländer: eine lebenswichtige Rolle (Hirn/Gedächtnis) lässt sich nicht leeren. */}
|
||||
{isProtected(activeRoleForAssign) ? (
|
||||
<div className="w-full px-3 py-2 rounded-lg text-[11px] text-muted-foreground border border-border/30 bg-background/20 flex items-center gap-1.5">
|
||||
🔒 Diese Rolle ist lebenswichtig und kann nicht leer bleiben — wähle stattdessen ein anderes Modell.
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => assign("")}
|
||||
className="w-full text-left px-3 py-2 rounded-lg text-xs hover:bg-accent text-red-400 font-semibold cursor-pointer border border-red-500/20 bg-red-500/5 flex items-center justify-between"
|
||||
>
|
||||
<span>Zuweisung entfernen</span>
|
||||
</button>
|
||||
)}
|
||||
{ordered.map((m) => {
|
||||
const r = recByName[m.name]
|
||||
const isCur = m.role === activeRoleForAssign
|
||||
const isRec = !!r?.recommended
|
||||
const unfit = !!r && !r.suitable
|
||||
return (
|
||||
<button
|
||||
key={m.name}
|
||||
onClick={() => assign(m.name)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border",
|
||||
isRec ? "border-primary/50 bg-primary/10"
|
||||
: isCur ? "text-primary font-bold bg-primary/5 border-primary/30"
|
||||
: unfit ? "border-border/20 bg-background/10 opacity-60"
|
||||
: "text-foreground bg-background/20 border-border/30"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col text-left min-w-0">
|
||||
<span className="truncate max-w-[260px] font-semibold flex items-center gap-1.5">
|
||||
{m.name.split("/").pop()?.replace(/\.gguf$/i, "")}
|
||||
{isRec && <span className="text-[8px] font-bold uppercase tracking-wide text-primary bg-primary/15 px-1.5 py-0.5 rounded">Empfohlen</span>}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground mt-0.5">
|
||||
{r ? `${r.params_b}B · ${m.quant} · ${r.reason}` : `${fmtSize(m.size_bytes)} · ${m.quant}`}
|
||||
</span>
|
||||
</div>
|
||||
{isCur && <Check className="h-4 w-4 shrink-0 text-primary" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{activeRoleForAssign && (
|
||||
<RoleAssignModal role={activeRoleForAssign} roleRec={roleRec} models={models}
|
||||
onAssign={(name) => { handleRoleChange(activeRoleForAssign, name); setActiveRoleForAssign(null) }}
|
||||
onClose={() => setActiveRoleForAssign(null)} />
|
||||
)}
|
||||
|
||||
{specModel && (
|
||||
<SpecDraftModal
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Check, X, Zap } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { fmtSize } from "@/lib/format"
|
||||
import { roleMeta } from "@/lib/roleMeta"
|
||||
import { RoleLabel } from "@/components/models/ModelBadges"
|
||||
import type { ModelInfo, RoleRecResp } from "@/lib/api"
|
||||
|
||||
// Rollen-Zuweisungs-Modal des Cockpits (Review P2-14, Teil 2: aus dem 990-Z-Monolithen
|
||||
// extrahiert). Zeigt die Modell-Bibliothek sortiert nach Empfehlung (RoleRec-Score) und
|
||||
// hält das Schutzgeländer: lebenswichtige Rollen (Hirn/Gedächtnis) können nicht leer bleiben.
|
||||
const isProtected = (role?: string | null) => !!roleMeta(role).protected
|
||||
export function RoleAssignModal({ role, roleRec, models, onAssign, onClose }: {
|
||||
role: string
|
||||
roleRec: RoleRecResp | null
|
||||
models: ModelInfo[]
|
||||
onAssign: (modelName: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const rec = roleRec && roleRec.role === role ? roleRec : null
|
||||
const recByName: Record<string, RoleRecResp["models"][number]> = {}
|
||||
rec?.models.forEach((r) => { recByName[r.name] = r })
|
||||
// Empfohlene Reihenfolge (nach Score) wenn vorhanden, sonst Bibliotheks-Reihenfolge.
|
||||
const ordered = rec
|
||||
? rec.models.map((r) => models.find((m) => m.name === r.name)).filter(Boolean) as ModelInfo[]
|
||||
: models
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label={`${roleMeta(role).label} konfigurieren`}>
|
||||
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between border-b border-border/20 pb-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
|
||||
<RoleLabel role={role} /> festlegen
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Wähle ein Modell für <strong className="text-foreground">{roleMeta(role).label}</strong>
|
||||
<span className="block text-[10px] text-muted-foreground/70 mt-0.5">{roleMeta(role).desc}</span>
|
||||
</p>
|
||||
{rec?.recommended && (
|
||||
<button
|
||||
onClick={() => onAssign(rec.recommended!)}
|
||||
title={recByName[rec.recommended]?.reason}
|
||||
className="shrink-0 h-7 px-3 text-[11px] font-semibold text-primary border border-primary/50 bg-primary/10 hover:bg-primary/20 rounded-lg cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<Zap className="h-3 w-3" /> Auto: {rec.recommended.split("/").pop()?.replace(/\.gguf$/i, "")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
|
||||
{/* Schutzgeländer: eine lebenswichtige Rolle (Hirn/Gedächtnis) lässt sich nicht leeren. */}
|
||||
{isProtected(role) ? (
|
||||
<div className="w-full px-3 py-2 rounded-lg text-[11px] text-muted-foreground border border-border/30 bg-background/20 flex items-center gap-1.5">
|
||||
🔒 Diese Rolle ist lebenswichtig und kann nicht leer bleiben — wähle stattdessen ein anderes Modell.
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onAssign("")}
|
||||
className="w-full text-left px-3 py-2 rounded-lg text-xs hover:bg-accent text-red-400 font-semibold cursor-pointer border border-red-500/20 bg-red-500/5 flex items-center justify-between"
|
||||
>
|
||||
<span>Zuweisung entfernen</span>
|
||||
</button>
|
||||
)}
|
||||
{ordered.map((m) => {
|
||||
const r = recByName[m.name]
|
||||
const isCur = m.role === role
|
||||
const isRec = !!r?.recommended
|
||||
const unfit = !!r && !r.suitable
|
||||
return (
|
||||
<button
|
||||
key={m.name}
|
||||
onClick={() => onAssign(m.name)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border",
|
||||
isRec ? "border-primary/50 bg-primary/10"
|
||||
: isCur ? "text-primary font-bold bg-primary/5 border-primary/30"
|
||||
: unfit ? "border-border/20 bg-background/10 opacity-60"
|
||||
: "text-foreground bg-background/20 border-border/30"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col text-left min-w-0">
|
||||
<span className="truncate max-w-[260px] font-semibold flex items-center gap-1.5">
|
||||
{m.name.split("/").pop()?.replace(/\.gguf$/i, "")}
|
||||
{isRec && <span className="text-[8px] font-bold uppercase tracking-wide text-primary bg-primary/15 px-1.5 py-0.5 rounded">Empfohlen</span>}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground mt-0.5">
|
||||
{r ? `${r.params_b}B · ${m.quant} · ${r.reason}` : `${fmtSize(m.size_bytes)} · ${m.quant}`}
|
||||
</span>
|
||||
</div>
|
||||
{isCur && <Check className="h-4 w-4 shrink-0 text-primary" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user