1682 lines
80 KiB
TypeScript
1682 lines
80 KiB
TypeScript
import { useEffect, useState, useRef, useCallback } from "react"
|
|
import { Download, Search, Trash2, Edit3, Star, Layers, Activity, HardDrive, X, Check, Copy, Bot, Eye, Code, Brain, Compass, ChevronDown, ChevronUp } from "lucide-react"
|
|
import { api, type DiscoverResp, type Fit, type Job, type ModelInfo, type RoutingResp, type UpdatesResp, type ConnectResp } from "@/lib/api"
|
|
import { CapsChips } from "@/components/CapsChips"
|
|
import { cn } from "@/lib/utils"
|
|
import { CustomDialog } from "@/components/CustomDialog"
|
|
|
|
|
|
function fmtBytes(b?: number) {
|
|
if (!b) return ""
|
|
if (b > 1024 ** 3) return `${(b / 1024 ** 3).toFixed(1)} GB`
|
|
return `${(b / 1024 ** 2).toFixed(0)} MB`
|
|
}
|
|
|
|
function fmtEta(s?: number) {
|
|
if (!s) return ""
|
|
const m = Math.floor(s / 60)
|
|
return m > 0 ? `${m} min` : `${s} s`
|
|
}
|
|
|
|
function JobsBar({ onError }: { onError?: (msg: string) => void }) {
|
|
const [jobs, setJobs] = useState<Job[]>([])
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
|
|
|
function load() {
|
|
api<{ jobs: Job[] }>("/api/jobs")
|
|
.then((d) => setJobs(d.jobs || []))
|
|
.catch(() => {})
|
|
}
|
|
|
|
useEffect(() => {
|
|
load()
|
|
const t = setInterval(load, 2000)
|
|
return () => clearInterval(t)
|
|
}, [])
|
|
|
|
async function cancelJob(jobId: string) {
|
|
try {
|
|
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
|
|
load()
|
|
} catch (e: any) {
|
|
if (onError) onError(e.message)
|
|
else setErrorMsg(e.message)
|
|
}
|
|
}
|
|
|
|
const active = jobs.filter((j) => j.state === "running" || j.state === "queued")
|
|
const recent = jobs.filter((j) => j.state !== "running" && j.state !== "queued").slice(-3)
|
|
|
|
if (active.length === 0 && recent.length === 0) return null
|
|
|
|
return (
|
|
<div className="space-y-3 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 shadow-lg shadow-black/10">
|
|
<div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">Aktive Downloads</div>
|
|
|
|
{active.map((j) => (
|
|
<div key={j.id} className="space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40">
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="font-semibold truncate max-w-[250px]">{j.label}</span>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-muted-foreground font-mono">
|
|
{j.progress ?? 0}% • {fmtBytes(j.done_bytes)}/{fmtBytes(j.total_bytes)}
|
|
{j.eta_s ? ` • ETA ${fmtEta(j.eta_s)}` : ""}
|
|
</span>
|
|
<button
|
|
onClick={() => cancelJob(j.id)}
|
|
className="text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
|
<div className="h-full rounded-full bg-primary transition-all duration-500" style={{ width: `${j.progress ?? 0}%` }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{recent.map((j) => (
|
|
<div key={j.id} className="flex justify-between items-center text-xs text-muted-foreground px-1">
|
|
<span className="truncate">{j.label}</span>
|
|
<span className={cn(
|
|
"font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",
|
|
j.state === "done" ? "bg-emerald-500/10 text-emerald-400" : "bg-amber-500/10 text-amber-400"
|
|
)}>
|
|
{j.state}
|
|
</span>
|
|
</div>
|
|
))}
|
|
|
|
{errorMsg && (
|
|
<CustomDialog
|
|
type="alert"
|
|
title="Fehler"
|
|
message={errorMsg}
|
|
onConfirm={() => setErrorMsg(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function fmtSize(b?: number | null) {
|
|
if (!b) return "—"
|
|
const gb = b / 1024 ** 3
|
|
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB`
|
|
}
|
|
|
|
function fmtCtx(c: number | null) {
|
|
return c ? `${Math.round(c / 1024)}k` : "—"
|
|
}
|
|
|
|
function FitBadge({ fit }: { fit: Fit }) {
|
|
const tone = {
|
|
perfect: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/20",
|
|
marginal: "bg-amber-500/15 text-amber-400 border border-amber-500/20",
|
|
too_tight: "bg-red-500/15 text-red-400 border border-red-500/20",
|
|
}[fit.level]
|
|
return (
|
|
<span className={cn("rounded px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider font-mono", tone)}>
|
|
{fit.text} • {fit.req_gb} GB RAM
|
|
</span>
|
|
)
|
|
}
|
|
|
|
const ROLES = ["fast", "heavy", "coder", "reasoning", "agent", "vision", "scout"]
|
|
|
|
function getBrandInfo(name: string) {
|
|
const low = name.toLowerCase()
|
|
if (low.includes("qwen")) return { name: "Qwen", color: "bg-purple-500/20 text-purple-300 border-purple-500/30", initial: "Q" }
|
|
if (low.includes("gemma")) return { name: "Gemma", color: "bg-blue-500/20 text-blue-300 border-blue-500/30", initial: "G" }
|
|
if (low.includes("llama")) return { name: "Llama", color: "bg-red-500/20 text-red-300 border-red-500/30", initial: "🦙" }
|
|
if (low.includes("mistral") || low.includes("mixtral")) return { name: "Mistral", color: "bg-orange-500/20 text-orange-300 border-orange-500/30", initial: "M" }
|
|
if (low.includes("deepseek")) return { name: "DeepSeek", color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30", initial: "D" }
|
|
if (low.includes("hermes") || low.includes("nous")) return { name: "Hermes", color: "bg-amber-500/20 text-amber-300 border-amber-500/30", initial: "H" }
|
|
if (low.includes("phi")) return { name: "Phi", color: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30", initial: "Φ" }
|
|
return { name: "Other", color: "bg-slate-500/20 text-slate-300 border-slate-500/30", initial: "AI" }
|
|
}
|
|
|
|
function Cockpit() {
|
|
const [models, setModels] = useState<ModelInfo[]>([])
|
|
const [running, setRunning] = useState<string[]>([])
|
|
const [routing, setRouting] = useState<RoutingResp | null>(null)
|
|
const [connectData, setConnectData] = useState<ConnectResp | null>(null)
|
|
const [updates, setUpdates] = useState<UpdatesResp | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState("")
|
|
|
|
// UI state
|
|
const [activeClient, setActiveClient] = useState<"roocode" | "cursor" | "opencode" | "zed" | "continue" | null>(null)
|
|
const [activeRoleForAssign, setActiveRoleForAssign] = useState<string | null>(null)
|
|
const [copied, setCopied] = useState(false)
|
|
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
|
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid")
|
|
const [filterMode, setFilterMode] = useState<"all" | "in_use">("all")
|
|
|
|
// Custom Dialog State
|
|
const [dialog, setDialog] = useState<{
|
|
type: "alert" | "confirm" | "prompt"
|
|
title: string
|
|
message: string
|
|
defaultValue?: string
|
|
onConfirm: (val?: string) => void
|
|
onCancel?: () => void
|
|
} | null>(null)
|
|
|
|
function showAlert(title: string, message: string, onConfirm?: () => void) {
|
|
setDialog({
|
|
type: "alert",
|
|
title,
|
|
message,
|
|
onConfirm: () => {
|
|
setDialog(null)
|
|
if (onConfirm) onConfirm()
|
|
}
|
|
})
|
|
}
|
|
|
|
function showConfirm(title: string, message: string, onConfirm: () => void, onCancel?: () => void) {
|
|
setDialog({
|
|
type: "confirm",
|
|
title,
|
|
message,
|
|
onConfirm: () => {
|
|
setDialog(null)
|
|
onConfirm()
|
|
},
|
|
onCancel: () => {
|
|
setDialog(null)
|
|
if (onCancel) onCancel()
|
|
}
|
|
})
|
|
}
|
|
|
|
function showPrompt(title: string, message: string, defaultValue: string, onConfirm: (val?: string) => void, onCancel?: () => void) {
|
|
setDialog({
|
|
type: "prompt",
|
|
title,
|
|
message,
|
|
defaultValue,
|
|
onConfirm: (val) => {
|
|
setDialog(null)
|
|
onConfirm(val)
|
|
},
|
|
onCancel: () => {
|
|
setDialog(null)
|
|
if (onCancel) onCancel()
|
|
}
|
|
})
|
|
}
|
|
|
|
|
|
const filteredModels = models.filter((m) => {
|
|
if (filterMode === "in_use") {
|
|
return !!m.role || running.includes(m.name)
|
|
}
|
|
return true
|
|
})
|
|
|
|
// Canvas pixel tracking for pixel-perfect connection graph without non-uniform scaling
|
|
const [dimensions, setDimensions] = useState({ width: 800, height: 360 })
|
|
const observerRef = useRef<ResizeObserver | null>(null)
|
|
|
|
const containerRef = useCallback((node: HTMLDivElement | null) => {
|
|
if (observerRef.current) {
|
|
observerRef.current.disconnect()
|
|
observerRef.current = null
|
|
}
|
|
if (node) {
|
|
const observer = new ResizeObserver((entries) => {
|
|
if (!entries || entries.length === 0) return
|
|
const rect = entries[0].contentRect
|
|
setDimensions({ width: rect.width, height: rect.height })
|
|
})
|
|
observer.observe(node)
|
|
observerRef.current = observer
|
|
}
|
|
}, [])
|
|
|
|
const w = dimensions.width
|
|
const h = dimensions.height
|
|
|
|
// Helper to generate curve from client to gateway
|
|
const getClientPath = (yPercent: number) => {
|
|
const startX = w * 0.1
|
|
const startY = h * yPercent
|
|
const endX = w * 0.5
|
|
const endY = h * 0.5
|
|
const cp1X = w * 0.3
|
|
const cp1Y = startY
|
|
const cp2X = w * 0.3
|
|
const cp2Y = endY
|
|
return `M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${endX} ${endY}`
|
|
}
|
|
|
|
// Helper to generate curve from gateway to role
|
|
const getRolePath = (yPercent: number) => {
|
|
const startX = w * 0.5
|
|
const startY = h * 0.5
|
|
const endX = w * 0.9
|
|
const endY = h * yPercent
|
|
const cp1X = w * 0.7
|
|
const cp1Y = startY
|
|
const cp2X = w * 0.7
|
|
const cp2Y = endY
|
|
return `M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${endX} ${endY}`
|
|
}
|
|
|
|
function load() {
|
|
Promise.all([
|
|
api<{ models: ModelInfo[]; running?: string[] }>("/api/models"),
|
|
api<RoutingResp>("/api/routing"),
|
|
api<ConnectResp>("/api/connect"),
|
|
api<UpdatesResp>("/api/maintenance/updates")
|
|
])
|
|
.then(([mResp, rResp, cResp, uResp]) => {
|
|
setModels(mResp.models || [])
|
|
setRunning(mResp.running || [])
|
|
setRouting(rResp)
|
|
setConnectData(cResp)
|
|
setUpdates(uResp)
|
|
})
|
|
.catch((e) => setError(String(e)))
|
|
.finally(() => setLoading(false))
|
|
}
|
|
|
|
useEffect(() => {
|
|
load()
|
|
const t = setInterval(load, 4000)
|
|
return () => clearInterval(t)
|
|
}, [])
|
|
|
|
async function handleLoadModel(name: string) {
|
|
try {
|
|
await api(`/api/models/${encodeURIComponent(name)}/load`, { method: "POST" })
|
|
load()
|
|
} catch (e: any) {
|
|
showAlert("Fehler", `Fehler beim Laden des Modells: ${e.message}`)
|
|
}
|
|
}
|
|
|
|
async function handleUnloadModel(name: string) {
|
|
try {
|
|
await api(`/api/models/${encodeURIComponent(name)}/unload`, { method: "POST" })
|
|
load()
|
|
} catch (e: any) {
|
|
showAlert("Fehler", `Fehler beim Entladen des Modells: ${e.message}`)
|
|
}
|
|
}
|
|
|
|
async function handleUnloadAll() {
|
|
try {
|
|
await api("/api/models/unload", { method: "POST" })
|
|
load()
|
|
} catch (e: any) {
|
|
showAlert("Fehler", `Fehler beim Entladen aller Modelle: ${e.message}`)
|
|
}
|
|
}
|
|
|
|
async function handleRoleChange(role: string, modelName: string) {
|
|
try {
|
|
await api(`/api/models/${encodeURIComponent(modelName)}/role`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ role: role || null }),
|
|
})
|
|
load()
|
|
} catch (e: any) {
|
|
showAlert("Fehler", `Fehler beim Zuweisen der Rolle: ${e.message || e}`)
|
|
}
|
|
}
|
|
|
|
async function handleSetCtx(name: string, cur: number | null) {
|
|
showPrompt(
|
|
"Kontextlänge anpassen",
|
|
"Gib die gewünschte Kontextlänge in Tokens an:",
|
|
String(cur || 32768),
|
|
async (v) => {
|
|
if (!v) return
|
|
try {
|
|
await api(`/api/models/${encodeURIComponent(name)}/ctx`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ ctx: parseInt(v, 10) }),
|
|
})
|
|
load()
|
|
} catch (e: any) {
|
|
showAlert("Fehler", `Fehler beim Setzen des Kontexts: ${e.message || e}`)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
async function handleDelete(name: string) {
|
|
showConfirm(
|
|
"Modell löschen?",
|
|
`Modell '${name}' und alle zugehörigen GGUF-Dateien unwiderruflich von der Box löschen?`,
|
|
async () => {
|
|
try {
|
|
await api(`/api/models/${encodeURIComponent(name)}`, { method: "DELETE" })
|
|
load()
|
|
} catch (e: any) {
|
|
showAlert("Fehler", `Fehler beim Löschen: ${e.message || e}`)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
async function handleSmartUpgrade(repo: string, role: string, quant: string, toolCapable: boolean) {
|
|
try {
|
|
await api("/api/models/install", {
|
|
method: "POST",
|
|
body: JSON.stringify({ repo, role, quant, jinja: toolCapable }),
|
|
})
|
|
showAlert("Herunterladen gestartet", `Download für '${repo}' gestartet! Der Fortschritt wird oben angezeigt.`)
|
|
} catch (e: any) {
|
|
showAlert("Fehler", `Fehler beim Starten des Upgrades: ${e.message || e}`)
|
|
}
|
|
}
|
|
|
|
async function copySnippet(snippet?: string) {
|
|
if (!snippet) return
|
|
await navigator.clipboard.writeText(snippet)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 1500)
|
|
}
|
|
|
|
if (loading) return <div className="text-xs text-muted-foreground py-12 text-center">Initialisiere HUD Cockpit…</div>
|
|
if (error) {
|
|
return (
|
|
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
|
|
Gateway oder Engine nicht erreichbar ({error}).
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// VRAM calculation: APU fallback 16GB if sysfs returns 0
|
|
const runningWithInfo = models.filter((m) => running.includes(m.name))
|
|
const totalRunningSize = runningWithInfo.reduce((acc, m) => acc + (m.size_bytes || 0), 0)
|
|
const virtualMax = 16 * 1024 ** 3 // 16 GB Default
|
|
const capacity = totalRunningSize > virtualMax ? totalRunningSize * 1.2 : virtualMax
|
|
|
|
// Find model by role
|
|
const getModelForRole = (role: string) => models.find((m) => m.role === role)
|
|
const isRoleRunning = (role: string) => {
|
|
const m = getModelForRole(role)
|
|
return m ? running.includes(m.name) : false
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Styles inside Cockpit for dash flow animations */}
|
|
<style>{`
|
|
@keyframes flow-dash {
|
|
to {
|
|
stroke-dashoffset: -20;
|
|
}
|
|
}
|
|
.svg-flow-path {
|
|
stroke-dasharray: 4 6;
|
|
animation: flow-dash 1s linear infinite;
|
|
}
|
|
`}</style>
|
|
|
|
{/* ZONE A: Widescreen VRAM HUD */}
|
|
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-3.5">
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex items-center gap-2">
|
|
<HardDrive className="h-4.5 w-4.5 text-primary" />
|
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground">VRAM / Memory Belegung</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-[10px] font-mono text-muted-foreground">
|
|
Llama Swap VRAM-Pool: {fmtSize(totalRunningSize)} / {fmtSize(capacity)} geladen
|
|
</span>
|
|
{running.length > 0 && (
|
|
<button
|
|
onClick={handleUnloadAll}
|
|
className="h-6 px-2.5 rounded border border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10 text-amber-400 text-[9px] font-bold uppercase transition-all cursor-pointer"
|
|
>
|
|
Alle entladen
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* The memory bar */}
|
|
<div className="h-7 w-full rounded-xl bg-background/50 border border-border/40 overflow-hidden flex p-0.5 relative group">
|
|
{runningWithInfo.length === 0 ? (
|
|
<div className="w-full h-full flex items-center justify-center text-[10px] text-muted-foreground/60 italic font-mono select-none">
|
|
VRAM Leer — Auto-Swap lädt Modelle bei Anfrage
|
|
</div>
|
|
) : (
|
|
runningWithInfo.map((m, idx) => {
|
|
const widthPct = ((m.size_bytes || 0) / capacity) * 100
|
|
const bgClass = [
|
|
"from-teal-500 to-emerald-500",
|
|
"from-indigo-500 to-blue-500",
|
|
"from-purple-500 to-pink-500",
|
|
"from-cyan-500 to-sky-500"
|
|
][idx % 4]
|
|
return (
|
|
<div
|
|
key={m.name}
|
|
style={{ width: `${widthPct}%` }}
|
|
className={cn(
|
|
"h-full rounded bg-gradient-to-r flex items-center justify-between px-2.5 transition-all text-white/95 shrink-0 shadow-inner select-none",
|
|
bgClass
|
|
)}
|
|
title={`${m.name} (${fmtSize(m.size_bytes)})`}
|
|
>
|
|
<span className="text-[9px] font-bold truncate max-w-[120px] font-space tracking-wide">
|
|
{m.role ? `[${m.role}] ` : ""}{m.name.split("/").pop()?.replace(".gguf", "")}
|
|
</span>
|
|
<span className="text-[8px] font-mono opacity-80">{fmtSize(m.size_bytes)}</span>
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ZONE B: SVG Node Router */}
|
|
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-6 shadow-lg shadow-black/10 space-y-4">
|
|
<div>
|
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground">Interactive Gateway Graph</span>
|
|
<p className="text-[10px] text-muted-foreground mt-0.5 leading-relaxed">
|
|
Klicke links auf Editoren, um Configs zu kopieren. Klicke rechts auf Rollen, um GGUF-Zuweisungen live zu ändern.
|
|
</p>
|
|
</div>
|
|
|
|
{/* The Graph Canvas Area */}
|
|
<div ref={containerRef} className="relative w-full h-96 border border-border/30 rounded-xl bg-black/25 overflow-hidden flex">
|
|
{/* SVG Overlay behind HTML Nodes */}
|
|
<svg className="absolute inset-0 pointer-events-none w-full h-full">
|
|
{/* Gradients */}
|
|
<defs>
|
|
<linearGradient id="cyan-to-teal" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" stopColor="#06b6d4" stopOpacity="0.45" />
|
|
<stop offset="100%" stopColor="#0d9488" stopOpacity="0.45" />
|
|
</linearGradient>
|
|
<linearGradient id="active-glow" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.8" />
|
|
<stop offset="100%" stopColor="#10b981" stopOpacity="0.8" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
{/* Bezier Curves: Clients to Gateway (10% -> 50%) */}
|
|
{/* Roo Code (y=10) */}
|
|
<path d={getClientPath(0.10)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
|
{(hoveredNode === "roocode" || activeClient === "roocode") && (
|
|
<path d={getClientPath(0.10)} stroke="#06b6d4" strokeWidth="2.5" fill="none" className="svg-flow-path" />
|
|
)}
|
|
|
|
{/* Cursor (y=30) */}
|
|
<path d={getClientPath(0.30)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
|
{(hoveredNode === "cursor" || activeClient === "cursor") && (
|
|
<path d={getClientPath(0.30)} stroke="#06b6d4" strokeWidth="2.5" fill="none" className="svg-flow-path" />
|
|
)}
|
|
|
|
{/* OpenCode (y=50) */}
|
|
<path d={getClientPath(0.50)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
|
{(hoveredNode === "opencode" || activeClient === "opencode") && (
|
|
<path d={getClientPath(0.50)} stroke="#06b6d4" strokeWidth="2.5" fill="none" className="svg-flow-path" />
|
|
)}
|
|
|
|
{/* Zed (y=70) */}
|
|
<path d={getClientPath(0.70)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
|
{(hoveredNode === "zed" || activeClient === "zed") && (
|
|
<path d={getClientPath(0.70)} stroke="#06b6d4" strokeWidth="2.5" fill="none" className="svg-flow-path" />
|
|
)}
|
|
|
|
{/* Continue (y=90) */}
|
|
<path d={getClientPath(0.90)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
|
{(hoveredNode === "continue" || activeClient === "continue") && (
|
|
<path d={getClientPath(0.90)} stroke="#06b6d4" strokeWidth="2.5" fill="none" className="svg-flow-path" />
|
|
)}
|
|
|
|
{/* Bezier Curves: Gateway to Roles (50% -> 90%) */}
|
|
{/* fast (y=12) */}
|
|
<path d={getRolePath(0.12)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
|
{isRoleRunning("fast") && (
|
|
<path d={getRolePath(0.12)} stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="svg-flow-path" />
|
|
)}
|
|
|
|
{/* heavy (y=31) */}
|
|
<path d={getRolePath(0.31)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
|
{isRoleRunning("heavy") && (
|
|
<path d={getRolePath(0.31)} stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="svg-flow-path" />
|
|
)}
|
|
|
|
{/* coder (y=50) */}
|
|
<path d={getRolePath(0.50)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
|
{isRoleRunning("coder") && (
|
|
<path d={getRolePath(0.50)} stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="svg-flow-path" />
|
|
)}
|
|
|
|
{/* vision (y=69) */}
|
|
<path d={getRolePath(0.69)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
|
{isRoleRunning("vision") && (
|
|
<path d={getRolePath(0.69)} stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="svg-flow-path" />
|
|
)}
|
|
|
|
{/* scout (y=88) */}
|
|
<path d={getRolePath(0.88)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
|
{isRoleRunning("scout") && (
|
|
<path d={getRolePath(0.88)} stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="svg-flow-path" />
|
|
)}
|
|
</svg>
|
|
|
|
{/* HTML Nodes */}
|
|
{/* COLUMN 1: Client nodes */}
|
|
<div
|
|
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
|
|
style={{ left: "10%", top: "10%" }}
|
|
onMouseEnter={() => setHoveredNode("roocode")}
|
|
onMouseLeave={() => setHoveredNode(null)}
|
|
onClick={() => setActiveClient(c => c === "roocode" ? null : "roocode")}
|
|
>
|
|
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
|
<span>Roo Code</span>
|
|
</div>
|
|
|
|
<div
|
|
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
|
|
style={{ left: "10%", top: "30%" }}
|
|
onMouseEnter={() => setHoveredNode("cursor")}
|
|
onMouseLeave={() => setHoveredNode(null)}
|
|
onClick={() => setActiveClient(c => c === "cursor" ? null : "cursor")}
|
|
>
|
|
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
|
<span>Cursor IDE</span>
|
|
</div>
|
|
|
|
<div
|
|
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
|
|
style={{ left: "10%", top: "50%" }}
|
|
onMouseEnter={() => setHoveredNode("opencode")}
|
|
onMouseLeave={() => setHoveredNode(null)}
|
|
onClick={() => setActiveClient(c => c === "opencode" ? null : "opencode")}
|
|
>
|
|
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
|
<span>OpenCode</span>
|
|
</div>
|
|
|
|
<div
|
|
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
|
|
style={{ left: "10%", top: "70%" }}
|
|
onMouseEnter={() => setHoveredNode("zed")}
|
|
onMouseLeave={() => setHoveredNode(null)}
|
|
onClick={() => setActiveClient(c => c === "zed" ? null : "zed")}
|
|
>
|
|
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
|
<span>Zed</span>
|
|
</div>
|
|
|
|
<div
|
|
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
|
|
style={{ left: "10%", top: "90%" }}
|
|
onMouseEnter={() => setHoveredNode("continue")}
|
|
onMouseLeave={() => setHoveredNode(null)}
|
|
onClick={() => setActiveClient(c => c === "continue" ? null : "continue")}
|
|
>
|
|
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
|
<span>Continue</span>
|
|
</div>
|
|
|
|
{/* COLUMN 2: Central Gateway Node */}
|
|
<div
|
|
className="absolute select-none z-10 w-36 py-3 px-4 -translate-y-1/2 -translate-x-1/2 rounded-2xl border border-primary/50 bg-card/90 backdrop-blur-md flex flex-col items-center justify-center text-center shadow-lg shadow-primary/5"
|
|
style={{ left: "50%", top: "50%" }}
|
|
>
|
|
<div className="text-[10px] uppercase font-bold text-primary font-space tracking-wide">Gateway Auto</div>
|
|
<div className="text-[10px] font-mono text-muted-foreground mt-0.5">
|
|
Schwelle: > {routing?.heavy_threshold_chars ? (routing.heavy_threshold_chars / 1000) : "4"}k Zeichen
|
|
</div>
|
|
<div className="mt-1 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-[9px] font-semibold text-primary uppercase font-mono">
|
|
Auto-Swap
|
|
</div>
|
|
</div>
|
|
|
|
{/* COLUMN 3: Role nodes (fast, heavy, coder, vision, scout) */}
|
|
{ROLES.map((role) => {
|
|
const yPositions = ["12%", "31%", "50%", "69%", "88%"]
|
|
const activeModel = getModelForRole(role)
|
|
const isWarm = activeModel ? running.includes(activeModel.name) : false
|
|
|
|
// Skip "reasoning" & "agent" to keep layout neat and identical to canonical roles
|
|
if (role === "reasoning" || role === "agent") return null
|
|
const indexMap = { fast: 0, heavy: 1, coder: 2, vision: 3, scout: 4 }[role] as number
|
|
|
|
return (
|
|
<div
|
|
key={role}
|
|
className={cn(
|
|
"absolute z-10 w-44 py-1.5 px-3 -translate-y-1/2 -translate-x-1/2 rounded-xl border flex flex-col justify-center hover:border-primary/50 transition-all text-left shadow select-none cursor-pointer",
|
|
isWarm
|
|
? "border-emerald-500/50 bg-emerald-500/5 shadow-emerald-500/5"
|
|
: activeModel
|
|
? "border-border/60 bg-card/75"
|
|
: "border-dashed border-border/40 bg-background/20"
|
|
)}
|
|
style={{ left: "90%", top: yPositions[indexMap] }}
|
|
onClick={() => setActiveRoleForAssign(role)}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{role}</span>
|
|
{isWarm && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />}
|
|
</div>
|
|
<div className="text-[10px] font-mono font-medium truncate mt-0.5 text-foreground max-w-[150px]">
|
|
{activeModel ? activeModel.name.split("/").pop()?.replace(".gguf", "") : "Keine Zuweisung"}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* CLIENT SETUP MODAL WINDOW */}
|
|
{activeClient && connectData && (
|
|
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
|
|
<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 flex flex-col justify-between">
|
|
{/* Modal Header */}
|
|
<div className="flex items-center justify-between border-b border-border/20 pb-2">
|
|
<span className="text-xs font-bold uppercase tracking-wider text-primary">
|
|
{activeClient === "roocode" && "Roo Code Setup"}
|
|
{activeClient === "cursor" && "Cursor Setup"}
|
|
{activeClient === "opencode" && "OpenCode Setup"}
|
|
{activeClient === "zed" && "Zed Setup"}
|
|
{activeClient === "continue" && "Continue Setup"}
|
|
</span>
|
|
<button
|
|
onClick={() => setActiveClient(null)}
|
|
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Modal Guide Steps */}
|
|
<div className="text-xs text-muted-foreground leading-relaxed space-y-2">
|
|
{activeClient === "roocode" && (
|
|
<ul className="list-decimal pl-4 space-y-1">
|
|
<li>Suche in VS Code nach der Erweiterung <strong>Roo Code</strong> und installiere sie.</li>
|
|
<li>Wähle in den Roo Code Einstellungen: Provider: <strong>OpenAI Compatible</strong>.</li>
|
|
<li>Füge das untenstehende JSON-Snippet in die <code>settings.json</code> ein.</li>
|
|
</ul>
|
|
)}
|
|
{activeClient === "cursor" && (
|
|
<ul className="list-decimal pl-4 space-y-1">
|
|
<li>Öffne Cursor Settings ➔ <strong>Models</strong>.</li>
|
|
<li>Deaktiviere Cloud-Modelle, klappe <strong>OpenAI API</strong> auf.</li>
|
|
<li>Trage die Base URL unten ein und aktiviere das Modell <strong>auto</strong>.</li>
|
|
</ul>
|
|
)}
|
|
{activeClient === "opencode" && (
|
|
<ul className="list-decimal pl-4 space-y-1">
|
|
<li>Öffne die <code>opencode.jsonc</code> Konfigurationsdatei.</li>
|
|
<li>Ersetze den Provider-Eintrag unter <code>provider</code> mit dem Snippet unten.</li>
|
|
</ul>
|
|
)}
|
|
{activeClient === "zed" && (
|
|
<ul className="list-decimal pl-4 space-y-1">
|
|
<li>Öffne die Zed Settings (<code>ctrl+,</code>).</li>
|
|
<li>Füge das untenstehende JSON-Segment unter <code>language_models</code> ein.</li>
|
|
</ul>
|
|
)}
|
|
{activeClient === "continue" && (
|
|
<ul className="list-decimal pl-4 space-y-1">
|
|
<li>Klicke auf das Zahnrad-Symbol in der Continue-Erweiterung.</li>
|
|
<li>Füge den Gateway-Eintrag zum <code>models</code>-Array hinzu.</li>
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{/* Snippet box */}
|
|
{connectData.tools && (
|
|
<div className="relative rounded-xl border border-border/40 bg-black/40 overflow-hidden mt-1 shrink-0">
|
|
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border/20 bg-black/20">
|
|
<span className="text-[10px] font-mono text-muted-foreground">JSON Config</span>
|
|
<button
|
|
onClick={() => copySnippet(
|
|
activeClient === "roocode" ? connectData.tools.cline?.snippet :
|
|
activeClient === "cursor" ? connectData.tools.cursor?.snippet :
|
|
activeClient === "opencode" ? connectData.tools.opencode?.snippet :
|
|
activeClient === "zed" ? connectData.tools.zed?.snippet :
|
|
connectData.tools.continue?.snippet
|
|
)}
|
|
className="text-xs font-semibold text-primary hover:text-primary-foreground flex items-center gap-1 cursor-pointer"
|
|
>
|
|
{copied ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}
|
|
<span>{copied ? "Kopiert" : "Kopieren"}</span>
|
|
</button>
|
|
</div>
|
|
<pre className="p-3 max-h-48 overflow-y-auto text-xs font-mono text-cyan-200/90 whitespace-pre scrollbar-thin select-text">
|
|
<code>
|
|
{activeClient === "roocode" && connectData.tools.cline?.snippet}
|
|
{activeClient === "cursor" && connectData.tools.cursor?.snippet}
|
|
{activeClient === "opencode" && connectData.tools.opencode?.snippet}
|
|
{activeClient === "zed" && connectData.tools.zed?.snippet}
|
|
{activeClient === "continue" && connectData.tools.continue?.snippet}
|
|
</code>
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => setActiveClient(null)}
|
|
className="w-full h-9 rounded-lg bg-primary text-primary-foreground hover:opacity-90 font-semibold text-xs transition-opacity cursor-pointer mt-2"
|
|
>
|
|
Schließen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Legend for the connection graph */}
|
|
<div className="flex flex-wrap items-center gap-6 mt-3 text-[10px] text-muted-foreground border-t border-border/20 pt-3">
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="h-2 w-2 rounded-full bg-cyan-400" />
|
|
<span>Cyan-Fluss: Client-Anfrage an Gateway</span>
|
|
</span>
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
|
|
<span>Grüner Puls: Aktive Verbindung / Warmes Modell geladen</span>
|
|
</span>
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="h-2 w-2 rounded bg-gradient-to-r from-teal-500 to-emerald-500" />
|
|
<span>VRAM-Verlauf: Modellspezifischer Speicheranteil</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ZONE B.5: Slot-Belegung */}
|
|
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-3.5">
|
|
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground px-1">
|
|
Gateway Steckplatz-Belegung (Slot-Zuweisung)
|
|
</div>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
|
{["fast", "heavy", "coder", "reasoning", "vision", "scout"].map((role) => {
|
|
const m = models.find((x) => x.role === role)
|
|
const isWarm = m ? running.includes(m.name) : false
|
|
|
|
return (
|
|
<div
|
|
key={role}
|
|
onClick={() => setActiveRoleForAssign(role)}
|
|
className={cn(
|
|
"rounded-xl border p-3 flex flex-col justify-between gap-2.5 transition-all cursor-pointer select-none text-left bg-background/25 hover:border-primary/45 hover:bg-background/40 group min-h-[90px]",
|
|
isWarm
|
|
? "border-emerald-500/40 shadow-sm shadow-emerald-500/5"
|
|
: m
|
|
? "border-primary/20 bg-primary/5"
|
|
: "border-border/30 border-dashed opacity-75"
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className={cn("text-[9px] font-bold uppercase tracking-wider font-mono px-1.5 py-0.5 rounded border",
|
|
role === "fast" ? "bg-cyan-500/10 text-cyan-400 border-cyan-500/20" :
|
|
role === "heavy" ? "bg-amber-500/10 text-amber-400 border-amber-500/20" :
|
|
role === "coder" ? "bg-violet-500/10 text-violet-400 border-violet-500/20" :
|
|
role === "reasoning" ? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" :
|
|
role === "vision" ? "bg-pink-500/10 text-pink-400 border-pink-500/20" :
|
|
"bg-teal-500/10 text-teal-400 border-teal-500/20"
|
|
)}>{role}</span>
|
|
{isWarm && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />}
|
|
</div>
|
|
|
|
<div className="text-[10px] font-mono font-medium truncate text-foreground/90 pr-1 font-space" title={m?.name}>
|
|
{m ? m.name.split("/").pop()?.replace(/\.gguf$/i, "") : "nicht zugewiesen"}
|
|
</div>
|
|
|
|
<div className="text-[8px] text-primary/70 group-hover:text-primary font-bold uppercase tracking-wider transition-colors font-space">
|
|
Ändern ➔
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ZONE C: Library list cards & Upgrade Radar */}
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 px-1">
|
|
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
|
Installierte Modell-Bibliothek ({filteredModels.length} von {models.length})
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
{/* Filter mode selector */}
|
|
<div className="flex rounded-lg border border-border/40 bg-card/45 p-0.5">
|
|
<button
|
|
onClick={() => setFilterMode("all")}
|
|
className={cn(
|
|
"px-2.5 py-1 rounded-md text-[9px] font-bold uppercase transition-all cursor-pointer",
|
|
filterMode === "all" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
Vorhanden
|
|
</button>
|
|
<button
|
|
onClick={() => setFilterMode("in_use")}
|
|
className={cn(
|
|
"px-2.5 py-1 rounded-md text-[9px] font-bold uppercase transition-all cursor-pointer",
|
|
filterMode === "in_use" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
In Benutzung
|
|
</button>
|
|
</div>
|
|
|
|
{/* Grid/List layout selector */}
|
|
<div className="flex rounded-lg border border-border/40 bg-card/45 p-0.5">
|
|
<button
|
|
onClick={() => setViewMode("grid")}
|
|
className={cn(
|
|
"px-2.5 py-1 rounded-md text-[9px] font-bold uppercase transition-all cursor-pointer",
|
|
viewMode === "grid" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
Grid
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode("list")}
|
|
className={cn(
|
|
"px-2.5 py-1 rounded-md text-[9px] font-bold uppercase transition-all cursor-pointer",
|
|
viewMode === "list" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
List
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{viewMode === "grid" ? (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{filteredModels.length === 0 ? (
|
|
<div className="col-span-full text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center select-none">
|
|
{filterMode === "in_use"
|
|
? "Keine Modelle in Benutzung (keine Zuweisung oder warm geladene Modelle)."
|
|
: "Keine Modelle konfiguriert. Verwende den Tab \"Modelle finden\" zum Herunterladen."}
|
|
</div>
|
|
) : (
|
|
filteredModels.map((m) => {
|
|
const isRunning = running.includes(m.name)
|
|
const hasUpgrade = updates?.model_list.find((u) => u.role === m.role)
|
|
const brand = getBrandInfo(m.name)
|
|
|
|
return (
|
|
<div
|
|
key={m.name}
|
|
className={cn(
|
|
"rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-4 transition-all duration-300 hover:border-primary/40 group relative overflow-hidden",
|
|
isRunning
|
|
? "border-primary/45 shadow-primary/5"
|
|
: m.role
|
|
? "border-primary/30 bg-primary/5 shadow-inner"
|
|
: "border-border/60"
|
|
)}
|
|
>
|
|
<div className="space-y-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex gap-2.5 min-w-0">
|
|
{/* Brand Icon Badge */}
|
|
<div className={cn("w-7 h-7 rounded-lg border flex items-center justify-center font-bold text-xs shrink-0 shadow-sm", brand.color)} title={brand.name}>
|
|
{brand.initial}
|
|
</div>
|
|
|
|
<div className="min-w-0">
|
|
<h3 className="text-xs font-bold tracking-tight text-foreground line-clamp-2 break-all font-mono" title={m.name}>
|
|
{m.name.split("/").pop()}
|
|
</h3>
|
|
<div className="flex items-center gap-2 flex-wrap mt-1">
|
|
<span className="text-[9px] font-mono text-muted-foreground bg-background/40 px-1.5 py-0.5 rounded border border-border/30">
|
|
{m.quant || "GGUF"}
|
|
</span>
|
|
{isRunning && (
|
|
<span className="flex items-center gap-1 text-[9px] font-semibold text-emerald-400 uppercase tracking-wider">
|
|
<Activity className="h-3 w-3 animate-pulse" /> Warm
|
|
</span>
|
|
)}
|
|
{m.role && (
|
|
<span className="px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-[9px] font-mono text-primary font-bold uppercase">
|
|
{m.role}
|
|
</span>
|
|
)}
|
|
{m.prompt_cache && (
|
|
<span className="px-1.5 py-0.5 rounded bg-cyan-500/10 border border-cyan-500/20 text-[9px] font-mono text-cyan-400 font-bold uppercase" title="Prompt Caching aktiv">
|
|
PC
|
|
</span>
|
|
)}
|
|
{m.spec_draft_model && (
|
|
<span className="px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-[9px] font-mono text-emerald-400 font-bold uppercase" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>
|
|
SPEC
|
|
</span>
|
|
)}
|
|
{m.parallel_slots > 1 && (
|
|
<span className="px-1.5 py-0.5 rounded bg-violet-500/10 border border-violet-500/20 text-[9px] font-mono text-violet-400 font-bold uppercase" title={`${m.parallel_slots} parallele Slots aktiv`}>
|
|
SLOTS: {m.parallel_slots}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-border/30 pt-3 flex flex-wrap gap-1">
|
|
<CapsChips caps={m.capabilities} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3 pt-1">
|
|
<div className="grid grid-cols-2 gap-2 text-[10px] font-mono text-muted-foreground">
|
|
<div className="flex items-center gap-1.5 p-2 bg-background/20 rounded-lg border border-border/20">
|
|
<HardDrive className="h-3.5 w-3.5 text-primary/80" />
|
|
<div>
|
|
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Größe</div>
|
|
<div className="text-foreground font-semibold">{fmtSize(m.size_bytes)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 p-2 bg-background/20 rounded-lg border border-border/20">
|
|
<Edit3 className="h-3.5 w-3.5 text-primary/80" />
|
|
<div>
|
|
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Kontext</div>
|
|
<div className="text-foreground font-semibold">{fmtCtx(m.ctx)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Smart Upgrade radar trigger banner */}
|
|
{hasUpgrade && (
|
|
<div className="p-3 bg-amber-500/5 border border-amber-500/30 rounded-xl space-y-2 flex flex-col justify-between shrink-0">
|
|
<div className="text-[9px] font-semibold text-amber-400 flex items-center gap-1">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping" />
|
|
<span>Upgrade verfügbar: {hasUpgrade.repo.split("/").pop()}</span>
|
|
</div>
|
|
<button
|
|
onClick={() => handleSmartUpgrade(hasUpgrade.repo, m.role!, m.quant || "Q4_K_M", m.capabilities.tools !== "no")}
|
|
className="h-7 w-full flex items-center justify-center gap-1 rounded-lg bg-amber-500 text-black text-[10px] font-bold hover:bg-amber-400 transition-colors cursor-pointer"
|
|
>
|
|
<Download className="h-3 w-3" /> Smart-Swap starten
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions row at the bottom of Grid Card */}
|
|
<div className="flex items-center gap-1.5 border-t border-border/20 pt-3 mt-auto">
|
|
<button
|
|
onClick={() => isRunning ? handleUnloadModel(m.name) : handleLoadModel(m.name)}
|
|
className={cn(
|
|
"flex-1 h-7 rounded-lg text-[9px] font-bold uppercase transition-all cursor-pointer border flex items-center justify-center gap-1",
|
|
isRunning
|
|
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/15 text-amber-400"
|
|
: "border-primary/30 bg-primary/5 hover:bg-primary/15 text-primary"
|
|
)}
|
|
>
|
|
{isRunning ? "Entladen" : "Laden"}
|
|
</button>
|
|
<button
|
|
onClick={() => handleSetCtx(m.name, m.ctx)}
|
|
className="h-7 px-2.5 rounded-lg border border-border/40 text-[9px] font-bold uppercase hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
|
|
title="Kontextlänge anpassen"
|
|
>
|
|
Ctx
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(m.name)}
|
|
className="h-7 w-7 rounded-lg border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 flex items-center justify-center transition-colors cursor-pointer"
|
|
title="Modell löschen"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{filteredModels.length === 0 ? (
|
|
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center select-none">
|
|
{filterMode === "in_use"
|
|
? "Keine Modelle in Benutzung (keine Zuweisung oder warm geladene Modelle)."
|
|
: "Keine Modelle konfiguriert. Verwende den Tab \"Modelle finden\" zum Herunterladen."}
|
|
</div>
|
|
) : (
|
|
filteredModels.map((m) => {
|
|
const isRunning = running.includes(m.name)
|
|
const brand = getBrandInfo(m.name)
|
|
|
|
return (
|
|
<div
|
|
key={m.name}
|
|
className={cn(
|
|
"rounded-xl border bg-card/45 backdrop-blur-md p-3.5 shadow-lg shadow-black/5 flex flex-col sm:flex-row sm:items-center justify-between gap-3 hover:border-primary/40 transition-all",
|
|
isRunning
|
|
? "border-primary/45"
|
|
: m.role
|
|
? "border-primary/30 bg-primary/5"
|
|
: "border-border/60"
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
{/* Brand Icon */}
|
|
<div className={cn("w-8 h-8 rounded-lg border border-border/40 flex items-center justify-center font-bold text-xs shrink-0 shadow-sm", brand.color)} title={brand.name}>
|
|
{brand.initial}
|
|
</div>
|
|
|
|
<div className="min-w-0 text-left">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<h4 className="text-xs font-bold text-foreground truncate max-w-[250px] sm:max-w-[400px] break-all font-mono" title={m.name}>
|
|
{m.name.split("/").pop()}
|
|
</h4>
|
|
{m.role && (
|
|
<span className="px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-[8px] font-mono text-primary font-bold uppercase shrink-0">
|
|
{m.role}
|
|
</span>
|
|
)}
|
|
{m.prompt_cache && (
|
|
<span className="px-1.5 py-0.5 rounded bg-cyan-500/10 border border-cyan-500/20 text-[8px] font-mono text-cyan-400 font-bold uppercase shrink-0" title="Prompt Caching aktiv">
|
|
PC
|
|
</span>
|
|
)}
|
|
{m.spec_draft_model && (
|
|
<span className="px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-[8px] font-mono text-emerald-400 font-bold uppercase shrink-0" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>
|
|
SPEC
|
|
</span>
|
|
)}
|
|
{m.parallel_slots > 1 && (
|
|
<span className="px-1.5 py-0.5 rounded bg-violet-500/10 border border-violet-500/20 text-[8px] font-mono text-violet-400 font-bold uppercase shrink-0" title={`${m.parallel_slots} parallele Slots aktiv`}>
|
|
SLOTS: {m.parallel_slots}
|
|
</span>
|
|
)}
|
|
{isRunning && (
|
|
<span className="flex items-center gap-0.5 text-[8px] font-semibold text-emerald-400 uppercase tracking-wider shrink-0">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> warm
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2 mt-0.5">
|
|
<span>Größe: {fmtSize(m.size_bytes)}</span>
|
|
<span>•</span>
|
|
<span>Kontext: {fmtCtx(m.ctx)}</span>
|
|
<span>•</span>
|
|
<span className="font-mono text-[9px]">{m.quant || "GGUF"}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chips and Actions */}
|
|
<div className="flex items-center gap-3 shrink-0 self-end sm:self-auto">
|
|
<div className="hidden lg:flex flex-wrap gap-1">
|
|
<CapsChips caps={m.capabilities} />
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
<button
|
|
onClick={() => isRunning ? handleUnloadModel(m.name) : handleLoadModel(m.name)}
|
|
className={cn(
|
|
"h-7 px-3 rounded-lg text-[9px] font-bold uppercase transition-all cursor-pointer border flex items-center gap-1",
|
|
isRunning
|
|
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/15 text-amber-400"
|
|
: "border-primary/30 bg-primary/5 hover:bg-primary/15 text-primary"
|
|
)}
|
|
>
|
|
{isRunning ? "Entladen" : "Laden"}
|
|
</button>
|
|
<button
|
|
onClick={() => handleSetCtx(m.name, m.ctx)}
|
|
className="h-7 px-2.5 rounded-lg border border-border/40 text-[9px] font-bold uppercase hover:bg-accent text-muted-foreground transition-all cursor-pointer"
|
|
title="Kontextlänge anpassen"
|
|
>
|
|
Ctx
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(m.name)}
|
|
className="h-7 w-7 rounded-lg flex items-center justify-center border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 transition-all cursor-pointer"
|
|
title="Modell löschen"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Role Assignment Modal */}
|
|
{activeRoleForAssign && (
|
|
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
|
|
<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">
|
|
Rolle '{activeRoleForAssign}' konfigurieren
|
|
</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>
|
|
<p className="text-xs text-muted-foreground">
|
|
Wähle ein Modell aus deiner Bibliothek für die Rolle <strong className="text-foreground">{activeRoleForAssign}</strong>:
|
|
</p>
|
|
|
|
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
|
|
<button
|
|
onClick={() => {
|
|
handleRoleChange(activeRoleForAssign, "")
|
|
setActiveRoleForAssign(null)
|
|
}}
|
|
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>
|
|
{models.map((m) => (
|
|
<button
|
|
key={m.name}
|
|
onClick={() => {
|
|
handleRoleChange(activeRoleForAssign, m.name)
|
|
setActiveRoleForAssign(null)
|
|
}}
|
|
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 border-border/30",
|
|
m.role === activeRoleForAssign
|
|
? "text-primary font-bold bg-primary/10 border-primary/30"
|
|
: "text-foreground bg-background/20"
|
|
)}
|
|
>
|
|
<div className="flex flex-col text-left">
|
|
<span className="truncate max-w-[280px] font-semibold">{m.name.split("/").pop()?.replace(".gguf", "")}</span>
|
|
<span className="text-[9px] text-muted-foreground mt-0.5">{fmtSize(m.size_bytes)} · {m.quant}</span>
|
|
</div>
|
|
{m.role === activeRoleForAssign && <Check className="h-4 w-4 shrink-0 text-primary" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{dialog && (
|
|
<CustomDialog
|
|
type={dialog.type}
|
|
title={dialog.title}
|
|
message={dialog.message}
|
|
defaultValue={dialog.defaultValue}
|
|
onConfirm={dialog.onConfirm}
|
|
onCancel={dialog.onCancel}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AddModel() {
|
|
const [repo, setRepo] = useState("")
|
|
const [quants, setQuants] = useState<string[]>([])
|
|
const [quant, setQuant] = useState("Q4_K_M")
|
|
const [msg, setMsg] = useState("")
|
|
const [q, setQ] = useState("")
|
|
const [results, setResults] = useState<{ repo: string; downloads: number }[]>([])
|
|
|
|
async function loadQuants(r?: string) {
|
|
const rr = r ?? repo
|
|
if (!rr.trim()) return
|
|
setMsg("Analysiere HuggingFace Repository...")
|
|
try {
|
|
const d = await api<{ repo: string; quants: string[] }>(`/api/hf/quants?repo=${encodeURIComponent(rr)}`)
|
|
setRepo(d.repo)
|
|
setQuants(d.quants)
|
|
if (d.quants.length) setQuant(d.quants.includes("Q4_K_M") ? "Q4_K_M" : d.quants[0])
|
|
setMsg(d.quants.length ? "" : "Keine GGUF-Dateien in diesem Repository gefunden.")
|
|
} catch (e) {
|
|
setMsg(`Fehler: ${e}`)
|
|
}
|
|
}
|
|
|
|
async function search() {
|
|
if (!q.trim()) return
|
|
setMsg("Durchsuche HuggingFace...")
|
|
try {
|
|
const d = await api<{ results: { repo: string; downloads: number }[] }>(`/api/hf/search?q=${encodeURIComponent(q)}`)
|
|
setResults(d.results)
|
|
setMsg(d.results.length ? "" : "Keine Ergebnisse gefunden.")
|
|
} catch (e) {
|
|
setMsg(`Suche fehlgeschlagen: ${e}`)
|
|
}
|
|
}
|
|
|
|
async function install() {
|
|
if (!repo.trim()) return
|
|
setMsg("Download-Job wird initiiert...")
|
|
try {
|
|
await api("/api/models/install", {
|
|
method: "POST",
|
|
body: JSON.stringify({ repo, quant, jinja: true }),
|
|
})
|
|
setMsg(`Download gestartet: ${repo} (${quant}). Fortschritt wird oben angezeigt.`)
|
|
} catch (e) {
|
|
setMsg(`Download-Fehler: ${e}`)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10">
|
|
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">HF Download & Suche</div>
|
|
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<input
|
|
value={repo}
|
|
onChange={(e) => setRepo(e.target.value)}
|
|
placeholder="HF-URL oder org/repo (z.B. unsloth/Qwen2.5-Coder-7B-Instruct-GGUF)"
|
|
className="flex-1 h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
|
|
/>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => loadQuants()}
|
|
className="h-9 px-4 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer whitespace-nowrap"
|
|
>
|
|
Quants laden
|
|
</button>
|
|
{quants.length > 0 && (
|
|
<>
|
|
<select
|
|
value={quant}
|
|
onChange={(e) => setQuant(e.target.value)}
|
|
className="h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none text-foreground font-semibold"
|
|
>
|
|
{quants.map((qq) => <option key={qq} value={qq} className="bg-popover text-foreground">{qq}</option>)}
|
|
</select>
|
|
<button
|
|
onClick={install}
|
|
className="h-9 px-4 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all cursor-pointer flex items-center gap-1.5"
|
|
>
|
|
<Download className="h-3.5 w-3.5" /> Herunterladen
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2 border-t border-border/20 pt-4">
|
|
<div className="relative flex-1">
|
|
<input
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && search()}
|
|
placeholder="HuggingFace durchsuchen (z.B. Llama-3.1)..."
|
|
className="w-full h-9 pl-9 pr-3 rounded-lg border border-border/60 bg-background/40 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
|
|
/>
|
|
<Search className="absolute left-3 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
|
</div>
|
|
<button
|
|
onClick={search}
|
|
className="h-9 px-4 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer"
|
|
>
|
|
Suchen
|
|
</button>
|
|
</div>
|
|
|
|
{results.length > 0 && (
|
|
<div className="max-h-48 space-y-1.5 overflow-y-auto border border-border/40 bg-background/20 p-2 rounded-xl scrollbar-thin">
|
|
{results.map((r) => (
|
|
<button
|
|
key={r.repo}
|
|
onClick={() => { setRepo(r.repo); setResults([]); setQ(""); loadQuants(r.repo) }}
|
|
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-xs bg-background/10 border border-border/10 hover:border-primary/30 hover:bg-background/30 transition-all"
|
|
>
|
|
<span className="font-semibold truncate">{r.repo}</span>
|
|
<span className="text-[10px] font-mono text-muted-foreground flex items-center gap-1 shrink-0">
|
|
<Download className="h-3 w-3" /> {r.downloads.toLocaleString()}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{msg && <div className="text-[10px] font-medium text-primary font-mono">{msg}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const ROLE_METADATA: Record<string, { title: string; desc: string; icon: any }> = {
|
|
vision: {
|
|
title: "Bilder & Vision",
|
|
desc: "Verarbeitet Bilder, Screenshots und visuelle Diagramme in multimodalen Chats.",
|
|
icon: Eye
|
|
},
|
|
coder: {
|
|
title: "Coden & Entwicklung",
|
|
desc: "Autovervollständigung, Refactoring und Codegenerierung direkt in deiner IDE.",
|
|
icon: Code
|
|
},
|
|
reasoning: {
|
|
title: "Logik & Nachdenken",
|
|
desc: "Komplexe logische Gedankengänge, Mathematik und tiefgründiges Planen (Reasoning).",
|
|
icon: Brain
|
|
},
|
|
agent: {
|
|
title: "Autonomer Agent (Hermes)",
|
|
desc: "Führt selbstständig Terminalbefehle aus und interagiert mit deinem Homelab.",
|
|
icon: Bot
|
|
},
|
|
scout: {
|
|
title: "Allrounder & Chat",
|
|
desc: "Schnelle Antworten, Zusammenfassungen, Übersetzungen und alltägliche Fragen.",
|
|
icon: Compass
|
|
}
|
|
}
|
|
|
|
function Discover() {
|
|
const [data, setData] = useState<DiscoverResp | null>(null)
|
|
const [models, setModels] = useState<ModelInfo[]>([])
|
|
const [updates, setUpdates] = useState<UpdatesResp | null>(null)
|
|
const [error, setError] = useState("")
|
|
const [loading, setLoading] = useState(true)
|
|
const [installing, setInstalling] = useState<Record<string, string>>({})
|
|
const [expandedAlternatives, setExpandedAlternatives] = useState<Record<string, boolean>>({})
|
|
const [showExpert, setShowExpert] = useState(false)
|
|
|
|
useEffect(() => {
|
|
Promise.all([
|
|
api<DiscoverResp>("/api/discover"),
|
|
api<{ models: ModelInfo[] }>("/api/models"),
|
|
api<UpdatesResp>("/api/maintenance/updates").catch(() => null)
|
|
])
|
|
.then(([discoverData, modelsData, updatesData]) => {
|
|
setData(discoverData)
|
|
setModels(modelsData.models || [])
|
|
if (updatesData) setUpdates(updatesData)
|
|
})
|
|
.catch((e) => setError(String(e)))
|
|
.finally(() => setLoading(false))
|
|
}, [])
|
|
|
|
async function install(repo: string, role: string, quant: string, toolCapable: boolean) {
|
|
setInstalling((s) => ({ ...s, [repo]: "Starte..." }))
|
|
try {
|
|
await api("/api/models/install", {
|
|
method: "POST",
|
|
body: JSON.stringify({ repo, role, quant, jinja: toolCapable }),
|
|
})
|
|
setInstalling((s) => ({ ...s, [repo]: "Download läuft" }))
|
|
} catch (e) {
|
|
setInstalling((s) => ({ ...s, [repo]: "Fehler" }))
|
|
}
|
|
}
|
|
|
|
if (loading) return <div className="text-xs text-muted-foreground py-12 text-center">Analysiere Hardware und suche passende GGUF-Empfehlungen…</div>
|
|
if (error || !data)
|
|
return (
|
|
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
|
|
Empfehlungsdienst temporär nicht erreichbar ({error}).
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Informational Header */}
|
|
<div className="text-[10px] font-mono text-muted-foreground bg-background/25 border border-border/40 p-4 rounded-xl flex flex-col sm:flex-row sm:items-center justify-between gap-3 shadow-sm">
|
|
<div>
|
|
Modell-Registry geladen für <span className="text-foreground font-bold">{data.sys_ram_gb} GB</span> System-RAM.
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<Star className="h-3.5 w-3.5 text-primary fill-primary/20" />
|
|
<span>Empfehlungen sind automatisch auf deine Box-Hardware optimiert.</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sockets/Slots Grid */}
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
{data.categories.map((cat) => {
|
|
const meta = ROLE_METADATA[cat.role] || {
|
|
title: cat.title || cat.role,
|
|
desc: "Spezifisches Modell für diese Systemrolle.",
|
|
icon: Layers
|
|
}
|
|
const IconComponent = meta.icon
|
|
|
|
// Check if a model is installed for this role
|
|
const installedModel = models.find((m) => m.role === cat.role)
|
|
|
|
// Check if an upgrade is available for this role
|
|
const hasUpgrade = updates?.model_list.find((u) => u.role === cat.role)
|
|
|
|
// Get the primary recommended model
|
|
const recommendedModel = cat.models.find((m) => m.repo === cat.recommended) || cat.models[0]
|
|
if (!recommendedModel) return null
|
|
|
|
const isInstallingRecommended = installing[recommendedModel.repo]
|
|
const alternativeModels = cat.models.filter((m) => m.repo !== cat.recommended)
|
|
const isExpanded = !!expandedAlternatives[cat.role]
|
|
|
|
return (
|
|
<div
|
|
key={cat.role}
|
|
className={cn(
|
|
"rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-5 transition-all duration-300 hover:border-primary/40",
|
|
installedModel ? "border-border/60" : "border-primary/20 shadow-primary/5"
|
|
)}
|
|
>
|
|
<div className="space-y-4">
|
|
{/* Socket Header */}
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shadow-sm shrink-0">
|
|
<IconComponent className="h-5.5 w-5.5" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-bold tracking-tight text-foreground">{meta.title}</h3>
|
|
<span className="text-[9px] font-mono text-muted-foreground uppercase bg-background/50 px-1.5 py-0.5 rounded border border-border/30 inline-block mt-0.5">
|
|
Rolle: {cat.role}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status Badges */}
|
|
{installedModel ? (
|
|
<span className="flex items-center gap-1.5 text-[9px] font-bold text-emerald-400 uppercase tracking-wider bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-1 rounded-lg">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
|
Aktiviert
|
|
</span>
|
|
) : (
|
|
<span className="text-[9px] font-bold text-primary/70 uppercase tracking-wider bg-primary/10 border border-primary/20 px-2.5 py-1 rounded-lg">
|
|
Frei
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Role Description */}
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
{meta.desc}
|
|
</p>
|
|
|
|
{/* Current vs Recommended model card */}
|
|
<div className="p-3.5 rounded-xl bg-background/35 border border-border/30 space-y-2.5">
|
|
{installedModel ? (
|
|
<div className="space-y-1.5">
|
|
<div className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/60">Aktive GGUF-Belegung</div>
|
|
<div className="text-xs font-mono font-bold text-foreground truncate" title={installedModel.name}>
|
|
{installedModel.name.split("/").pop()}
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2">
|
|
<span>Größe: {fmtBytes(installedModel.size_bytes || 0)}</span>
|
|
<span>•</span>
|
|
<span>Quant: {installedModel.quant || "GGUF"}</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
<div className="text-[9px] font-bold uppercase tracking-wider text-primary/80">Empfohlenes Modell</div>
|
|
<div className="text-xs font-mono font-bold text-foreground truncate" title={recommendedModel.name}>
|
|
{recommendedModel.name}
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2 flex-wrap">
|
|
<span>Ersteller: {recommendedModel.author}</span>
|
|
<span>•</span>
|
|
<span>Quant: {recommendedModel.quant}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 pt-0.5">
|
|
<FitBadge fit={recommendedModel.fit} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Main Action Button */}
|
|
<div className="pt-1">
|
|
{installedModel ? (
|
|
hasUpgrade ? (
|
|
<div className="space-y-2">
|
|
<div className="text-[9px] font-semibold text-amber-400 flex items-center gap-1.5">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping shrink-0" />
|
|
<span>Bessere Version in der Registry: {hasUpgrade.repo.split("/").pop()}</span>
|
|
</div>
|
|
<button
|
|
onClick={() => install(hasUpgrade.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
|
|
disabled={!!installing[hasUpgrade.repo]}
|
|
className="h-8 w-full flex items-center justify-center gap-1.5 rounded-lg bg-amber-500 text-black text-xs font-bold hover:bg-amber-400 disabled:opacity-50 transition-all cursor-pointer shadow-md shadow-amber-500/10"
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
{installing[hasUpgrade.repo] || "Auf neue Version aktualisieren"}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="h-8 flex items-center justify-center gap-1.5 text-[10px] font-bold text-emerald-400 uppercase tracking-wide bg-emerald-500/5 border border-emerald-500/10 rounded-lg select-none">
|
|
<Check className="h-4 w-4" /> Auf neuestem Stand
|
|
</div>
|
|
)
|
|
) : (
|
|
<button
|
|
onClick={() => install(recommendedModel.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
|
|
disabled={!!isInstallingRecommended}
|
|
className={cn(
|
|
"h-8 w-full flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer border",
|
|
isInstallingRecommended
|
|
? "border-primary/40 bg-primary/5 text-primary"
|
|
: "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/10"
|
|
)}
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
{isInstallingRecommended || "Optimales Modell einsetzen"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Collapsible alternatives list */}
|
|
{alternativeModels.length > 0 && (
|
|
<div className="border-t border-border/20 pt-3">
|
|
<button
|
|
onClick={() => setExpandedAlternatives((s) => ({ ...s, [cat.role]: !isExpanded }))}
|
|
className="flex items-center gap-1.5 text-[10px] text-muted-foreground/80 hover:text-foreground transition-colors cursor-pointer"
|
|
>
|
|
{isExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
|
<span>Alternative Empfehlungen anzeigen ({alternativeModels.length})</span>
|
|
</button>
|
|
|
|
{isExpanded && (
|
|
<div className="mt-2.5 space-y-2 max-h-36 overflow-y-auto pr-1 scrollbar-thin">
|
|
{alternativeModels.map((alt) => (
|
|
<div key={alt.repo} className="p-2 rounded-lg bg-background/25 border border-border/20 flex items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="text-[10px] font-mono font-bold text-foreground truncate" title={alt.name}>
|
|
{alt.name}
|
|
</div>
|
|
<div className="text-[9px] text-muted-foreground flex items-center gap-1.5 mt-0.5">
|
|
<span>Quant: {alt.quant}</span>
|
|
<span>•</span>
|
|
<span>{alt.fit.text}</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => install(alt.repo, cat.role, alt.quant || "Q4_K_M", alt.caps.tools !== "no")}
|
|
disabled={!!installing[alt.repo]}
|
|
className="h-6 px-2.5 rounded bg-background/40 hover:bg-background/80 border border-border/40 text-[9px] font-bold text-foreground transition-all cursor-pointer shrink-0 disabled:opacity-50"
|
|
>
|
|
{installing[alt.repo] || "Installieren"}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Collapsible Custom Hugging Face Downloader */}
|
|
<div className="border border-border/50 bg-card/20 rounded-2xl overflow-hidden shadow-md mt-4">
|
|
<button
|
|
onClick={() => setShowExpert(!showExpert)}
|
|
className="w-full px-5 py-4 flex items-center justify-between text-xs font-bold uppercase tracking-wider text-muted-foreground hover:bg-card/30 transition-colors cursor-pointer"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Search className="h-4 w-4 text-primary" />
|
|
<span>Eigenes Modell von Hugging Face laden (Erweiterte Ansicht)</span>
|
|
</div>
|
|
<span className="text-[10px] text-primary hover:underline">
|
|
{showExpert ? "Ausblenden ▲" : "Anzeigen ▼"}
|
|
</span>
|
|
</button>
|
|
{showExpert && (
|
|
<div className="p-5 border-t border-border/20 bg-card/10">
|
|
<AddModel />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function ModelsView() {
|
|
const [tab, setTab] = useState<"cockpit" | "discover">("cockpit")
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
|
|
Modell-Manager
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Verwalte deine Modelle und passe das Gateway-Routing live über das interaktive Cockpit an.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex rounded-xl border border-border/60 bg-card/45 backdrop-blur-md p-1 self-start">
|
|
{(["cockpit", "discover"] as const).map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setTab(t)}
|
|
className={cn(
|
|
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide uppercase transition-all cursor-pointer",
|
|
tab === t
|
|
? "bg-primary text-primary-foreground shadow-md shadow-primary/10"
|
|
: "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
>
|
|
{t === "cockpit" ? "Cockpit" : "Modelle finden"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<JobsBar />
|
|
|
|
<div className="transition-all duration-300">
|
|
{tab === "cockpit" ? <Cockpit /> : <Discover />}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|