feat: complete UI/UX Rework into Sleek Glassmorphic AI OS (June 2026)
This commit is contained in:
+330
-174
@@ -1,13 +1,15 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Download } from "lucide-react"
|
||||
import { Download, Search, Trash2, Edit3, Star, Layers, Activity, HardDrive } from "lucide-react"
|
||||
import { api, type DiscoverResp, type Fit, type Job, type ModelInfo } from "@/lib/api"
|
||||
import { CapsChips } from "@/components/CapsChips"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function fmtBytes(b?: number) {
|
||||
if (!b) return ""
|
||||
return `${(b / 1024 ** 3).toFixed(1)} GB`
|
||||
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)
|
||||
@@ -16,36 +18,69 @@ function fmtEta(s?: number) {
|
||||
|
||||
function JobsBar() {
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
|
||||
function load() {
|
||||
api<{ jobs: Job[] }>("/api/jobs")
|
||||
.then((d) => setJobs(d.jobs || []))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => api<{ jobs: Job[] }>("/api/jobs").then((d) => setJobs(d.jobs)).catch(() => {})
|
||||
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) {
|
||||
alert(`Fehler: ${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-2 rounded-xl border border-border bg-card p-3">
|
||||
<div className="text-xs font-medium text-muted-foreground">Downloads</div>
|
||||
<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">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="truncate">{j.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{j.progress ?? 0}% · {fmtBytes(j.done_bytes)}/{fmtBytes(j.total_bytes)}
|
||||
{j.eta_s ? ` · ETA ${fmtEta(j.eta_s)}` : ""}
|
||||
</span>
|
||||
<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"
|
||||
>
|
||||
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" style={{ width: `${j.progress ?? 0}%` }} />
|
||||
<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 text-xs text-muted-foreground">
|
||||
<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={j.state === "done" ? "text-emerald-500" : "text-amber-500"}>{j.state}</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>
|
||||
))}
|
||||
</div>
|
||||
@@ -57,19 +92,20 @@ function fmtSize(b: number | null) {
|
||||
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-500",
|
||||
marginal: "bg-amber-500/15 text-amber-500",
|
||||
too_tight: "bg-red-500/15 text-red-500",
|
||||
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-1.5 py-0.5 text-[11px] font-medium", tone)}>
|
||||
{fit.text} · ~{fit.req_gb} GB
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -78,99 +114,146 @@ const ROLES = ["", "fast", "heavy", "coder", "reasoning", "agent", "vision", "sc
|
||||
|
||||
function Installed() {
|
||||
const [models, setModels] = useState<ModelInfo[]>([])
|
||||
const [running, setRunning] = useState<string[]>([])
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
function load() {
|
||||
api<{ models: ModelInfo[] }>("/api/models")
|
||||
.then((d) => setModels(d.models))
|
||||
api<{ models: ModelInfo[]; running?: string[] }>("/api/models")
|
||||
.then((d) => {
|
||||
setModels(d.models || [])
|
||||
setRunning(d.running || [])
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
useEffect(load, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const t = setInterval(load, 3000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
async function setRole(name: string, role: string) {
|
||||
await api(`/api/models/${encodeURIComponent(name)}/role`, {
|
||||
method: "POST", body: JSON.stringify({ role: role || null }),
|
||||
method: "POST",
|
||||
body: JSON.stringify({ role: role || null }),
|
||||
})
|
||||
load()
|
||||
}
|
||||
|
||||
async function setCtx(name: string, cur: number | null) {
|
||||
const v = prompt("Kontextlänge (Tokens):", String(cur || 32768))
|
||||
if (!v) return
|
||||
await api(`/api/models/${encodeURIComponent(name)}/ctx`, {
|
||||
method: "POST", body: JSON.stringify({ ctx: parseInt(v, 10) }),
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ctx: parseInt(v, 10) }),
|
||||
})
|
||||
load()
|
||||
}
|
||||
|
||||
async function del(name: string) {
|
||||
if (!confirm(`Modell '${name}' aus der Config entfernen? (GGUF-Datei bleibt)`)) return
|
||||
await api(`/api/models/${encodeURIComponent(name)}`, { method: "DELETE" })
|
||||
load()
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-sm text-muted-foreground">Lade…</div>
|
||||
if (loading) return <div className="text-xs text-muted-foreground py-6 text-center">Lade installierte Modelle…</div>
|
||||
if (error)
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
|
||||
Engine nicht erreichbar oder keine Config gefunden ({error}).
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs text-muted-foreground">
|
||||
<th className="px-4 py-2 font-medium">Modell</th>
|
||||
<th className="px-4 py-2 font-medium">Rolle</th>
|
||||
<th className="px-4 py-2 font-medium">Fähigkeiten</th>
|
||||
<th className="px-4 py-2 font-medium">Kontext</th>
|
||||
<th className="px-4 py-2 font-medium">Größe</th>
|
||||
<th className="px-4 py-2 font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
||||
Keine Modelle konfiguriert.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{models.map((m) => (
|
||||
<tr key={m.name} className="border-b border-border/50 last:border-0">
|
||||
<td className="px-4 py-2.5 font-medium">{m.name}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<select
|
||||
value={m.role || ""}
|
||||
onChange={(e) => setRole(m.name, e.target.value)}
|
||||
className="rounded-md border border-border bg-background px-1.5 py-1 text-xs outline-none"
|
||||
title="Rolle/Alias setzen (so tauschst du z.B. das fast-Hirn)"
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>{r || "—"}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<CapsChips caps={m.capabilities} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<button onClick={() => setCtx(m.name, m.ctx)} className="text-muted-foreground hover:text-foreground" title="Kontext ändern">
|
||||
{fmtCtx(m.ctx)} ✎
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground">{fmtSize(m.size_bytes)}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<button onClick={() => del(m.name)} className="text-muted-foreground hover:text-red-500" title="Aus Config entfernen">
|
||||
🗑
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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">
|
||||
Keine Modelle konfiguriert. Verwende den Tab "Modelle finden" zum Herunterladen.
|
||||
</div>
|
||||
) : (
|
||||
models.map((m) => {
|
||||
const isRunning = running.includes(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.5">
|
||||
<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">
|
||||
<span className="text-[10px] 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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => del(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"
|
||||
title="Modell aus Config entfernen"
|
||||
>
|
||||
<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-2">
|
||||
<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>
|
||||
|
||||
<button
|
||||
onClick={() => setCtx(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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border/30 pt-3">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rolle</span>
|
||||
<select
|
||||
value={m.role || ""}
|
||||
onChange={(e) => setRole(m.name, e.target.value)}
|
||||
className="h-8 rounded-lg border border-border/60 bg-background/60 px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground cursor-pointer font-semibold min-w-[120px]"
|
||||
title="Weise diesem Modell eine Systemrolle zu"
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r} className="bg-popover text-foreground">{r || "Keine Rolle"}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -186,85 +269,119 @@ function AddModel() {
|
||||
async function loadQuants(r?: string) {
|
||||
const rr = r ?? repo
|
||||
if (!rr.trim()) return
|
||||
setMsg("Lade Quants…")
|
||||
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-Quants gefunden")
|
||||
setMsg(d.quants.length ? "" : "Keine GGUF-Dateien in diesem Repository gefunden.")
|
||||
} catch (e) {
|
||||
setMsg(`Fehler: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!q.trim()) return
|
||||
const d = await api<{ results: { repo: string; downloads: number }[] }>(`/api/hf/search?q=${encodeURIComponent(q)}`)
|
||||
setResults(d.results)
|
||||
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("Installiere…")
|
||||
setMsg("Download-Job wird initiiert...")
|
||||
try {
|
||||
await api("/api/models/install", {
|
||||
method: "POST", body: JSON.stringify({ repo, quant, jinja: true }),
|
||||
method: "POST",
|
||||
body: JSON.stringify({ repo, quant, jinja: true }),
|
||||
})
|
||||
setMsg(`Download gestartet: ${repo} (${quant}) — Fortschritt oben.`)
|
||||
setMsg(`Download gestartet: ${repo} (${quant}). Fortschritt wird oben angezeigt.`)
|
||||
} catch (e) {
|
||||
setMsg(`Fehler: ${e}`)
|
||||
setMsg(`Download-Fehler: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-border bg-card p-4">
|
||||
<div className="text-sm font-medium">Eigenes Modell laden (HuggingFace)</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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/Qwen3.6-35B-A3B-GGUF)"
|
||||
className="min-w-[280px] flex-1 rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||
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"
|
||||
/>
|
||||
<button onClick={() => loadQuants()} className="rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent">
|
||||
Quants laden
|
||||
</button>
|
||||
{quants.length > 0 && (
|
||||
<>
|
||||
<select value={quant} onChange={(e) => setQuant(e.target.value)} className="rounded-md border border-border bg-background px-2 py-1.5 text-sm">
|
||||
{quants.map((qq) => <option key={qq} value={qq}>{qq}</option>)}
|
||||
</select>
|
||||
<button onClick={install} className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90">
|
||||
Installieren
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<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 flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && search()}
|
||||
placeholder="HuggingFace durchsuchen (GGUF)…"
|
||||
className="min-w-[240px] flex-1 rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<button onClick={search} className="rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent">Suchen</button>
|
||||
<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 overflow-y-auto">
|
||||
<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-md px-2 py-1 text-left text-xs hover:bg-accent"
|
||||
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="truncate">{r.repo}</span>
|
||||
<span className="text-muted-foreground">↓{r.downloads.toLocaleString()}</span>
|
||||
<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-xs text-muted-foreground">{msg}</div>}
|
||||
|
||||
{msg && <div className="text-[10px] font-medium text-primary font-mono">{msg}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -283,59 +400,90 @@ function Discover() {
|
||||
}, [])
|
||||
|
||||
async function install(repo: string, role: string, quant: string, toolCapable: boolean) {
|
||||
setInstalling((s) => ({ ...s, [repo]: "…" }))
|
||||
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]: "geladen" }))
|
||||
setInstalling((s) => ({ ...s, [repo]: "Download läuft" }))
|
||||
} catch (e) {
|
||||
setInstalling((s) => ({ ...s, [repo]: `Fehler` }))
|
||||
setInstalling((s) => ({ ...s, [repo]: "Fehler" }))
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-sm text-muted-foreground">Suche aktuelle Modelle…</div>
|
||||
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-md border border-border bg-card p-3 text-sm text-muted-foreground">
|
||||
Modell-Quellen gerade nicht erreichbar ({error}).
|
||||
<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-6">
|
||||
<AddModel />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Live von HuggingFace · Hardware-Fit für ~{data.sys_ram_gb} GB · ⭐ = beste Wahl je Kategorie
|
||||
|
||||
<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>
|
||||
|
||||
{data.categories.map((cat) => (
|
||||
<div key={cat.role} className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{cat.title}</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{cat.models.map((m) => (
|
||||
<div key={m.repo} className="rounded-lg border border-border bg-card p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{cat.recommended === m.repo && <span title="beste Wahl">⭐</span>}
|
||||
<span className="truncate text-sm font-medium">{m.name}</span>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{m.fit.level !== "too_tight" && (
|
||||
<button
|
||||
onClick={() => install(m.repo, m.role, m.quant, m.caps.tools !== "no")}
|
||||
disabled={!!installing[m.repo]}
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<Download className={cn("h-3.5 w-3.5", !installing[m.repo] && "text-primary")} />
|
||||
{installing[m.repo] || "Modell laden"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{m.author}</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
<CapsChips caps={m.caps} />
|
||||
<FitBadge fit={m.fit} />
|
||||
</div>
|
||||
{m.fit.level !== "too_tight" && (
|
||||
<button
|
||||
onClick={() => install(m.repo, m.role, m.quant, m.caps.tools !== "no")}
|
||||
disabled={!!installing[m.repo]}
|
||||
className="mt-2 flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-accent disabled:opacity-60"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 text-primary" />
|
||||
{installing[m.repo] || "Installieren"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -346,32 +494,40 @@ function Discover() {
|
||||
export function ModelsView() {
|
||||
const [tab, setTab] = useState<"installed" | "discover">("installed")
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Modelle & Routing</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Installierte Modelle (mit Fähigkeiten) und aktuell beste Modelle für deine Hardware.
|
||||
</p>
|
||||
<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-Zentrale
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Verwalte installierte GGUFs, weise Systemrollen zu und lade neue Modelle von HuggingFace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex rounded-xl border border-border/60 bg-card/45 backdrop-blur-md p-1 self-start">
|
||||
{(["installed", "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 === "installed" ? "Installiert" : "Suchen & Entdecken"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JobsBar />
|
||||
|
||||
<div className="inline-flex rounded-lg border border-border bg-card p-0.5 text-sm">
|
||||
{(["installed", "discover"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 transition-colors",
|
||||
tab === t ? "bg-primary/15 text-primary" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t === "installed" ? "Installiert" : "Modelle finden"}
|
||||
</button>
|
||||
))}
|
||||
<div className="transition-all duration-300">
|
||||
{tab === "installed" ? <Installed /> : <Discover />}
|
||||
</div>
|
||||
|
||||
{tab === "installed" ? <Installed /> : <Discover />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user