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
+12 -1
View File
@@ -1,12 +1,23 @@
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht).""" """Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel
from services.agent import agent_status from services.agent import agent_status, update_brain_model
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
class BrainReq(BaseModel):
model: str
@router.get("/agent/status") @router.get("/agent/status")
def status() -> dict: def status() -> dict:
return agent_status() return agent_status()
@router.post("/agent/brain")
def set_brain_model(body: BrainReq) -> dict:
ok = update_brain_model(body.model)
return {"ok": ok}
+44
View File
@@ -46,3 +46,47 @@ def agent_status() -> dict:
"has_skills": (home / "skills").exists(), "has_skills": (home / "skills").exists(),
"has_memories": (home / "memories").exists(), "has_memories": (home / "memories").exists(),
} }
def update_brain_model(new_model: str) -> bool:
from config import HERMES_HOME
home = HERMES_HOME
config_path = home / "config.yaml"
# Ensure home directory exists
home.mkdir(parents=True, exist_ok=True)
cfg = {}
if config_path.exists():
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
except Exception:
cfg = {}
if not isinstance(cfg, dict):
cfg = {}
if "model" not in cfg or not isinstance(cfg["model"], dict):
cfg["model"] = {}
cfg["model"]["model"] = new_model
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("w", encoding="utf-8") as f:
r_yaml.dump(cfg, f)
# Restart the user-space service to apply changes
try:
import services.maintenance as maintenance
maintenance.restart_service("hermes-gateway")
except Exception:
pass
return True
except Exception:
return False
+14 -3
View File
@@ -70,9 +70,20 @@ def model_upgrades() -> list[dict]:
cmds = " ".join(str(s.get("cmd", "")).lower() cmds = " ".join(str(s.get("cmd", "")).lower()
for s in (llamaswap.read_config().get("models") or {}).values()) for s in (llamaswap.read_config().get("models") or {}).values())
out = [] out = []
ROLE_MAP = {
"reasoning": "heavy",
"agent": "fast",
"scout": "fast",
"coder": "coder",
"vision": "vision"
}
for c in disc.get("categories", []): for c in disc.get("categories", []):
role = c["role"] disc_role = c["role"]
if role not in active_roles: mapped_role = ROLE_MAP.get(disc_role, disc_role)
if mapped_role not in active_roles:
continue continue
rec = c.get("recommended") rec = c.get("recommended")
@@ -82,7 +93,7 @@ def model_upgrades() -> list[dict]:
stem = base[:-5] if base.endswith("-gguf") else base stem = base[:-5] if base.endswith("-gguf") else base
if base in cmds or (stem and stem in cmds): if base in cmds or (stem and stem in cmds):
continue continue
out.append({"role": role, "title": c["title"], "repo": rec}) out.append({"role": mapped_role, "title": c["title"], "repo": rec})
return out return out
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-oiIMDlga.js"></script> <script type="module" crossorigin src="/assets/index-rbtAsh2N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-WcZJ9RJs.css"> <link rel="stylesheet" crossorigin href="/assets/index-DJE5r1SD.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+67
View File
@@ -0,0 +1,67 @@
import { X } from "lucide-react"
export interface CustomDialogProps {
type: "alert" | "confirm" | "prompt"
title: string
message: string
defaultValue?: string
onConfirm: (val?: string) => void
onCancel?: () => void
}
export function CustomDialog({ type, title, message, defaultValue, onConfirm, onCancel }: CustomDialogProps) {
return (
<div className="fixed inset-0 z-[99] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-sm 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">{title}</h3>
<button
onClick={onCancel || (() => onConfirm())}
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">{message}</p>
{type === "prompt" && (
<input
type="text"
id="custom-dialog-input"
defaultValue={defaultValue}
className="w-full h-9 px-3 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") {
const val = (document.getElementById("custom-dialog-input") as HTMLInputElement)?.value
onConfirm(val)
}
}}
/>
)}
<div className="flex justify-end gap-3 pt-2">
{(type === "confirm" || type === "prompt") && (
<button
onClick={onCancel}
className="h-8 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
>
Abbrechen
</button>
)}
<button
onClick={() => {
const val = type === "prompt"
? (document.getElementById("custom-dialog-input") as HTMLInputElement)?.value
: undefined
onConfirm(val)
}}
className="h-8 px-4 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/95 rounded-lg transition-colors cursor-pointer"
>
{type === "confirm" ? "Ja, fortfahren" : type === "prompt" ? "Übernehmen" : "OK"}
</button>
</div>
</div>
</div>
)
}
+94 -16
View File
@@ -2,6 +2,8 @@ import { useEffect, useState, useRef } from "react"
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, Download, AlertTriangle } from "lucide-react" import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, Download, AlertTriangle } from "lucide-react"
import { api, type Job, type UpdatesResp } from "@/lib/api" import { api, type Job, type UpdatesResp } from "@/lib/api"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CustomDialog } from "./CustomDialog"
interface SystemDrawerProps { interface SystemDrawerProps {
open: boolean open: boolean
@@ -34,6 +36,51 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
const [activeTab, setActiveTab] = useState<"maintenance" | "logs" | "settings">("maintenance") const [activeTab, setActiveTab] = useState<"maintenance" | "logs" | "settings">("maintenance")
const [checkingUpdates, setCheckingUpdates] = useState(false) const [checkingUpdates, setCheckingUpdates] = 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 formatLastCheck(ts?: number | null) {
if (!ts) return "Nie"
return new Date(ts * 1000).toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
})
}
// Credentials State // Credentials State
const [sudoPasswordInput, setSudoPasswordInput] = useState("") const [sudoPasswordInput, setSudoPasswordInput] = useState("")
const [hfTokenInput, setHfTokenInput] = useState("") const [hfTokenInput, setHfTokenInput] = useState("")
@@ -117,7 +164,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
loadJobs() loadJobs()
setActiveTab("maintenance") setActiveTab("maintenance")
} catch (e: any) { } catch (e: any) {
alert(`Fehler beim Starten des OS-Updates: ${e.message}`) showAlert("Fehler", `Fehler beim Starten des OS-Updates: ${e.message}`)
} }
} }
@@ -127,7 +174,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
loadJobs() loadJobs()
setActiveTab("maintenance") setActiveTab("maintenance")
} catch (e: any) { } catch (e: any) {
alert(`Fehler: ${e.message}`) showAlert("Fehler", `Fehler beim Engine-Update: ${e.message}`)
} }
} }
@@ -138,7 +185,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
loadJobs() loadJobs()
setActiveTab("maintenance") setActiveTab("maintenance")
} catch (e: any) { } catch (e: any) {
alert(`Fehler bei der Update-Suche: ${e.message}`) showAlert("Fehler", `Fehler bei der Update-Suche: ${e.message}`)
} finally { } finally {
setCheckingUpdates(false) setCheckingUpdates(false)
} }
@@ -150,24 +197,30 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
method: "POST", method: "POST",
body: JSON.stringify({ repo, role }) body: JSON.stringify({ repo, role })
}) })
alert(`Modell-Upgrade für '${role}' (${repo}) gestartet.`) showAlert("Gestartet", `Modell-Upgrade für '${role}' (${repo}) gestartet.`)
loadJobs() loadJobs()
setActiveTab("maintenance") setActiveTab("maintenance")
} catch (e: any) { } catch (e: any) {
alert(`Fehler beim Starten des Modell-Upgrades: ${e.message}`) showAlert("Fehler", `Fehler beim Starten des Modell-Upgrades: ${e.message}`)
} }
} }
async function triggerReboot() { async function triggerReboot() {
if (!confirm("Bist du sicher, dass du das gesamte Host-System neu starten willst?")) return showConfirm(
"Reboot bestätigen",
"Bist du sicher, dass du das gesamte Host-System neu starten willst?",
async () => {
try { try {
await api("/api/maintenance/reboot", { method: "POST" }) await api("/api/maintenance/reboot", { method: "POST" })
alert("Reboot ausgelöst. System startet neu...") showAlert("Reboot", "Reboot ausgelöst. System startet neu...", () => {
onClose() onClose()
})
} catch (e: any) { } catch (e: any) {
alert(`Fehler: ${e.message}`) showAlert("Fehler", `Fehler beim Reboot: ${e.message}`)
} }
} }
)
}
async function restartService(serviceId: string) { async function restartService(serviceId: string) {
setRestartingServices(prev => ({ ...prev, [serviceId]: true })) setRestartingServices(prev => ({ ...prev, [serviceId]: true }))
@@ -177,15 +230,16 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
body: JSON.stringify({ service: serviceId }) body: JSON.stringify({ service: serviceId })
}) })
if (res.ok) { if (res.ok) {
alert(`Dienst ${serviceId} wurde erfolgreich neu gestartet.`) showAlert("Dienst neu gestartet", `Dienst ${serviceId} wurde erfolgreich neu gestartet.`, () => {
if (activeTab === "logs" && selectedService === serviceId) { if (activeTab === "logs" && selectedService === serviceId) {
loadServiceLogs(serviceId) loadServiceLogs(serviceId)
} }
})
} else { } else {
alert(`Fehler beim Neustart: ${res.err || "Unbekannter Fehler"}`) showAlert("Fehler", `Fehler beim Neustart: ${res.err || "Unbekannter Fehler"}`)
} }
} catch (e: any) { } catch (e: any) {
alert(`Fehler: ${e.message}`) showAlert("Fehler", `Fehler beim Neustart: ${e.message}`)
} finally { } finally {
setRestartingServices(prev => ({ ...prev, [serviceId]: false })) setRestartingServices(prev => ({ ...prev, [serviceId]: false }))
} }
@@ -196,7 +250,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" }) await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
loadJobs() loadJobs()
} catch (e: any) { } catch (e: any) {
alert(`Fehler beim Abbrechen: ${e.message}`) showAlert("Fehler", `Fehler beim Abbrechen: ${e.message}`)
} }
} }
@@ -277,6 +331,12 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Wartungsaktionen</h3> <h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Wartungsaktionen</h3>
<div className="flex items-center gap-2">
{updates?.last_check && (
<span className="text-[9px] text-muted-foreground">
Zuletzt gesucht: {formatLastCheck(updates.last_check)}
</span>
)}
<button <button
onClick={triggerCheckUpdates} onClick={triggerCheckUpdates}
disabled={checkingUpdates} disabled={checkingUpdates}
@@ -286,6 +346,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
Nach Updates suchen Nach Updates suchen
</button> </button>
</div> </div>
</div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<button <button
@@ -326,7 +387,14 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
{/* Modell Upgrades */} {/* Modell Upgrades */}
{updates?.model_list && updates.model_list.length > 0 && ( {updates?.model_list && updates.model_list.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Verfügbare Modell-Upgrades</h3> <h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Verfügbare Modell-Upgrades</h3>
{updates?.last_check && (
<span className="text-[9px] text-muted-foreground">
Gesucht: {formatLastCheck(updates.last_check)}
</span>
)}
</div>
<div className="space-y-2"> <div className="space-y-2">
{updates.model_list.map((m) => ( {updates.model_list.map((m) => (
<div key={m.role} className="p-3 rounded-xl border border-border/60 bg-background/20 flex flex-col gap-2"> <div key={m.role} className="p-3 rounded-xl border border-border/60 bg-background/20 flex flex-col gap-2">
@@ -588,9 +656,9 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
onClick={() => { onClick={() => {
localStorage.setItem("mc_sudo_password", sudoPasswordInput); localStorage.setItem("mc_sudo_password", sudoPasswordInput);
localStorage.setItem("mc_hf_token", hfTokenInput); localStorage.setItem("mc_hf_token", hfTokenInput);
alert("Einstellungen erfolgreich lokal gespeichert."); showAlert("Erfolgreich", "Einstellungen erfolgreich lokal gespeichert.");
}} }}
className="flex-1 h-9 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors" className="flex-1 h-9 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors shadow shadow-primary/10 cursor-pointer"
> >
Speichern Speichern
</button> </button>
@@ -600,9 +668,9 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
setHfTokenInput(""); setHfTokenInput("");
localStorage.removeItem("mc_sudo_password"); localStorage.removeItem("mc_sudo_password");
localStorage.removeItem("mc_hf_token"); localStorage.removeItem("mc_hf_token");
alert("Zugangsdaten gelöscht."); showAlert("Gelöscht", "Zugangsdaten gelöscht.");
}} }}
className="h-9 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors" className="h-9 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
> >
Zurücksetzen Zurücksetzen
</button> </button>
@@ -611,6 +679,16 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
)} )}
</div> </div>
</div> </div>
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={dialog.onConfirm}
onCancel={dialog.onCancel}
/>
)}
</> </>
) )
} }
+134 -7
View File
@@ -1,14 +1,20 @@
import { useEffect, useState, useRef, useCallback } from "react" 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 { api, type AgentStatus } from "@/lib/api"
import { cn, resolveExternalUrl } from "@/lib/utils" 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 ( return (
<div className={cn( <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", "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" ok ? "border-border/60" : "border-amber-500/30",
)}> onClick && "cursor-pointer hover:bg-card/70"
)}
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span> <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")} /> <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> </div>
{detail && <div className="text-[10px] font-mono text-muted-foreground truncate max-w-[200px]" title={detail}>{detail}</div>} {detail && <div className="text-[10px] font-mono text-muted-foreground truncate max-w-[200px]" title={detail}>{detail}</div>}
</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> </div>
) )
} }
@@ -36,6 +54,21 @@ export function AgentView() {
// Graph UI state // Graph UI state
const [hoveredNode, setHoveredNode] = useState<string | null>(null) 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 // Canvas pixel tracking for pixel-perfect connection graph without non-uniform scaling
const [dimensions, setDimensions] = useState({ width: 800, height: 360 }) 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))) 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(() => { useEffect(() => {
loadData() loadData()
loadModels()
const t = setInterval(loadData, 5000) const t = setInterval(loadData, 5000)
return () => clearInterval(t) return () => clearInterval(t)
}, []) }, [])
@@ -147,6 +204,7 @@ export function AgentView() {
ok={s.gateway_reachable} ok={s.gateway_reachable}
detail={s.brain_model ? `Model: ${s.brain_model}` : "Model: auto"} detail={s.brain_model ? `Model: ${s.brain_model}` : "Model: auto"}
icon={Cpu} icon={Cpu}
onClick={() => setShowBrainSelect(true)}
/> />
<Tile <Tile
label="Verdrahtung" label="Verdrahtung"
@@ -236,12 +294,13 @@ export function AgentView() {
{/* COLUMN 3: Brain & Wiring */} {/* COLUMN 3: Brain & Wiring */}
<div <div
className={cn( 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" 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%" }} style={{ left: "85%", top: "30%" }}
onMouseEnter={() => setHoveredNode("brain")} onMouseEnter={() => setHoveredNode("brain")}
onMouseLeave={() => setHoveredNode(null)} onMouseLeave={() => setHoveredNode(null)}
onClick={() => setShowBrainSelect(true)}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-muted-foreground"> <div className="flex items-center gap-1 text-muted-foreground">
@@ -250,8 +309,13 @@ export function AgentView() {
</div> </div>
{s.gateway_reachable && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />} {s.gateway_reachable && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />}
</div> </div>
<div className="text-[10px] font-mono font-medium truncate mt-0.5 text-foreground font-space" title={s.brain_model}> <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"} {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>
</div> </div>
@@ -358,6 +422,69 @@ export function AgentView() {
)} )}
</div> </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> </div>
) )
} }
+127 -4
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react" 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 { api, type SystemStatus, type AgentStatus, type ModelInfo, type Memory, type UpdatesResp, type Job } from "@/lib/api"
import { cn, resolveExternalUrl } from "@/lib/utils" import { cn, resolveExternalUrl } from "@/lib/utils"
import { CustomDialog } from "@/components/CustomDialog"
function gb(b: number) { function gb(b: number) {
return (b / 1024 ** 3).toFixed(1) return (b / 1024 ** 3).toFixed(1)
@@ -55,6 +57,55 @@ export function DashboardView() {
error?: string error?: string
}>({ open: false, actionPath: "", actionLabel: "" }) }>({ 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 // Quick Memory Form State
const [memContent, setMemContent] = useState("") const [memContent, setMemContent] = useState("")
const [memCat, setMemCat] = useState("stable") const [memCat, setMemCat] = useState("stable")
@@ -385,7 +436,7 @@ export function DashboardView() {
</div> </div>
<button <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} 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" 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> </div>
<div className="p-3 bg-background/20 rounded-xl border border-border/40"> <div
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Aktives Gehirn</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"> <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" /> <Layers className="h-3.5 w-3.5" />
{agent.brain_model ? `model: ${agent.brain_model}` : "model: auto"} {agent.brain_model ? `model: ${agent.brain_model}` : "model: auto"}
@@ -628,6 +689,68 @@ export function DashboardView() {
</div> </div>
</div> </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> </div>
) )
} }
+36 -5
View File
@@ -388,8 +388,19 @@ export function GuideView() {
<p> <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. <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> </p>
<div className="p-3 bg-background/25 rounded-xl border border-border/30 font-mono text-[10px] text-foreground"> <div className="space-y-1.5 p-3 bg-background/25 rounded-xl border border-border/30 text-[10px]">
<span className="text-violet-400">Prinzip:</span> Agent MCP Gateway Lokale Tools (Dateien, Terminal, Web) <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> </div>
</div> </div>
@@ -418,9 +429,29 @@ description: Drive development with strict TDD practices
# Instructions # Instructions
...`} ...`}
</pre> </pre>
<p> <div className="space-y-1.5 p-3 bg-background/25 rounded-xl border border-border/30 text-[10px]">
Der Agent scannt diese Ordner automatisch und erweitert seine Systembefehle bei passenden Workflows. Gute Quellen sind dein lokales Mission Control Repository oder GitHub. <div className="font-bold text-foreground">Wo gibt es Skills &amp; wo liegen sie?</div>
</p> <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>
</div> </div>
+59 -4
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 { Trash2, Sparkles, User, Scroll, Shield, Tag, Clock, Search, Plus, BookOpen } from "lucide-react"
import { api, type DedupeResult, type Memory } from "@/lib/api" import { api, type DedupeResult, type Memory } from "@/lib/api"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CustomDialog } from "@/components/CustomDialog"
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"] const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
@@ -32,6 +34,41 @@ export function MemoryView() {
const [error, setError] = useState("") const [error, setError] = useState("")
const [deduping, setDeduping] = useState(false) 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() { function load() {
const params = new URLSearchParams() const params = new URLSearchParams()
if (q) params.set("q", q) if (q) params.set("q", q)
@@ -66,18 +103,26 @@ export function MemoryView() {
body: JSON.stringify({ apply: false }), body: JSON.stringify({ apply: false }),
}) })
if (dry.duplicate_count === 0) { if (dry.duplicate_count === 0) {
alert("Keine Dubletten gefunden — alles sauber.") showAlert("Ergebnis", "Keine Dubletten gefunden — alles sauber.")
return return
} }
if (confirm(`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`)) { showConfirm(
"Deduplizierung bestätigen",
`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`,
async () => {
try {
await api("/api/memory/dedupe", { await api("/api/memory/dedupe", {
method: "POST", method: "POST",
body: JSON.stringify({ apply: true }) body: JSON.stringify({ apply: true })
}) })
load() load()
}
} catch (e: any) { } catch (e: any) {
alert(`Fehler: ${e.message}`) showAlert("Fehler", `Fehler beim Löschen: ${e.message}`)
}
}
)
} catch (e: any) {
showAlert("Fehler", `Fehler bei der Deduplizierung: ${e.message}`)
} finally { } finally {
setDeduping(false) setDeduping(false)
} }
@@ -239,6 +284,16 @@ export function MemoryView() {
}) })
)} )}
</div> </div>
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={dialog.onConfirm}
onCancel={dialog.onCancel}
/>
)}
</div> </div>
) )
} }
+165 -46
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 { api, type DiscoverResp, type Fit, type Job, type ModelInfo, type RoutingResp, type UpdatesResp, type ConnectResp } from "@/lib/api"
import { CapsChips } from "@/components/CapsChips" import { CapsChips } from "@/components/CapsChips"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CustomDialog } from "@/components/CustomDialog"
function fmtBytes(b?: number) { function fmtBytes(b?: number) {
if (!b) return "" if (!b) return ""
@@ -16,8 +18,9 @@ function fmtEta(s?: number) {
return m > 0 ? `${m} min` : `${s} s` return m > 0 ? `${m} min` : `${s} s`
} }
function JobsBar() { function JobsBar({ onError }: { onError?: (msg: string) => void }) {
const [jobs, setJobs] = useState<Job[]>([]) const [jobs, setJobs] = useState<Job[]>([])
const [errorMsg, setErrorMsg] = useState<string | null>(null)
function load() { function load() {
api<{ jobs: Job[] }>("/api/jobs") api<{ jobs: Job[] }>("/api/jobs")
@@ -36,7 +39,8 @@ function JobsBar() {
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" }) await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
load() load()
} catch (e: any) { } catch (e: any) {
alert(`Fehler: ${e.message}`) if (onError) onError(e.message)
else setErrorMsg(e.message)
} }
} }
@@ -83,11 +87,20 @@ function JobsBar() {
</span> </span>
</div> </div>
))} ))}
{errorMsg && (
<CustomDialog
type="alert"
title="Fehler"
message={errorMsg}
onConfirm={() => setErrorMsg(null)}
/>
)}
</div> </div>
) )
} }
function fmtSize(b: number | null) { function fmtSize(b?: number | null) {
if (!b) return "—" if (!b) return "—"
const gb = b / 1024 ** 3 const gb = b / 1024 ** 3
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB` return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB`
@@ -135,12 +148,68 @@ function Cockpit() {
// UI state // UI state
const [activeClient, setActiveClient] = useState<"roocode" | "cursor" | "opencode" | "zed" | "continue" | null>(null) 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 [copied, setCopied] = useState(false)
const [hoveredNode, setHoveredNode] = useState<string | null>(null) const [hoveredNode, setHoveredNode] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<"grid" | "list">("grid") const [viewMode, setViewMode] = useState<"grid" | "list">("grid")
const [filterMode, setFilterMode] = useState<"all" | "in_use">("all") 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) => { const filteredModels = models.filter((m) => {
if (filterMode === "in_use") { if (filterMode === "in_use") {
return !!m.role || running.includes(m.name) return !!m.role || running.includes(m.name)
@@ -226,7 +295,7 @@ function Cockpit() {
await api(`/api/models/${encodeURIComponent(name)}/load`, { method: "POST" }) await api(`/api/models/${encodeURIComponent(name)}/load`, { method: "POST" })
load() load()
} catch (e: any) { } 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" }) await api(`/api/models/${encodeURIComponent(name)}/unload`, { method: "POST" })
load() load()
} catch (e: any) { } catch (e: any) {
alert(`Fehler beim Entladen des Modells: ${e.message}`) showAlert("Fehler", `Fehler beim Entladen des Modells: ${e.message}`)
} }
} }
@@ -244,25 +313,28 @@ function Cockpit() {
await api("/api/models/unload", { method: "POST" }) await api("/api/models/unload", { method: "POST" })
load() load()
} catch (e: any) { } 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) { async function handleRoleChange(role: string, modelName: string) {
setActiveRoleDrop(null)
try { try {
await api(`/api/models/${encodeURIComponent(modelName)}/role`, { await api(`/api/models/${encodeURIComponent(modelName)}/role`, {
method: "POST", method: "POST",
body: JSON.stringify({ role: role || null }), body: JSON.stringify({ role: role || null }),
}) })
load() load()
} catch (e) { } catch (e: any) {
alert(`Fehler beim Zuweisen der Rolle: ${e}`) showAlert("Fehler", `Fehler beim Zuweisen der Rolle: ${e.message || e}`)
} }
} }
async function handleSetCtx(name: string, cur: number | null) { async function handleSetCtx(name: string, cur: number | null) {
const v = prompt("Kontextlänge (Tokens):", String(cur || 32768)) showPrompt(
"Kontextlänge anpassen",
"Gib die gewünschte Kontextlänge in Tokens an:",
String(cur || 32768),
async (v) => {
if (!v) return if (!v) return
try { try {
await api(`/api/models/${encodeURIComponent(name)}/ctx`, { await api(`/api/models/${encodeURIComponent(name)}/ctx`, {
@@ -270,20 +342,27 @@ function Cockpit() {
body: JSON.stringify({ ctx: parseInt(v, 10) }), body: JSON.stringify({ ctx: parseInt(v, 10) }),
}) })
load() load()
} catch (e) { } catch (e: any) {
alert(`Fehler beim Setzen des Kontexts: ${e}`) showAlert("Fehler", `Fehler beim Setzen des Kontexts: ${e.message || e}`)
} }
} }
)
}
async function handleDelete(name: string) { async function handleDelete(name: string) {
if (!confirm(`Modell '${name}' und alle zugehörigen GGUF-Dateien unwiderruflich von der Box löschen?`)) return showConfirm(
"Modell löschen?",
`Modell '${name}' und alle zugehörigen GGUF-Dateien unwiderruflich von der Box löschen?`,
async () => {
try { try {
await api(`/api/models/${encodeURIComponent(name)}`, { method: "DELETE" }) await api(`/api/models/${encodeURIComponent(name)}`, { method: "DELETE" })
load() load()
} catch (e) { } catch (e: any) {
alert(`Fehler beim Löschen: ${e}`) showAlert("Fehler", `Fehler beim Löschen: ${e.message || e}`)
} }
} }
)
}
async function handleSmartUpgrade(repo: string, role: string, quant: string, toolCapable: boolean) { async function handleSmartUpgrade(repo: string, role: string, quant: string, toolCapable: boolean) {
try { try {
@@ -291,9 +370,9 @@ function Cockpit() {
method: "POST", method: "POST",
body: JSON.stringify({ repo, role, quant, jinja: toolCapable }), body: JSON.stringify({ repo, role, quant, jinja: toolCapable }),
}) })
alert(`Download für '${repo}' gestartet! Der Fortschritt wird oben angezeigt.`) showAlert("Herunterladen gestartet", `Download für '${repo}' gestartet! Der Fortschritt wird oben angezeigt.`)
} catch (e) { } catch (e: any) {
alert(`Fehler beim Starten des Upgrades: ${e}`) showAlert("Fehler", `Fehler beim Starten des Upgrades: ${e.message || e}`)
} }
} }
@@ -580,7 +659,7 @@ function Cockpit() {
: "border-dashed border-border/40 bg-background/20" : "border-dashed border-border/40 bg-background/20"
)} )}
style={{ left: "90%", top: yPositions[indexMap] }} style={{ left: "90%", top: yPositions[indexMap] }}
onClick={() => setActiveRoleDrop(activeRoleDrop === role ? null : role)} onClick={() => setActiveRoleForAssign(role)}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{role}</span> <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]"> <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"} {activeModel ? activeModel.name.split("/").pop()?.replace(".gguf", "") : "Keine Zuweisung"}
</div> </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> </div>
) )
})} })}
@@ -1018,6 +1071,72 @@ function Cockpit() {
</div> </div>
)} )}
</div> </div>
{/* Role Assignment Modal */}
{activeRoleForAssign && (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary">
Rolle '{activeRoleForAssign}' konfigurieren
</h3>
<button
onClick={() => setActiveRoleForAssign(null)}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-xs text-muted-foreground">
Wähle ein Modell aus deiner Bibliothek für die Rolle <strong className="text-foreground">{activeRoleForAssign}</strong>:
</p>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
<button
onClick={() => {
handleRoleChange(activeRoleForAssign, "")
setActiveRoleForAssign(null)
}}
className="w-full text-left px-3 py-2 rounded-lg text-xs hover:bg-accent text-red-400 font-semibold cursor-pointer border border-red-500/20 bg-red-500/5 flex items-center justify-between"
>
<span>Zuweisung entfernen</span>
</button>
{models.map((m) => (
<button
key={m.name}
onClick={() => {
handleRoleChange(activeRoleForAssign, m.name)
setActiveRoleForAssign(null)
}}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border border-border/30",
m.role === activeRoleForAssign
? "text-primary font-bold bg-primary/10 border-primary/30"
: "text-foreground bg-background/20"
)}
>
<div className="flex flex-col text-left">
<span className="truncate max-w-[280px] font-semibold">{m.name.split("/").pop()?.replace(".gguf", "")}</span>
<span className="text-[9px] text-muted-foreground mt-0.5">{fmtSize(m.size_bytes)} · {m.quant}</span>
</div>
{m.role === activeRoleForAssign && <Check className="h-4 w-4 shrink-0 text-primary" />}
</button>
))}
</div>
</div>
</div>
)}
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
defaultValue={dialog.defaultValue}
onConfirm={dialog.onConfirm}
onCancel={dialog.onCancel}
/>
)}
</div> </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 { ExternalLink, RefreshCw, Save, Cpu, HardDrive, Cpu as GpuIcon, Activity } from "lucide-react"
import { api, type ServicesResp, type SystemStatus } from "@/lib/api" import { api, type ServicesResp, type SystemStatus } from "@/lib/api"
import { cn, resolveExternalUrl } from "@/lib/utils" import { cn, resolveExternalUrl } from "@/lib/utils"
import { CustomDialog } from "@/components/CustomDialog"
function gb(b: number) { function gb(b: number) {
return (b / 1024 ** 3).toFixed(1) return (b / 1024 ** 3).toFixed(1)
@@ -43,6 +45,27 @@ export function SystemView() {
const [backupMsg, setBackupMsg] = useState("") const [backupMsg, setBackupMsg] = useState("")
const [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({}) 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() { function load() {
api<SystemStatus>("/api/system/status").then(setS).catch((e) => setError(String(e))) api<SystemStatus>("/api/system/status").then(setS).catch((e) => setError(String(e)))
api<ServicesResp>("/api/system/services").then(setSvc).catch(() => {}) api<ServicesResp>("/api/system/services").then(setSvc).catch(() => {})
@@ -72,12 +95,12 @@ export function SystemView() {
body: JSON.stringify({ service: serviceId }) body: JSON.stringify({ service: serviceId })
}) })
if (r.ok) { if (r.ok) {
alert(`Dienst ${serviceId} wurde erfolgreich neu gestartet.`) showAlert("Erfolgreich", `Dienst ${serviceId} wurde erfolgreich neu gestartet.`)
} else { } else {
alert(`Fehler beim Neustart: ${r.err || "Unbekannter Fehler"}`) showAlert("Fehler beim Neustart", `Fehler beim Neustart: ${r.err || "Unbekannter Fehler"}`)
} }
} catch (e: any) { } catch (e: any) {
alert(`Fehler: ${e.message}`) showAlert("Fehler", `Fehler: ${e.message}`)
} finally { } finally {
setRestartingServices(prev => ({ ...prev, [serviceId]: false })) setRestartingServices(prev => ({ ...prev, [serviceId]: false }))
} }
@@ -234,7 +257,15 @@ export function SystemView() {
</div> </div>
)} )}
{/* Maintenance controls inside system view */} {dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={dialog.onConfirm}
onCancel={dialog.onCancel}
/>
)}
</div> </div>
) )
} }