Feat: Add Hermes brain change buttons and skills.sh guide source

This commit is contained in:
Hitonabi
2026-06-26 13:34:53 +02:00
parent 0c7b0b19af
commit 066feee3ea
16 changed files with 1201 additions and 504 deletions
+136 -9
View File
@@ -1,14 +1,20 @@
import { useEffect, useState, useRef, useCallback } from "react"
import { Bot, ExternalLink, Activity, Cpu, Wrench, Shield } from "lucide-react"
import { Bot, ExternalLink, Activity, Cpu, Wrench, Shield, Check, X } from "lucide-react"
import { api, type AgentStatus } from "@/lib/api"
import { cn, resolveExternalUrl } from "@/lib/utils"
import { CustomDialog } from "@/components/CustomDialog"
function Tile({ label, ok, detail, icon: Icon }: { label: string; ok: boolean; detail?: string; icon: any }) {
function Tile({ label, ok, detail, icon: Icon, onClick }: { label: string; ok: boolean; detail?: string; icon: any; onClick?: () => void }) {
return (
<div className={cn(
"rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-3 hover:border-primary/40 transition-all duration-300",
ok ? "border-border/60" : "border-amber-500/30"
)}>
<div
onClick={onClick}
className={cn(
"rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-3 hover:border-primary/40 transition-all duration-300",
ok ? "border-border/60" : "border-amber-500/30",
onClick && "cursor-pointer hover:bg-card/70"
)}
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
<Icon className={cn("h-4.5 w-4.5", ok ? "text-primary" : "text-amber-500")} />
@@ -26,6 +32,18 @@ function Tile({ label, ok, detail, icon: Icon }: { label: string; ok: boolean; d
</div>
{detail && <div className="text-[10px] font-mono text-muted-foreground truncate max-w-[200px]" title={detail}>{detail}</div>}
</div>
{onClick && (
<button
onClick={(e) => {
e.stopPropagation()
onClick()
}}
className="mt-2 flex items-center justify-center gap-1.5 w-full py-1.5 px-3 rounded-lg border border-primary/30 bg-primary/10 hover:bg-primary/20 text-primary text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer font-space"
>
<Cpu className="h-3.5 w-3.5" />
<span>Gehirn wechseln</span>
</button>
)}
</div>
)
}
@@ -36,6 +54,21 @@ export function AgentView() {
// Graph UI state
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
const [showBrainSelect, setShowBrainSelect] = useState(false)
const [availableModels, setAvailableModels] = useState<string[]>([])
// Custom Dialog State
const [dialog, setDialog] = useState<{
type: "alert" | "confirm"
title: string
message: string
onConfirm?: () => void
onCancel?: () => void
} | null>(null)
function showAlert(title: string, message: string, onConfirm?: () => void) {
setDialog({ type: "alert", title, message, onConfirm })
}
// Canvas pixel tracking for pixel-perfect connection graph without non-uniform scaling
const [dimensions, setDimensions] = useState({ width: 800, height: 360 })
@@ -70,8 +103,32 @@ export function AgentView() {
api<AgentStatus>("/api/agent/status").then(setS).catch((e) => setError(String(e)))
}
function loadModels() {
api<{ models: { name: string }[] }>("/api/models")
.then((res) => {
const names = res.models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)
setAvailableModels(["auto", "fast", "heavy", ...names])
})
.catch((e) => console.error("Error loading models", e))
}
async function changeBrainModel(model: string) {
try {
await api("/api/agent/brain", {
method: "POST",
body: JSON.stringify({ model })
})
showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`)
loadData()
setShowBrainSelect(false)
} catch (e: any) {
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
}
}
useEffect(() => {
loadData()
loadModels()
const t = setInterval(loadData, 5000)
return () => clearInterval(t)
}, [])
@@ -147,6 +204,7 @@ export function AgentView() {
ok={s.gateway_reachable}
detail={s.brain_model ? `Model: ${s.brain_model}` : "Model: auto"}
icon={Cpu}
onClick={() => setShowBrainSelect(true)}
/>
<Tile
label="Verdrahtung"
@@ -236,12 +294,13 @@ export function AgentView() {
{/* COLUMN 3: Brain & Wiring */}
<div
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",
"absolute z-20 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",
s.gateway_reachable ? "border-emerald-500/50 bg-emerald-500/5 shadow-emerald-500/5" : "border-border/60 bg-card/75"
)}
style={{ left: "85%", top: "30%" }}
onMouseEnter={() => setHoveredNode("brain")}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => setShowBrainSelect(true)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-muted-foreground">
@@ -250,8 +309,13 @@ export function AgentView() {
</div>
{s.gateway_reachable && <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 font-space" title={s.brain_model}>
{s.brain_model || "auto"}
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] font-mono font-medium truncate text-foreground font-space max-w-[100px]" title={s.brain_model}>
{s.brain_model || "auto"}
</span>
<span className="text-[8px] bg-primary/20 hover:bg-primary/30 border border-primary/30 text-primary px-1.5 py-0.5 rounded uppercase font-bold tracking-wider transition-colors">
Ändern
</span>
</div>
</div>
@@ -358,6 +422,69 @@ export function AgentView() {
)}
</div>
)}
{/* Brain Selection Modal */}
{s && showBrainSelect && (
<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 flex items-center gap-1.5">
<Cpu className="h-4 w-4" />
<span>Hermes-Gehirn konfigurieren</span>
</h3>
<button
onClick={() => setShowBrainSelect(false)}
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 leading-relaxed">
Das Gehirn" ist das primäre LLM für Hermes. Wähle ein Modell aus deiner Bibliothek oder nutze ein Gateway-Alias (<code className="text-primary font-semibold">auto</code> / <code className="text-primary font-semibold">fast</code> / <code className="text-primary font-semibold">heavy</code>):
</p>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
{availableModels.map((m) => {
const isAlias = ["auto", "fast", "heavy"].includes(m);
return (
<button
key={m}
onClick={() => changeBrainModel(m)}
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",
s.brain_model === m || (!s.brain_model && m === "auto")
? "text-primary font-bold bg-primary/10 border-primary/30"
: "text-foreground bg-background/20"
)}
>
<div className="flex flex-col text-left">
<span className="font-semibold truncate max-w-[280px]">
{m}
</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{isAlias ? "Gateway Routing Alias" : "Installiertes GGUF Modell"}
</span>
</div>
{(s.brain_model === m || (!s.brain_model && m === "auto")) && (
<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}
onConfirm={() => dialog.onConfirm && dialog.onConfirm()}
onCancel={dialog.onCancel}
/>
)}
</div>
)
}
+127 -4
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react"
import { Bot, Cpu, ExternalLink, Brain, Layers, Plus, ShieldAlert, X, Power, Shield, Download, RefreshCw } from "lucide-react"
import { Bot, Cpu, ExternalLink, Brain, Layers, Plus, ShieldAlert, X, Power, Shield, Download, RefreshCw, Check } from "lucide-react"
import { api, type SystemStatus, type AgentStatus, type ModelInfo, type Memory, type UpdatesResp, type Job } from "@/lib/api"
import { cn, resolveExternalUrl } from "@/lib/utils"
import { CustomDialog } from "@/components/CustomDialog"
function gb(b: number) {
return (b / 1024 ** 3).toFixed(1)
@@ -55,6 +57,55 @@ export function DashboardView() {
error?: string
}>({ open: false, actionPath: "", actionLabel: "" })
// Custom Dialog State
const [dialog, setDialog] = useState<{
type: "alert" | "confirm"
title: string
message: string
onConfirm: () => void
onCancel?: () => void
} | null>(null)
const [showBrainSelect, setShowBrainSelect] = useState(false)
async function changeBrainModel(model: string) {
try {
await api("/api/agent/brain", {
method: "POST",
body: JSON.stringify({ model })
})
setDialog({
type: "alert",
title: "Erfolgreich",
message: `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`,
onConfirm: () => setDialog(null)
})
loadData()
setShowBrainSelect(false)
} catch (e: any) {
setDialog({
type: "alert",
title: "Fehler",
message: `Fehler beim Wechseln des Gehirns: ${e.message}`,
onConfirm: () => setDialog(null)
})
}
}
function showConfirm(title: string, message: string, onConfirm: () => void) {
setDialog({
type: "confirm",
title,
message,
onConfirm: () => {
setDialog(null)
onConfirm()
},
onCancel: () => setDialog(null)
})
}
// Quick Memory Form State
const [memContent, setMemContent] = useState("")
const [memCat, setMemCat] = useState("stable")
@@ -385,7 +436,7 @@ export function DashboardView() {
</div>
<button
onClick={() => { if (confirm("Bist du sicher, dass du das Host-System neu starten willst?")) postAction("/api/maintenance/reboot", "Reboot") }}
onClick={() => { showConfirm("Host-System neu starten?", "Bist du sicher, dass du das Host-System neu starten willst?", () => postAction("/api/maintenance/reboot", "Reboot")) }}
disabled={loading}
className="w-full h-8 flex items-center justify-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 text-red-400 text-[10px] font-bold uppercase hover:bg-red-500/10 transition-all cursor-pointer disabled:opacity-50"
>
@@ -490,8 +541,18 @@ export function DashboardView() {
</div>
</div>
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Aktives Gehirn</div>
<div
onClick={() => setShowBrainSelect(true)}
className="p-3 bg-background/20 rounded-xl border border-border/40 hover:border-primary/45 hover:bg-background/30 transition-all duration-300 cursor-pointer group"
>
<div className="flex justify-between items-center">
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Aktives Gehirn</span>
<button
className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-primary border border-primary/20 bg-primary/10 hover:bg-primary/20 px-1.5 py-0.5 rounded transition-all cursor-pointer font-space"
>
<Cpu className="h-3 w-3" /> Ändern
</button>
</div>
<div className="text-xs font-medium mt-1 font-mono text-primary flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
{agent.brain_model ? `model: ${agent.brain_model}` : "model: auto"}
@@ -628,6 +689,68 @@ export function DashboardView() {
</div>
</div>
</div>
{agent && showBrainSelect && (
<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 flex items-center gap-1.5">
<Cpu className="h-4 w-4" />
<span>Hermes-Gehirn konfigurieren</span>
</h3>
<button
onClick={() => setShowBrainSelect(false)}
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 leading-relaxed">
Das Gehirn" ist das primäre LLM für Hermes. Wähle ein Modell aus deiner Bibliothek oder nutze ein Gateway-Alias (<code className="text-primary font-semibold">auto</code> / <code className="text-primary font-semibold">fast</code> / <code className="text-primary font-semibold">heavy</code>):
</p>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
{(["auto", "fast", "heavy", ...models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)]).map((m) => {
const isAlias = ["auto", "fast", "heavy"].includes(m);
return (
<button
key={m}
onClick={() => changeBrainModel(m)}
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",
agent.brain_model === m || (!agent.brain_model && m === "auto")
? "text-primary font-bold bg-primary/10 border-primary/30"
: "text-foreground bg-background/20"
)}
>
<div className="flex flex-col text-left">
<span className="font-semibold truncate max-w-[280px]">
{m}
</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{isAlias ? "Gateway Routing Alias" : "Installiertes GGUF Modell"}
</span>
</div>
{(agent.brain_model === m || (!agent.brain_model && m === "auto")) && (
<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}
onConfirm={() => dialog.onConfirm()}
onCancel={dialog.onCancel}
/>
)}
</div>
)
}
+36 -5
View File
@@ -388,8 +388,19 @@ export function GuideView() {
<p>
<strong>Warum in MC2?</strong> MCP trennt den AI-Kern von der Umgebung. Statt für jeden Editor eigene Tools zu schreiben, binden deine Agenten (Roo Code, Hermes) einfach MCP-Server an. Diese Server können Dateien lesen, Websuchen durchführen, Git bedienen oder mit deiner App interagieren.
</p>
<div className="p-3 bg-background/25 rounded-xl border border-border/30 font-mono text-[10px] text-foreground">
<span className="text-violet-400">Prinzip:</span> Agent MCP Gateway Lokale Tools (Dateien, Terminal, Web)
<div className="space-y-1.5 p-3 bg-background/25 rounded-xl border border-border/30 text-[10px]">
<div className="font-bold text-foreground">Gute Quellen für MCP Server:</div>
<ul className="list-disc pl-4 space-y-1 text-muted-foreground">
<li>
<a href="https://smithery.ai/" target="_blank" rel="noopener" className="text-primary hover:underline font-semibold">Smithery Registry</a> Ein Portal zum Suchen und automatischen Installieren von MCP Servern.
</li>
<li>
<a href="https://glama.ai/mcp/servers" target="_blank" rel="noopener" className="text-primary hover:underline font-semibold">Glama MCP Registry</a> Eine kuratierte, umfangreiche Community-Datenbank von MCP Servern.
</li>
<li>
<a href="https://github.com/modelcontextprotocol/servers" target="_blank" rel="noopener" className="text-primary hover:underline font-semibold">Offizielles Anthropic Repo</a> Das offizielle Repository mit Standards wie filesystem, postgres, sqlite, brave-search und puppeteer.
</li>
</ul>
</div>
</div>
</div>
@@ -418,9 +429,29 @@ description: Drive development with strict TDD practices
# Instructions
...`}
</pre>
<p>
Der Agent scannt diese Ordner automatisch und erweitert seine Systembefehle bei passenden Workflows. Gute Quellen sind dein lokales Mission Control Repository oder GitHub.
</p>
<div className="space-y-1.5 p-3 bg-background/25 rounded-xl border border-border/30 text-[10px]">
<div className="font-bold text-foreground">Wo gibt es Skills &amp; wo liegen sie?</div>
<ul className="list-disc pl-4 space-y-2.5 text-muted-foreground">
<li>
<strong>skills.sh Registry &amp; CLI:</strong> Das offizielle offene Portal für Agent-Skills (<a href="https://skills.sh/" target="_blank" rel="noopener" className="text-primary hover:underline font-semibold">skills.sh</a>). Du kannst Skills direkt über das Terminal suchen und in deinem Projekt installieren:
<div className="mt-1 font-mono text-[9px] bg-background/40 p-2 rounded border border-border/30 text-cyan-300">
# Nach Skills suchen:<br />
<span className="text-foreground">npx skills find</span><br />
# Skill zum aktuellen Projekt hinzufügen:<br />
<span className="text-foreground">npx skills add [owner/repo]</span>
</div>
</li>
<li>
<strong>Globaler Pfad:</strong> <code className="text-foreground select-all">C:\Users\TobisPC\.gemini\config\plugins\agent-skills\skills\</code>. Hier sind deine vorinstallierten, global verfügbaren Skills (wie <i>code-simplification</i>, <i>api-and-interface-design</i>, etc.) abgelegt.
</li>
<li>
<strong>Projekt-Pfad:</strong> <code className="text-foreground select-all">.agents/skills/</code>. Lege diesen Ordner im Root eines beliebigen Projekts an. Dein lokaler Editor-Agent (z.B. Roo Code) liest ihn beim Starten automatisch ein.
</li>
<li>
<strong>Vorlagen / Beispiele:</strong> Kopiere einfach bestehende Skills aus dem globalen Verzeichnis oder erstelle deine eigenen, indem du eine <code>SKILL.md</code> mit YAML-Header (name, description) anlegst.
</li>
</ul>
</div>
</div>
</div>
+64 -9
View File
@@ -2,6 +2,8 @@ import { useEffect, useState } from "react"
import { Trash2, Sparkles, User, Scroll, Shield, Tag, Clock, Search, Plus, BookOpen } from "lucide-react"
import { api, type DedupeResult, type Memory } from "@/lib/api"
import { cn } from "@/lib/utils"
import { CustomDialog } from "@/components/CustomDialog"
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
@@ -32,6 +34,41 @@ export function MemoryView() {
const [error, setError] = useState("")
const [deduping, setDeduping] = useState(false)
// Custom Dialog State
const [dialog, setDialog] = useState<{
type: "alert" | "confirm"
title: string
message: string
onConfirm: () => 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) {
setDialog({
type: "confirm",
title,
message,
onConfirm: () => {
setDialog(null)
onConfirm()
},
onCancel: () => setDialog(null)
})
}
function load() {
const params = new URLSearchParams()
if (q) params.set("q", q)
@@ -66,18 +103,26 @@ export function MemoryView() {
body: JSON.stringify({ apply: false }),
})
if (dry.duplicate_count === 0) {
alert("Keine Dubletten gefunden — alles sauber.")
showAlert("Ergebnis", "Keine Dubletten gefunden — alles sauber.")
return
}
if (confirm(`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`)) {
await api("/api/memory/dedupe", {
method: "POST",
body: JSON.stringify({ apply: true })
})
load()
}
showConfirm(
"Deduplizierung bestätigen",
`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`,
async () => {
try {
await api("/api/memory/dedupe", {
method: "POST",
body: JSON.stringify({ apply: true })
})
load()
} catch (e: any) {
showAlert("Fehler", `Fehler beim Löschen: ${e.message}`)
}
}
)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
showAlert("Fehler", `Fehler bei der Deduplizierung: ${e.message}`)
} finally {
setDeduping(false)
}
@@ -239,6 +284,16 @@ export function MemoryView() {
})
)}
</div>
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={dialog.onConfirm}
onCancel={dialog.onCancel}
/>
)}
</div>
)
}
+177 -58
View File
@@ -3,6 +3,8 @@ import { Download, Search, Trash2, Edit3, Star, Layers, Activity, HardDrive, X,
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 ""
@@ -16,8 +18,9 @@ function fmtEta(s?: number) {
return m > 0 ? `${m} min` : `${s} s`
}
function JobsBar() {
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")
@@ -36,7 +39,8 @@ function JobsBar() {
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
load()
} catch (e: any) {
alert(`Fehler: ${e.message}`)
if (onError) onError(e.message)
else setErrorMsg(e.message)
}
}
@@ -83,11 +87,20 @@ function JobsBar() {
</span>
</div>
))}
{errorMsg && (
<CustomDialog
type="alert"
title="Fehler"
message={errorMsg}
onConfirm={() => setErrorMsg(null)}
/>
)}
</div>
)
}
function fmtSize(b: number | null) {
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`
@@ -135,12 +148,68 @@ function Cockpit() {
// UI state
const [activeClient, setActiveClient] = useState<"roocode" | "cursor" | "opencode" | "zed" | "continue" | null>(null)
const [activeRoleDrop, setActiveRoleDrop] = useState<string | 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)
@@ -226,7 +295,7 @@ function Cockpit() {
await api(`/api/models/${encodeURIComponent(name)}/load`, { method: "POST" })
load()
} catch (e: any) {
alert(`Fehler beim Laden des Modells: ${e.message}`)
showAlert("Fehler", `Fehler beim Laden des Modells: ${e.message}`)
}
}
@@ -235,7 +304,7 @@ function Cockpit() {
await api(`/api/models/${encodeURIComponent(name)}/unload`, { method: "POST" })
load()
} catch (e: any) {
alert(`Fehler beim Entladen des Modells: ${e.message}`)
showAlert("Fehler", `Fehler beim Entladen des Modells: ${e.message}`)
}
}
@@ -244,45 +313,55 @@ function Cockpit() {
await api("/api/models/unload", { method: "POST" })
load()
} catch (e: any) {
alert(`Fehler beim Entladen aller Modelle: ${e.message}`)
showAlert("Fehler", `Fehler beim Entladen aller Modelle: ${e.message}`)
}
}
async function handleRoleChange(role: string, modelName: string) {
setActiveRoleDrop(null)
try {
await api(`/api/models/${encodeURIComponent(modelName)}/role`, {
method: "POST",
body: JSON.stringify({ role: role || null }),
})
load()
} catch (e) {
alert(`Fehler beim Zuweisen der Rolle: ${e}`)
} catch (e: any) {
showAlert("Fehler", `Fehler beim Zuweisen der Rolle: ${e.message || e}`)
}
}
async function handleSetCtx(name: string, cur: number | null) {
const v = prompt("Kontextlänge (Tokens):", String(cur || 32768))
if (!v) return
try {
await api(`/api/models/${encodeURIComponent(name)}/ctx`, {
method: "POST",
body: JSON.stringify({ ctx: parseInt(v, 10) }),
})
load()
} catch (e) {
alert(`Fehler beim Setzen des Kontexts: ${e}`)
}
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) {
if (!confirm(`Modell '${name}' und alle zugehörigen GGUF-Dateien unwiderruflich von der Box löschen?`)) return
try {
await api(`/api/models/${encodeURIComponent(name)}`, { method: "DELETE" })
load()
} catch (e) {
alert(`Fehler beim Löschen: ${e}`)
}
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) {
@@ -291,9 +370,9 @@ function Cockpit() {
method: "POST",
body: JSON.stringify({ repo, role, quant, jinja: toolCapable }),
})
alert(`Download für '${repo}' gestartet! Der Fortschritt wird oben angezeigt.`)
} catch (e) {
alert(`Fehler beim Starten des Upgrades: ${e}`)
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}`)
}
}
@@ -580,7 +659,7 @@ function Cockpit() {
: "border-dashed border-border/40 bg-background/20"
)}
style={{ left: "90%", top: yPositions[indexMap] }}
onClick={() => setActiveRoleDrop(activeRoleDrop === role ? null : role)}
onClick={() => setActiveRoleForAssign(role)}
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{role}</span>
@@ -589,32 +668,6 @@ function Cockpit() {
<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>
{/* Inline drop selection */}
{activeRoleDrop === role && (
<div className="absolute right-0 top-full mt-1 z-35 w-52 rounded-xl border border-border/80 bg-popover/95 backdrop-blur-md p-1.5 shadow-2xl space-y-1">
<div className="text-[9px] uppercase font-bold text-muted-foreground/60 px-2 py-1 select-none">Modell zuweisen:</div>
<button
onClick={() => handleRoleChange(role, "")}
className="w-full text-left px-2.5 py-1.5 rounded-lg text-[10px] hover:bg-accent text-red-400 font-semibold cursor-pointer"
>
Zuweisung entfernen
</button>
{models.map((m) => (
<button
key={m.name}
onClick={() => handleRoleChange(role, m.name)}
className={cn(
"w-full text-left px-2.5 py-1.5 rounded-lg text-[10px] hover:bg-accent flex items-center justify-between font-mono cursor-pointer",
m.role === role ? "text-primary font-bold" : "text-foreground"
)}
>
<span className="truncate max-w-[150px]">{m.name.split("/").pop()?.replace(".gguf", "")}</span>
{m.role === role && <Check className="h-3 w-3 shrink-0" />}
</button>
))}
</div>
)}
</div>
)
})}
@@ -1018,6 +1071,72 @@ function Cockpit() {
</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>
)
}
+35 -4
View File
@@ -2,6 +2,8 @@ import { useEffect, useState } from "react"
import { ExternalLink, RefreshCw, Save, Cpu, HardDrive, Cpu as GpuIcon, Activity } from "lucide-react"
import { api, type ServicesResp, type SystemStatus } from "@/lib/api"
import { cn, resolveExternalUrl } from "@/lib/utils"
import { CustomDialog } from "@/components/CustomDialog"
function gb(b: number) {
return (b / 1024 ** 3).toFixed(1)
@@ -43,6 +45,27 @@ export function SystemView() {
const [backupMsg, setBackupMsg] = useState("")
const [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({})
// Custom Dialog State
const [dialog, setDialog] = useState<{
type: "alert" | "confirm"
title: string
message: string
onConfirm: () => 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 load() {
api<SystemStatus>("/api/system/status").then(setS).catch((e) => setError(String(e)))
api<ServicesResp>("/api/system/services").then(setSvc).catch(() => {})
@@ -72,12 +95,12 @@ export function SystemView() {
body: JSON.stringify({ service: serviceId })
})
if (r.ok) {
alert(`Dienst ${serviceId} wurde erfolgreich neu gestartet.`)
showAlert("Erfolgreich", `Dienst ${serviceId} wurde erfolgreich neu gestartet.`)
} else {
alert(`Fehler beim Neustart: ${r.err || "Unbekannter Fehler"}`)
showAlert("Fehler beim Neustart", `Fehler beim Neustart: ${r.err || "Unbekannter Fehler"}`)
}
} catch (e: any) {
alert(`Fehler: ${e.message}`)
showAlert("Fehler", `Fehler: ${e.message}`)
} finally {
setRestartingServices(prev => ({ ...prev, [serviceId]: false }))
}
@@ -234,7 +257,15 @@ export function SystemView() {
</div>
)}
{/* Maintenance controls inside system view */}
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={dialog.onConfirm}
onCancel={dialog.onCancel}
/>
)}
</div>
)
}