feat: simplify Discover view into socket cards & backend upgrades
This commit is contained in:
+615
-165
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Download, Search, Trash2, Edit3, Star, Layers, Activity, HardDrive, X, Check, Copy } from "lucide-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"
|
||||
@@ -112,6 +112,18 @@ function FitBadge({ fit }: { fit: Fit }) {
|
||||
|
||||
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[]>([])
|
||||
@@ -122,10 +134,11 @@ function Cockpit() {
|
||||
const [error, setError] = useState("")
|
||||
|
||||
// UI state
|
||||
const [activeClient, setActiveClient] = useState<"roocode" | "cursor" | "opencode" | null>(null)
|
||||
const [activeClient, setActiveClient] = useState<"roocode" | "cursor" | "opencode" | "zed" | "continue" | null>(null)
|
||||
const [activeRoleDrop, setActiveRoleDrop] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid")
|
||||
|
||||
function load() {
|
||||
Promise.all([
|
||||
@@ -151,6 +164,33 @@ function Cockpit() {
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
async function handleLoadModel(name: string) {
|
||||
try {
|
||||
await api(`/api/models/${encodeURIComponent(name)}/load`, { method: "POST" })
|
||||
load()
|
||||
} catch (e: any) {
|
||||
alert(`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) {
|
||||
alert(`Fehler beim Entladen des Modells: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnloadAll() {
|
||||
try {
|
||||
await api("/api/models/unload", { method: "POST" })
|
||||
load()
|
||||
} catch (e: any) {
|
||||
alert(`Fehler beim Entladen aller Modelle: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRoleChange(role: string, modelName: string) {
|
||||
setActiveRoleDrop(null)
|
||||
try {
|
||||
@@ -251,9 +291,19 @@ function Cockpit() {
|
||||
<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>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">
|
||||
Llama Swap VRAM-Pool: {fmtSize(totalRunningSize)} / {fmtSize(capacity)} geladen
|
||||
</span>
|
||||
<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 */}
|
||||
@@ -318,22 +368,34 @@ function Cockpit() {
|
||||
</defs>
|
||||
|
||||
{/* Bezier Curves: Clients to Gateway (10% -> 50%) */}
|
||||
{/* Roo Code (y=18) */}
|
||||
<path d="M 10 18 C 30 18, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
||||
{/* Roo Code (y=10) */}
|
||||
<path d="M 10 10 C 30 10, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
||||
{(hoveredNode === "roocode" || activeClient === "roocode") && (
|
||||
<path d="M 10 18 C 30 18, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
|
||||
<path d="M 10 10 C 30 10, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
|
||||
)}
|
||||
|
||||
{/* Cursor (y=50) */}
|
||||
<path d="M 10 50 C 30 50, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
||||
{/* Cursor (y=30) */}
|
||||
<path d="M 10 30 C 30 30, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
||||
{(hoveredNode === "cursor" || activeClient === "cursor") && (
|
||||
<path d="M 10 30 C 30 30, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
|
||||
)}
|
||||
|
||||
{/* OpenCode (y=50) */}
|
||||
<path d="M 10 50 C 30 50, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
||||
{(hoveredNode === "opencode" || activeClient === "opencode") && (
|
||||
<path d="M 10 50 C 30 50, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
|
||||
)}
|
||||
|
||||
{/* OpenCode (y=82) */}
|
||||
<path d="M 10 82 C 30 82, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
||||
{(hoveredNode === "opencode" || activeClient === "opencode") && (
|
||||
<path d="M 10 82 C 30 82, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
|
||||
{/* Zed (y=70) */}
|
||||
<path d="M 10 70 C 30 70, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
||||
{(hoveredNode === "zed" || activeClient === "zed") && (
|
||||
<path d="M 10 70 C 30 70, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
|
||||
)}
|
||||
|
||||
{/* Continue (y=90) */}
|
||||
<path d="M 10 90 C 30 90, 30 50, 50 50" stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
|
||||
{(hoveredNode === "continue" || activeClient === "continue") && (
|
||||
<path d="M 10 90 C 30 90, 30 50, 50 50" stroke="#06b6d4" strokeWidth="2.5" fill="none" className="animate-flow-cyan" />
|
||||
)}
|
||||
|
||||
{/* Bezier Curves: Gateway to Roles (50% -> 90%) */}
|
||||
@@ -371,8 +433,8 @@ function Cockpit() {
|
||||
{/* HTML Nodes */}
|
||||
{/* COLUMN 1: Client nodes */}
|
||||
<div
|
||||
className="absolute cursor-pointer select-none z-10 w-28 h-10 -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-xs font-semibold text-foreground shadow shadow-black/20"
|
||||
style={{ left: "10%", top: "18%" }}
|
||||
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")}
|
||||
@@ -382,8 +444,8 @@ function Cockpit() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute cursor-pointer select-none z-10 w-28 h-10 -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-xs font-semibold text-foreground shadow shadow-black/20"
|
||||
style={{ left: "10%", top: "50%" }}
|
||||
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")}
|
||||
@@ -393,8 +455,8 @@ function Cockpit() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute cursor-pointer select-none z-10 w-28 h-10 -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-xs font-semibold text-foreground shadow shadow-black/20"
|
||||
style={{ left: "10%", top: "82%" }}
|
||||
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")}
|
||||
@@ -403,9 +465,31 @@ function Cockpit() {
|
||||
<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 select-none"
|
||||
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>
|
||||
@@ -484,7 +568,11 @@ function Cockpit() {
|
||||
className="absolute z-30 md:w-96 w-[90%] rounded-2xl border border-border/80 bg-card/95 backdrop-blur-xl p-4 shadow-2xl space-y-3 flex flex-col justify-between"
|
||||
style={{
|
||||
left: "22%",
|
||||
top: activeClient === "roocode" ? "8%" : activeClient === "cursor" ? "30%" : "48%",
|
||||
top: activeClient === "roocode" ? "5%"
|
||||
: activeClient === "cursor" ? "20%"
|
||||
: activeClient === "opencode" ? "40%"
|
||||
: activeClient === "zed" ? "55%"
|
||||
: "65%"
|
||||
}}
|
||||
>
|
||||
{/* Popover Header */}
|
||||
@@ -493,6 +581,8 @@ function Cockpit() {
|
||||
{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)}
|
||||
@@ -521,7 +611,19 @@ function Cockpit() {
|
||||
{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>providers</code> mit dem Snippet unten.</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>
|
||||
@@ -533,9 +635,11 @@ function Cockpit() {
|
||||
<span className="text-[8px] font-mono text-muted-foreground">JSON Config</span>
|
||||
<button
|
||||
onClick={() => copySnippet(
|
||||
activeClient === "roocode" ? connectData.tools.cline.snippet :
|
||||
activeClient === "cursor" ? connectData.tools.cursor.snippet :
|
||||
connectData.tools.opencode.snippet
|
||||
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-[9px] font-semibold text-primary hover:text-primary-foreground flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
@@ -545,9 +649,11 @@ function Cockpit() {
|
||||
</div>
|
||||
<pre className="p-3 max-h-36 overflow-y-auto text-[9px] 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 === "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>
|
||||
@@ -555,109 +661,266 @@ function Cockpit() {
|
||||
</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 C: Library list cards & Upgrade Radar */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground px-1">Installierte Modell-Bibliothek</div>
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Installierte Modell-Bibliothek</div>
|
||||
<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 className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{models.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">
|
||||
Keine Modelle konfiguriert. Verwende den Tab "Modelle finden" zum Herunterladen.
|
||||
</div>
|
||||
) : (
|
||||
models.map((m) => {
|
||||
const isRunning = running.includes(m.name)
|
||||
const hasUpgrade = updates?.model_list.find((u) => u.role === m.role)
|
||||
{viewMode === "grid" ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{models.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">
|
||||
Keine Modelle konfiguriert. Verwende den Tab "Modelle finden" zum Herunterladen.
|
||||
</div>
|
||||
) : (
|
||||
models.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",
|
||||
isRunning ? "border-primary/45 shadow-primary/5" : "border-border/60"
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-xs font-bold tracking-tight text-foreground line-clamp-2 break-all" title={m.name}>
|
||||
{m.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<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>
|
||||
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",
|
||||
isRunning ? "border-primary/45 shadow-primary/5" : "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>
|
||||
)}
|
||||
</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">
|
||||
{models.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center select-none">
|
||||
Keine Modelle konfiguriert. Verwende den Tab "Modelle finden" zum Herunterladen.
|
||||
</div>
|
||||
) : (
|
||||
models.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" : "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-[9px] font-mono text-primary font-bold uppercase">
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
<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 opacity-0 group-hover:opacity-100 shrink-0 cursor-pointer"
|
||||
title="Modell und GGUF-Dateien löschen"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<button
|
||||
onClick={() => handleSetCtx(m.name, m.ctx)}
|
||||
className="flex items-center gap-1.5 p-2 bg-background/20 rounded-lg border border-border/20 hover:border-primary/40 text-left transition-colors cursor-pointer"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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"
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<Download className="h-3 w-3" /> Smart-Swap starten
|
||||
{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>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -791,15 +1054,55 @@ function AddModel() {
|
||||
)
|
||||
}
|
||||
|
||||
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(() => {
|
||||
api<DiscoverResp>("/api/discover")
|
||||
.then(setData)
|
||||
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))
|
||||
}, [])
|
||||
@@ -826,72 +1129,219 @@ function Discover() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AddModel />
|
||||
|
||||
<div className="text-[10px] font-mono text-muted-foreground bg-background/25 border border-border/40 p-3 rounded-xl">
|
||||
Modell-Registry geladen für {data.sys_ram_gb} GB System-RAM • ⭐ markiert die empfohlene Standard-Rolle.
|
||||
<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>
|
||||
|
||||
{data.categories.map((cat) => (
|
||||
<div key={cat.role} className="space-y-3">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Layers className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">{cat.title}</h3>
|
||||
</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
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{cat.models.map((m) => {
|
||||
const isRec = cat.recommended === m.repo
|
||||
return (
|
||||
<div
|
||||
key={m.repo}
|
||||
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",
|
||||
isRec ? "border-primary/30" : "border-border/60"
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRec && <span title="Empfohlen für diese Rolle"><Star className="h-3.5 w-3.5 text-amber-400 fill-amber-400" /></span>}
|
||||
<h4 className="text-xs font-bold text-foreground truncate max-w-[200px]" title={m.name}>
|
||||
{m.name}
|
||||
</h4>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{m.author}</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono bg-background/40 px-1.5 py-0.5 rounded border border-border/30 text-muted-foreground">
|
||||
{m.quant}
|
||||
</div>
|
||||
// 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 className="flex flex-wrap items-center gap-1.5 border-t border-border/30 pt-3">
|
||||
<CapsChips caps={m.caps} />
|
||||
<FitBadge fit={m.fit} />
|
||||
<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>
|
||||
|
||||
{m.fit.level !== "too_tight" && (
|
||||
{/* 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(m.repo, m.role, m.quant, m.caps.tools !== "no")}
|
||||
disabled={!!installing[m.repo]}
|
||||
onClick={() => install(recommendedModel.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
|
||||
disabled={!!isInstallingRecommended}
|
||||
className={cn(
|
||||
"mt-2 h-8 w-full flex items-center justify-center gap-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer border border-border/60 bg-background/20 hover:border-primary/50 disabled:opacity-50",
|
||||
installing[m.repo] && "border-primary/40 bg-primary/5 text-primary"
|
||||
"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={cn("h-3.5 w-3.5", !installing[m.repo] && "text-primary")} />
|
||||
{installing[m.repo] || "Modell laden"}
|
||||
<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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user