Refactor: UI-Konsolidierung über Zentrale, Modell-Manager, Hermes & Verbinden

Beseitigt Redundanzen/Mismatches, die sich mit den Lanes angesammelt hatten:

- Modell-Manager: großen animierten SVG-Gateway-Graph (~310 Z.) entfernt —
  Rolle→Modell deckt das Slot-Grid ab, Lanes der Lane-Editor, IDE-Config die
  "Verbinden"-Seite. Cockpit-Brain-Switch raus → Verweis auf Hermes-Tab.
- Hermes: zweiten SVG-Graph + 4 Status-Kacheln durch einen ruhigen Box→Pfeil-
  Fluss ersetzt (Terminal → Gateway → Hirn/Verdrahtung/PC), Stil wie "Verbinden".
- Hirn-Wechsel vereinheitlicht: nur noch im Hermes-Tab. Installiert = warm-bewusst
  (/api/agent/brain/set), Alias = /api/agent/brain; Lane-Aliase chat/coding/fast/heavy.
- Terminologie auf Lane-Sprache: ConnectView "model auto" → chat/coding;
  connect.py-Snippets defaulten auf 'coding' (GATEWAY_MODELS mit Lanes vorn).
- Zentrale: ActiveModelsCard + RolesCard zu einer ModelsCard verschmolzen
  (Rollen + warm + Inferenz + Größe); Layout entdoppelt.

~600 Zeilen SVG-Graph-Code raus, 2 tote Karten gelöscht. Verifiziert:
npm run build (tsc strict) clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-29 19:22:56 +02:00
parent 9e432dbd2d
commit c1fac1e688
15 changed files with 5775 additions and 6439 deletions
+11 -10
View File
@@ -1,7 +1,7 @@
"""
Connect: erzeugt saubere, getestete Konfig-Snippets für IDEs/Agenten auf dem
LOKALEN PC (separate Maschine im LAN). Alle zeigen auf den **Gateway** der Box
(`model: auto`, Cockpit-Port :9001/v1) + den **Shared-Memory-MCP** (MC :9001).
(Lanes `coding`/`chat`, Cockpit-Port :9001/v1) + den **Shared-Memory-MCP** (MC :9001).
Wichtig: Host ist die LAN-IP der Box (NICHT eine Proxy-Domain) — das war in v1
die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
@@ -15,8 +15,9 @@ from config import LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT
DEFAULT_HOST = "192.168.178.151"
# Modelle, die der Gateway anbietet (model:auto = Standard).
GATEWAY_MODELS = ["auto", "fast", "heavy", "coder", "vision"]
# Modelle/Lanes, die der Gateway anbietet. Lanes zuerst: 'coding' (agentischer Coder) ist der
# Standard für IDEs, 'chat' für Allgemeines; dahinter die direkten Aliase.
GATEWAY_MODELS = ["coding", "chat", "fast", "heavy", "coder", "vision"]
def _gw(host: str) -> str:
@@ -34,7 +35,7 @@ def build_snippets(host: str = DEFAULT_HOST,
"apiProvider": "openai",
"openAiBaseUrl": gw,
"openAiApiKey": "local",
"openAiModelId": "auto",
"openAiModelId": "coding",
}, indent=2)
opencode = json.dumps({
@@ -51,7 +52,7 @@ def build_snippets(host: str = DEFAULT_HOST,
cursor = json.dumps({
"Base URL": gw,
"API Key": "local",
"Active Model": "auto"
"Active Model": "coding"
}, indent=2)
zed = json.dumps({
@@ -69,7 +70,7 @@ def build_snippets(host: str = DEFAULT_HOST,
"assistant": {
"default_model": {
"provider": "openai_compatible",
"model": "auto"
"model": "coding"
}
}
}, indent=2)
@@ -77,7 +78,7 @@ def build_snippets(host: str = DEFAULT_HOST,
cont = json.dumps({
"models": [
{"title": f"Bosgame / {m}", "provider": "openai", "model": m,
"apiBase": gw, "apiKey": "local"} for m in ("auto", "coder", "heavy")
"apiBase": gw, "apiKey": "local"} for m in ("coding", "chat", "coder")
]
}, indent=2)
@@ -89,12 +90,12 @@ def build_snippets(host: str = DEFAULT_HOST,
f"# und hat kein /v1/messages. Dazwischen muss ein Übersetzer (Anthropic ⇄ OpenAI) laufen:\n"
f"# • claude-code-router (leichtgewichtig, npm)\n"
f"# • oder LiteLLM mit /v1/messages-Bridge\n"
f"# Den Übersetzer auf den Gateway zeigen lassen: baseURL={gw}, model=auto, apiKey=local.\n"
f"# Den Übersetzer auf den Gateway zeigen lassen: baseURL={gw}, model=coding, apiKey=local.\n"
f"# Dann Claude Code auf den lokalen Übersetzer richten (Beispiel-Port 3456):\n"
f"\n"
f'export ANTHROPIC_BASE_URL="http://localhost:3456"\n'
f'export ANTHROPIC_AUTH_TOKEN="local"\n'
f'export ANTHROPIC_MODEL="auto"'
f'export ANTHROPIC_MODEL="coding"'
)
memory_mcp = json.dumps({
@@ -114,7 +115,7 @@ def build_snippets(host: str = DEFAULT_HOST,
# Leitung 1 — das MODELL. Alle Snippets zeigen auf den OpenAI-kompatiblen Gateway.
"tools": {
"cline": {"label": "Roo Code / Cline", "lang": "json", "snippet": cline,
"note": "OpenAI-Provider → Gateway. Modell 'auto' (schnell, eskaliert bei Bedarf)."},
"note": "OpenAI-Provider → Gateway. Lane 'coding' (agentischer Coder); 'chat' für Allgemeines."},
"cursor": {"label": "Cursor", "lang": "json", "snippet": cursor,
"note": "Einstellungen ➔ Models ➔ OpenAI API key + Base URL."},
"opencode": {"label": "OpenCode", "lang": "jsonc", "snippet": opencode,
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
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="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-CyyjRWEt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CHGCSDst.css">
<script type="module" crossorigin src="/assets/index-DSZM1C9y.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-UDlAwulM.css">
</head>
<body>
<div id="root"></div>
@@ -1,81 +0,0 @@
import { useEffect, useRef, useState } from "react"
import { Activity, Zap, Moon } from "lucide-react"
import { useModels, useTokenStats } from "@/lib/queries"
import { fmtSize } from "@/lib/format"
import { cn } from "@/lib/utils"
import { roleTone } from "@/components/models/ModelBadges"
export function ActiveModelsCard() {
const { data } = useModels(2_000)
const { data: stats } = useTokenStats(2_000)
const models = data?.models ?? []
const running = data?.running ?? []
const loaded = models.filter((m) => running.includes(m.name))
// "Inferenz aktiv": total_tokens ist seit dem letzten Poll gestiegen → es wird generiert.
const prev = useRef<number | null>(null)
const [generating, setGenerating] = useState(false)
useEffect(() => {
if (!stats) return
const total = stats.total_tokens
if (prev.current !== null && total > prev.current) {
setGenerating(true)
const t = setTimeout(() => setGenerating(false), 4_000)
prev.current = total
return () => clearTimeout(t)
}
prev.current = total
}, [stats?.total_tokens])
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Activity className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Aktive Modelle (Live)</h2>
</div>
<span className={cn(
"flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider font-mono px-2 py-0.5 rounded-full border transition-colors",
generating
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/30"
: "bg-background/30 text-muted-foreground/70 border-border/40"
)}>
{generating ? <Zap className="h-3 w-3 animate-pulse" /> : <Moon className="h-3 w-3" />}
{generating ? "Inferenz aktiv" : "Idle"}
</span>
</div>
{loaded.length === 0 ? (
<div className="flex items-center justify-center gap-2 h-20 text-xs text-muted-foreground/70 italic">
Kein Modell geladen Auto-Swap lädt bei Anfrage.
</div>
) : (
<div className="grid gap-2">
{loaded.map((m) => (
<div key={m.name} className="flex items-center justify-between gap-2 p-3 rounded-xl bg-emerald-500/5 border border-emerald-500/20">
<div className="min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
{m.role && (
<span className={cn("text-[8px] font-semibold uppercase px-1.5 py-0.5 rounded font-mono border tracking-wider shrink-0",
roleTone(m.role))}>
{m.role}
</span>
)}
<span className="text-xs font-semibold font-mono text-foreground truncate">
{m.name.split("/").pop()?.replace(/\.gguf$/i, "")}
</span>
</div>
<div className="text-[9px] font-mono text-muted-foreground/70 mt-0.5">
{fmtSize(m.size_bytes)} im Unified-RAM
</div>
</div>
<span className="flex items-center gap-1 text-[8px] font-semibold text-emerald-400 uppercase tracking-wider font-mono shrink-0">
<span className={cn("h-1.5 w-1.5 rounded-full bg-emerald-500", generating && "animate-pulse")} /> warm
</span>
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,117 @@
import { useEffect, useRef, useState } from "react"
import { Boxes, Zap, Moon } from "lucide-react"
import { useModels, useTokenStats } from "@/lib/queries"
import { fmtSize } from "@/lib/format"
import { cn } from "@/lib/utils"
import { ROLES, roleTone } from "@/components/models/ModelBadges"
// Vereint die frühere RolesCard (Rolle→Modell) + ActiveModelsCard (warm/Inferenz/Größe)
// zu EINER Karte — dieselbe Info stand vorher doppelt auf der Zentrale.
export function ModelsCard() {
const { data } = useModels(2_000)
const { data: stats } = useTokenStats(2_000)
const models = data?.models ?? []
const running = data?.running ?? []
// „Inferenz aktiv": total_tokens seit letztem Poll gestiegen → es wird generiert.
const prev = useRef<number | null>(null)
const [generating, setGenerating] = useState(false)
useEffect(() => {
if (!stats) return
const total = stats.total_tokens
if (prev.current !== null && total > prev.current) {
setGenerating(true)
const t = setTimeout(() => setGenerating(false), 4_000)
prev.current = total
return () => clearTimeout(t)
}
prev.current = total
}, [stats?.total_tokens])
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Boxes className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Modelle</h2>
</div>
<span className={cn(
"flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider font-mono px-2 py-0.5 rounded-full border transition-colors",
generating
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/30"
: "bg-background/30 text-muted-foreground/70 border-border/40"
)}>
{generating ? <Zap className="h-3 w-3 animate-pulse" /> : <Moon className="h-3 w-3" />}
{generating ? "Inferenz aktiv" : "Idle"}
</span>
</div>
<div className="space-y-2 flex-1">
{ROLES.map((role) => {
const m = models.find((x) => x.role === role)
const isRunning = m ? running.includes(m.name) : false
return (
<div
key={role}
className={cn(
"flex items-center justify-between gap-2 p-2 rounded-xl border transition-all duration-300",
isRunning
? "border-emerald-500/30 bg-emerald-500/5 shadow-sm shadow-emerald-500/5"
: m
? "border-primary/20 bg-primary/5"
: "border-border/30 bg-background/10 opacity-60"
)}
>
<div className="flex items-center gap-2 min-w-0">
<span className={cn("text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded font-mono border tracking-wider shrink-0", roleTone(role))}>
{role}
</span>
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold truncate font-mono text-foreground">
{m ? m.name.split("/").pop()?.replace(/\.gguf$/i, "") : "nicht zugewiesen"}
</span>
{m && (
<div className="flex gap-1 items-center mt-0.5 flex-wrap text-muted-foreground/70">
<span className="text-[9px] font-mono">{fmtSize(m.size_bytes)}</span>
{m.prompt_cache && (
<span className="text-[7px] leading-none font-mono font-bold bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 px-1 py-0.5 rounded" title="Prompt Caching aktiv">PC</span>
)}
{m.spec_active && (
<span className="text-[7px] leading-none font-mono font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-1 py-0.5 rounded" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>SPEC</span>
)}
{m.parallel_slots > 1 && (
<span className="text-[7px] leading-none font-mono font-bold bg-violet-500/10 text-violet-400 border border-violet-500/20 px-1 py-0.5 rounded" title={`${m.parallel_slots} parallele Slots aktiv`}>SLOTS: {m.parallel_slots}</span>
)}
{m.incomplete && (
<span className="text-[7px] leading-none font-mono font-bold bg-amber-500/10 text-amber-400 border border-amber-500/20 px-1 py-0.5 rounded" title="GGUF-Datei fehlt"> fehlt</span>
)}
</div>
)}
</div>
</div>
<span className="shrink-0">
{m ? (
isRunning ? (
<span className="flex items-center gap-1 text-[8px] font-semibold text-emerald-400 uppercase tracking-wider font-mono">
<span className={cn("h-1.5 w-1.5 rounded-full bg-emerald-500", generating && "animate-pulse")} /> warm
</span>
) : (
<span className="text-[8px] font-semibold text-muted-foreground uppercase tracking-wider font-mono">bereit</span>
)
) : (
<span className="text-[8px] font-semibold text-muted-foreground/60 uppercase tracking-wider font-mono"></span>
)}
</span>
</div>
)
})}
</div>
<div className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
Laden erfolgt automatisch per Auto-Swap. Details &amp; Routing im Modell-Manager.
</div>
</div>
)
}
@@ -1,102 +0,0 @@
import { Layers } from "lucide-react"
import { useModels } from "@/lib/queries"
import { cn } from "@/lib/utils"
import { ROLES, roleTone } from "@/components/models/ModelBadges"
export function RolesCard() {
const { data } = useModels(3_000)
const models = data?.models ?? []
const running = data?.running ?? []
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div>
<div className="flex items-center gap-2 mb-4">
<Layers className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Rollen-Belegung</h2>
</div>
<div className="space-y-2 max-h-48 overflow-y-auto pr-1 scrollbar-thin">
{ROLES.map((role) => {
const m = models.find((x) => x.role === role)
const isRunning = m ? running.includes(m.name) : false
return (
<div
key={role}
className={cn(
"flex items-center justify-between p-2 rounded-xl border transition-all duration-300",
isRunning
? "border-emerald-500/30 bg-emerald-500/5 shadow-sm shadow-emerald-500/5"
: m
? "border-primary/20 bg-primary/5"
: "border-border/30 bg-background/10 opacity-60"
)}
>
<div className="min-w-0 flex-1 mr-2">
<div className="flex items-center gap-2 min-w-0">
<span className={cn("text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded font-mono border tracking-wider shrink-0",
roleTone(role))}>
{role}
</span>
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold truncate font-mono text-foreground">
{m ? m.name.split("/").pop()?.replace(/\.gguf$/i, "") : "nicht zugewiesen"}
</span>
{m && (
<div className="flex gap-1 items-center mt-0.5 flex-wrap">
{m.prompt_cache && (
<span className="text-[7px] leading-none font-mono font-bold bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 px-1 py-0.5 rounded" title="Prompt Caching aktiv">
PC
</span>
)}
{m.spec_active && (
<span className="text-[7px] leading-none font-mono font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-1 py-0.5 rounded" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>
SPEC
</span>
)}
{m.parallel_slots > 1 && (
<span className="text-[7px] leading-none font-mono font-bold bg-violet-500/10 text-violet-400 border border-violet-500/20 px-1 py-0.5 rounded" title={`${m.parallel_slots} parallele Slots aktiv`}>
SLOTS: {m.parallel_slots}
</span>
)}
{m.incomplete && (
<span className="text-[7px] leading-none font-mono font-bold bg-amber-500/10 text-amber-400 border border-amber-500/20 px-1 py-0.5 rounded" title="GGUF-Datei fehlt">
fehlt
</span>
)}
</div>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{m ? (
isRunning ? (
<span className="flex items-center gap-1 text-[8px] font-semibold text-emerald-400 uppercase tracking-wider font-mono">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> warm
</span>
) : (
<span className="text-[8px] font-semibold text-muted-foreground uppercase tracking-wider font-mono">
bereit
</span>
)
) : (
<span className="text-[8px] font-semibold text-muted-foreground/60 uppercase tracking-wider font-mono">
</span>
)}
</div>
</div>
)
})}
</div>
</div>
<div className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
Laden erfolgt automatisch per Auto-Swap.
</div>
</div>
)
}
@@ -4,8 +4,8 @@ import { cn } from "@/lib/utils"
// Die 5 kanonischen Serving-Rollen (identisch zu Backend sources.py/llamaswap.ROLE_IDS).
export const ROLES = ["fast", "heavy", "coder", "vision", "scout"]
// Rolle → Farbton. EINE Quelle der Wahrheit für alle Rollen-Badges (Cockpit, RolesCard,
// ActiveModelsCard, …). `hermes` = Agent-Hirn (taucht als geladenes Modell auf).
// Rolle → Farbton. EINE Quelle der Wahrheit für alle Rollen-Badges (Cockpit, ModelsCard,
// …). `hermes` = Agent-Hirn (taucht als geladenes Modell auf).
export const ROLE_TONE: Record<string, string> = {
fast: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25",
heavy: "bg-amber-500/15 text-amber-400 border-amber-500/25",
+155 -284
View File
@@ -1,53 +1,12 @@
import { useState, useRef, useCallback, useMemo } from "react"
import { useState } from "react"
import { Bot, ExternalLink, Activity, Cpu, Wrench, Monitor, Shield, Check, X } from "lucide-react"
import { api } from "@/lib/api"
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn, resolveExternalUrl } from "@/lib/utils"
function Tile({ label, ok, detail, icon: Icon, onClick }: { label: string; ok: boolean; detail?: string; icon: any; onClick?: () => void }) {
return (
<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")} />
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className={cn(
"h-2 w-2 rounded-full ring-2 ring-black/40",
ok ? "bg-emerald-500 animate-pulse" : "bg-amber-500"
)} />
<span className="text-xs font-semibold text-foreground">
{ok ? "Bereit / Online" : "Offline / Inaktiv"}
</span>
</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>
)
}
// Lane-/Gateway-Aliase, auf die das Hirn zeigen kann (Lane-Sprache statt altem „auto").
const ALIASES = ["chat", "coding", "fast", "heavy"]
export function AgentView() {
const { data: s, error: sErr } = useAgentStatus(5_000)
@@ -55,75 +14,37 @@ export function AgentView() {
const { showAlert, dialogElement } = useDialog()
const qc = useQueryClient()
const error = sErr ? String(sErr) : ""
const availableModels = useMemo(() => {
const names = (modelsData?.models ?? []).map(
(m) => m.name.split("/").pop()?.replace(".gguf", "") || m.name)
return ["auto", "fast", "heavy", ...names]
}, [modelsData])
// Graph UI state
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
const [showBrainSelect, setShowBrainSelect] = useState(false)
// Canvas pixel tracking for pixel-perfect connection graph without non-uniform scaling
const [dimensions, setDimensions] = useState({ width: 800, height: 360 })
const observerRef = useRef<ResizeObserver | null>(null)
const installed = modelsData?.models ?? []
const containerRef = useCallback((node: HTMLDivElement | null) => {
if (observerRef.current) {
observerRef.current.disconnect()
observerRef.current = null
}
if (node) {
const observer = new ResizeObserver((entries) => {
if (!entries || entries.length === 0) return
const rect = entries[0].contentRect
setDimensions({ width: rect.width, height: rect.height })
})
observer.observe(node)
observerRef.current = observer
}
}, [])
const w = dimensions.width
const h = dimensions.height
// Helper to generate curve between any two points
const getCurvePath = (startX: number, startY: number, endX: number, endY: number) => {
const midX = (startX + endX) / 2
return `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`
}
async function changeBrainModel(model: string) {
// Alias setzen → roher Config-Write (model.default zeigt aufs Alias).
async function setBrainAlias(alias: 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.`)
await api("/api/agent/brain", { method: "POST", body: JSON.stringify({ model: alias }) })
showAlert("Erledigt", `Hermes-Hirn zeigt jetzt auf das Alias '${alias}'. Der Gateway-Dienst wurde neu gestartet.`)
qc.invalidateQueries({ queryKey: qk.agentStatus })
setShowBrainSelect(false)
} catch (e: any) {
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
showAlert("Fehler", `Wechsel fehlgeschlagen: ${e.message || e}`)
}
}
// Installiertes Modell → warm-bewusst (hermes-Alias + brains-Gruppe + Config + Restart).
async function setBrainWarm(modelName: string) {
try {
await api("/api/agent/brain/set", { method: "POST", body: JSON.stringify({ model_id: modelName }) })
showAlert("Erledigt", "Agent-Hirn gewechselt (warm gehalten). Gateway wurde neugestartet.")
qc.invalidateQueries({ queryKey: qk.agentStatus })
qc.invalidateQueries({ queryKey: qk.models })
setShowBrainSelect(false)
} catch (e: any) {
showAlert("Fehler", `Wechsel fehlgeschlagen: ${e.message || e}`)
}
}
return (
<div className="space-y-6">
{/* Styles inside AgentView for dash flow animations */}
<style>{`
@keyframes flow-dash {
to {
stroke-dashoffset: -20;
}
}
.svg-flow-path {
stroke-dasharray: 4 6;
animation: flow-dash 1s linear infinite;
}
`}</style>
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div>
@@ -161,170 +82,89 @@ export function AgentView() {
{s && (
<div className="space-y-6">
{/* Tile Grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Tile
label="Agent Gateway"
ok={s.gateway_reachable}
detail="Port :8642 (REST API)"
icon={Bot}
/>
<Tile
label="Terminal"
ok={s.terminal_reachable}
detail="Web-Terminal (hermes chat)"
icon={Activity}
/>
<Tile
label="Aktives Gehirn"
ok={s.gateway_reachable}
detail={s.brain_model ? `Model: ${s.brain_model}` : "Model: auto"}
icon={Cpu}
onClick={() => setShowBrainSelect(true)}
/>
<Tile
label="Verdrahtung"
ok={s.has_config}
detail={`Config: ${s.has_config ? "✓" : "—"} · Skills: ${s.has_skills ? "✓" : "—"} · Memory: ${s.has_memories ? "✓" : "—"}`}
icon={Wrench}
/>
</div>
{/* Interactive Gateway Graph */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-6 shadow-lg shadow-black/10 space-y-4">
<div>
<span className="text-xs font-bold uppercase tracking-wider text-foreground">Interactive Agent Flow Graph</span>
<p className="text-[10px] text-muted-foreground mt-0.5 leading-relaxed">
Visualisiert das Zusammenspiel und die Verbindungspfade zwischen den Hermes-Systemkomponenten.
</p>
{/* Agent-Fluss: Terminal → Gateway → Hirn / Verdrahtung / PC (ruhig, statt SVG-Graph) */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10">
<div className="mb-4 flex items-center gap-2">
<Bot className="h-4.5 w-4.5 text-primary" />
<span className="text-xs font-bold uppercase tracking-wider text-foreground">Agent-Fluss</span>
</div>
{/* The Graph Canvas Area */}
<div ref={containerRef} className="relative w-full h-96 border border-border/30 rounded-xl bg-black/25 overflow-hidden flex">
<svg className="absolute inset-0 pointer-events-none w-full h-full">
<defs>
<linearGradient id="cyan-to-teal" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#06b6d4" stopOpacity="0.45" />
<stop offset="100%" stopColor="#0d9488" stopOpacity="0.45" />
</linearGradient>
<linearGradient id="active-glow" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.8" />
<stop offset="100%" stopColor="#10b981" stopOpacity="0.8" />
</linearGradient>
</defs>
{/* Connection: Terminal to Gateway */}
<path d={getCurvePath(w * 0.15, h * 0.5, w * 0.5, h * 0.5)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "terminal" || s.terminal_reachable) && (
<path d={getCurvePath(w * 0.15, h * 0.5, w * 0.5, h * 0.5)} stroke="#06b6d4" strokeWidth="2.5" fill="none" className="svg-flow-path" />
)}
{/* Connection: Gateway to Gehirn */}
<path d={getCurvePath(w * 0.5, h * 0.5, w * 0.85, h * 0.3)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "gateway" || hoveredNode === "brain" || s.gateway_reachable) && (
<path d={getCurvePath(w * 0.5, h * 0.5, w * 0.85, h * 0.3)} stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="svg-flow-path" />
)}
{/* Connection: Gateway to Verdrahtung */}
<path d={getCurvePath(w * 0.5, h * 0.5, w * 0.85, h * 0.7)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "gateway" || hoveredNode === "wiring" || s.gateway_reachable) && (
<path d={getCurvePath(w * 0.5, h * 0.5, w * 0.85, h * 0.7)} stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="svg-flow-path" />
)}
</svg>
{/* COLUMN 1: Interface */}
<div
className="absolute cursor-pointer select-none z-10 w-36 h-9 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2.5 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
style={{ left: "15%", top: "50%" }}
onMouseEnter={() => setHoveredNode("terminal")}
onMouseLeave={() => setHoveredNode(null)}
<div className="flex flex-col lg:flex-row lg:items-stretch gap-3">
{/* Quelle: Terminal/Clients */}
<button
onClick={() => s.terminal_reachable && window.open(resolveExternalUrl(s.terminal_url), "_blank")}
title={s.terminal_reachable ? "Klicken um das Hermes-Terminal zu öffnen" : "Terminal offline"}
title={s.terminal_reachable ? "Hermes-Terminal öffnen" : "Terminal offline"}
className="lg:w-40 shrink-0 rounded-xl border border-border/60 bg-background/30 p-3 text-left hover:border-primary/40 transition-all cursor-pointer flex flex-col justify-center"
>
<Activity className={cn("h-3.5 w-3.5", s.terminal_reachable ? "text-emerald-400" : "text-amber-500")} />
<span>Terminal</span>
<span className={cn("w-1.5 h-1.5 rounded-full animate-pulse ml-auto", s.terminal_reachable ? "bg-emerald-500" : "bg-amber-500")} />
</div>
<div className="flex items-center gap-1.5">
<Activity className={cn("h-3.5 w-3.5", s.terminal_reachable ? "text-emerald-400" : "text-amber-500")} />
<span className="text-xs font-semibold text-foreground">Terminal</span>
<span className={cn("ml-auto h-1.5 w-1.5 rounded-full animate-pulse", s.terminal_reachable ? "bg-emerald-500" : "bg-amber-500")} />
</div>
<span className="text-[10px] text-muted-foreground font-mono mt-0.5">hermes chat · Clients</span>
</button>
{/* COLUMN 2: Gateway */}
<div
className="absolute select-none z-10 w-40 py-2.5 px-3 -translate-y-1/2 -translate-x-1/2 rounded-2xl border border-primary/50 bg-card/90 backdrop-blur-md flex flex-col items-center justify-center text-center shadow-lg shadow-primary/5 cursor-pointer"
style={{ left: "50%", top: "50%" }}
onMouseEnter={() => setHoveredNode("gateway")}
onMouseLeave={() => setHoveredNode(null)}
>
<div className="flex items-center gap-1">
<div className="hidden lg:flex items-center text-muted-foreground text-lg select-none"></div>
{/* Gateway + nachgelagerte Module */}
<div className="flex-1 flex flex-col gap-2.5">
{/* Agent Gateway */}
<div className="rounded-xl border border-primary/40 bg-primary/5 px-3.5 py-2.5 flex items-center gap-2">
<Bot className="h-3.5 w-3.5 text-primary" />
<span className="text-[10px] uppercase font-bold text-primary font-space tracking-wide">Agent Gateway</span>
<span className="text-xs font-semibold text-primary">Agent Gateway</span>
<span className="text-[11px] font-mono text-muted-foreground">:8642</span>
<span className={cn(
"ml-auto inline-flex items-center gap-1.5 text-[10px] font-bold uppercase font-mono px-2 py-0.5 rounded-full border",
s.gateway_reachable
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-400 border-red-500/20"
)}>
<span className={cn("h-1.5 w-1.5 rounded-full", s.gateway_reachable ? "bg-emerald-500 animate-pulse" : "bg-red-500")} />
{s.gateway_reachable ? "online" : "offline"}
</span>
</div>
<div className="text-[9px] font-mono text-muted-foreground mt-0.5">Port :8642</div>
<div className={cn("mt-1 px-1.5 py-0.5 rounded text-[8px] font-semibold uppercase font-mono border",
s.gateway_reachable
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-400"
: "bg-red-500/10 border-red-500/20 text-red-400"
)}>
{s.gateway_reachable ? "Online" : "Offline"}
</div>
</div>
{/* COLUMN 3: Brain & Wiring */}
<div
className={cn(
"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">
<Cpu className="h-3 w-3 text-primary" />
<span className="text-[10px] font-bold uppercase tracking-wider">Aktives Gehirn</span>
{/* Drei Module: Hirn (klickbar), Verdrahtung, PC */}
<div className="grid gap-2.5 sm:grid-cols-3">
<button
onClick={() => setShowBrainSelect(true)}
className="rounded-xl border border-border/60 bg-background/30 p-3 text-left hover:border-primary/45 transition-all cursor-pointer"
>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Cpu className="h-3.5 w-3.5 text-primary" />
<span className="text-[10px] font-bold uppercase tracking-wider">Hirn</span>
<span className="ml-auto text-[9px] text-primary/70 font-bold uppercase">wechseln </span>
</div>
<div className="text-[11px] font-mono font-medium text-foreground truncate mt-1" title={s.brain_model}>
{s.brain_model || "chat"}
</div>
</button>
<div className="rounded-xl border border-border/60 bg-background/30 p-3">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Wrench className="h-3.5 w-3.5 text-primary" />
<span className="text-[10px] font-bold uppercase tracking-wider">Verdrahtung</span>
</div>
<div className="text-[10px] font-mono mt-1 flex flex-wrap gap-x-2 text-muted-foreground">
<span>Config: {s.has_config ? "✓" : "—"}</span>
<span>Skills: {s.has_skills ? "✓" : "—"}</span>
<span>Memory: {s.has_memories ? "✓" : "—"}</span>
</div>
</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>
</div>
<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",
s.has_config ? "border-emerald-500/50 bg-emerald-500/5 shadow-emerald-500/5" : "border-border/60 bg-card/75"
)}
style={{ left: "85%", top: "70%" }}
onMouseEnter={() => setHoveredNode("wiring")}
onMouseLeave={() => setHoveredNode(null)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-muted-foreground">
<Wrench className="h-3 w-3 text-primary" />
<span className="text-[10px] font-bold uppercase tracking-wider">Verdrahtung</span>
<div className="rounded-xl border border-border/60 bg-background/30 p-3">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Monitor className="h-3.5 w-3.5 text-primary" />
<span className="text-[10px] font-bold uppercase tracking-wider">PC</span>
<span className={cn("ml-auto h-1.5 w-1.5 rounded-full", s.pc_executor_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
</div>
<div className={cn("text-[11px] font-mono mt-1", s.pc_executor_reachable ? "text-emerald-400" : "text-muted-foreground")}>
:7777 {s.pc_executor_reachable ? "verbunden" : "offline"}
</div>
</div>
{s.has_config && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />}
</div>
<div className="text-[9px] font-mono mt-0.5 text-muted-foreground flex gap-1 justify-between">
<span>Config: {s.has_config ? "✓" : "—"}</span>
<span>Skills: {s.has_skills ? "✓" : "—"}</span>
<span>Memory: {s.has_memories ? "✓" : "—"}</span>
</div>
</div>
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-6 mt-3 text-[10px] text-muted-foreground border-t border-border/20 pt-3">
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-cyan-400" />
<span>Cyan-Fluss: Client-Anfrage an Agenten</span>
</span>
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<span>Grüner Puls: Verbindung hergestellt / Modul online</span>
</span>
</div>
</div>
{/* PC Executor Status Card */}
@@ -408,14 +248,14 @@ export function AgentView() {
</div>
)}
{/* Brain Selection Modal */}
{/* Brain Selection Modal — eine Stelle für den Hirn-Wechsel (warm-bewusst + Alias) */}
{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>
<span>Hermes-Hirn konfigurieren</span>
</h3>
<button
onClick={() => setShowBrainSelect(false)}
@@ -424,38 +264,69 @@ export function AgentView() {
<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>
);
})}
{/* Lane / Gateway-Alias */}
<div className="space-y-1.5">
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Lane / Gateway-Alias</div>
<div className="flex flex-wrap gap-1.5">
{ALIASES.map((a) => {
const cur = s.brain_model === a
return (
<button
key={a}
onClick={() => setBrainAlias(a)}
className={cn(
"px-3 py-1.5 rounded-lg text-xs font-mono font-semibold border transition-all cursor-pointer flex items-center gap-1.5",
cur ? "border-primary/40 bg-primary/10 text-primary" : "border-border/30 bg-background/20 text-foreground hover:bg-accent",
)}
>
{a}{cur && <Check className="h-3.5 w-3.5" />}
</button>
)
})}
</div>
<p className="text-[10px] text-muted-foreground/70">
Schlank &amp; flexibel: das Hirn folgt dem Lane-Routing (z.B. <code className="text-primary">chat</code> = fastheavy).
</p>
</div>
{/* Installierte Modelle (warm-bewusst) */}
<div className="space-y-1.5">
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Installiertes Modell (warm gehalten)</div>
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-1">
{installed.map((m) => {
const isBrain = m.role === "hermes"
return (
<button
key={m.name}
onClick={() => !isBrain && setBrainWarm(m.name)}
disabled={isBrain || m.incomplete}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-xs flex items-center justify-between font-mono border transition-all",
isBrain ? "border-indigo-500/40 bg-indigo-500/10 text-indigo-300"
: m.incomplete ? "border-border/20 bg-background/10 text-muted-foreground/50 cursor-not-allowed"
: "border-border/30 bg-background/20 text-foreground hover:bg-accent cursor-pointer"
)}
>
<div className="flex flex-col min-w-0">
<span className="font-semibold truncate max-w-[280px]">{m.name.split("/").pop()?.replace(/\.gguf$/i, "")}</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{m.capabilities.params_b ? `${m.capabilities.params_b}B` : "?"} · {m.capabilities.tools !== "no" ? "Tools ✓" : "ohne Tools"}
{m.role && ` · Rolle: ${m.role}`}
</span>
</div>
{isBrain
? <span className="text-[9px] font-bold uppercase text-indigo-300 shrink-0 flex items-center gap-1"><Check className="h-3.5 w-3.5" /> Aktiv</span>
: <span className="text-[9px] font-bold uppercase text-primary/70 shrink-0">Warm setzen </span>}
</button>
)
})}
</div>
</div>
<div className="text-[10px] text-amber-400/80 flex items-start gap-1.5 border-t border-border/20 pt-2.5">
<span>💡</span>
<span>Für einen <strong>Agenten</strong> sind Hermes-Modelle (natives Tool-Calling) meist die robustere Wahl. Neues Modell? Erst über Modell-Manager Modelle finden" laden.</span>
</div>
</div>
</div>
+1 -1
View File
@@ -142,7 +142,7 @@ export function ConnectView() {
<StatusBadge line={health?.gateway} loading={healthLoading} />
</div>
<div className="mt-1 text-[11px] font-mono text-primary/80">
Gateway · :9001/v1 · model auto
Gateway · :9001/v1 · Lanes chat / coding
</div>
</div>
</div>
+6 -13
View File
@@ -1,12 +1,11 @@
import { ActiveModelsCard } from "@/components/dashboard/ActiveModelsCard"
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
import { UpdatesCard } from "@/components/dashboard/UpdatesCard"
import { AgentStatusCard } from "@/components/dashboard/AgentStatusCard"
import { RolesCard } from "@/components/dashboard/RolesCard"
import { MemoryInputCard } from "@/components/dashboard/MemoryInputCard"
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
import { ServicesCard } from "@/components/dashboard/ServicesCard"
import { VoiceLatencyCard } from "@/components/dashboard/VoiceLatencyCard"
import { ModelsCard } from "@/components/dashboard/ModelsCard"
function ZoneLabel({ children }: { children: React.ReactNode }) {
return <p className="mb-2 ml-0.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">{children}</p>
@@ -22,28 +21,22 @@ export function DashboardView() {
<p className="text-sm text-muted-foreground">Aktueller Status von System, Modellen und Agent.</p>
</div>
{/* Hero-Banner — die 3 relevantesten, gleich prominent */}
{/* Hero-Banner — System, Durchsatz, Modelle (Rollen + warm + Inferenz in einer Karte) */}
<div className="grid items-stretch gap-6 lg:grid-cols-3">
<SystemStatusCard />
<TokenPerformanceCard />
<ActiveModelsCard />
<ModelsCard />
</div>
{/* Stack-Status */}
{/* Stack & Telemetrie */}
<section>
<ZoneLabel>Stack-Status</ZoneLabel>
<ZoneLabel>Stack &amp; Telemetrie</ZoneLabel>
<div className="grid gap-6 md:grid-cols-2">
<RolesCard />
<ServicesCard />
<VoiceLatencyCard />
</div>
</section>
{/* Sprach-Latenz (Voice/Lucy-Pipeline, C2) */}
<section>
<ZoneLabel>Sprach-Latenz</ZoneLabel>
<VoiceLatencyCard />
</section>
{/* Betrieb & Wissen */}
<section>
<ZoneLabel>Betrieb &amp; Wissen</ZoneLabel>
+7 -460
View File
@@ -1,20 +1,18 @@
import { useState, useRef, useCallback } from "react"
import { Download, Trash2, Edit3, Activity, HardDrive, X, Check, Copy, Zap, Bot } from "lucide-react"
import { useState } from "react"
import { Download, Trash2, Edit3, Activity, HardDrive, X, Check, Zap, Bot } from "lucide-react"
import { api, setGroup, type ModelInfo, type RoleRecResp } from "@/lib/api"
import { useModels, useGroups, useRouting, useConnect, useUpdates, useHermesBrain, useSystemStatus, useQueryClient, qk } from "@/lib/queries"
import { useModels, useGroups, useUpdates, useHermesBrain, useSystemStatus, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { CapsChips } from "@/components/CapsChips"
import { cn } from "@/lib/utils"
import { fmtSize, fmtCtx } from "@/lib/format"
import { getBrandInfo, ROLES, roleTone } from "@/components/models/ModelBadges"
import { getBrandInfo, roleTone } from "@/components/models/ModelBadges"
import { SpecDraftModal } from "@/components/models/SpecDraftModal"
import { LaneEditor } from "@/components/models/LaneEditor"
export function Cockpit() {
const qc = useQueryClient()
const { data: modelsResp, isLoading: loading, error: loadErr } = useModels(4_000)
const { data: routing } = useRouting(4_000)
const { data: connectData } = useConnect()
const { data: updates } = useUpdates(4_000)
const { data: brain } = useHermesBrain()
const { data: sysStatus } = useSystemStatus()
@@ -53,13 +51,9 @@ export function Cockpit() {
}
// UI state
const [activeClient, setActiveClient] = useState<"roocode" | "cursor" | "opencode" | "zed" | "continue" | null>(null)
const [activeRoleForAssign, setActiveRoleForAssign] = useState<string | null>(null)
const [roleRec, setRoleRec] = useState<RoleRecResp | null>(null)
const [specModel, setSpecModel] = useState<ModelInfo | null>(null)
const [showBrainSwitch, setShowBrainSwitch] = useState(false)
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")
@@ -70,55 +64,6 @@ export function Cockpit() {
return true
})
// Canvas pixel tracking for pixel-perfect connection graph without non-uniform scaling
const [dimensions, setDimensions] = useState({ width: 800, height: 360 })
const observerRef = useRef<ResizeObserver | null>(null)
const containerRef = useCallback((node: HTMLDivElement | null) => {
if (observerRef.current) {
observerRef.current.disconnect()
observerRef.current = null
}
if (node) {
const observer = new ResizeObserver((entries) => {
if (!entries || entries.length === 0) return
const rect = entries[0].contentRect
setDimensions({ width: rect.width, height: rect.height })
})
observer.observe(node)
observerRef.current = observer
}
}, [])
const w = dimensions.width
const h = dimensions.height
// Helper to generate curve from client to gateway
const getClientPath = (yPercent: number) => {
const startX = w * 0.1
const startY = h * yPercent
const endX = w * 0.5
const endY = h * 0.5
const cp1X = w * 0.3
const cp1Y = startY
const cp2X = w * 0.3
const cp2Y = endY
return `M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${endX} ${endY}`
}
// Helper to generate curve from gateway to role
const getRolePath = (yPercent: number) => {
const startX = w * 0.5
const startY = h * 0.5
const endX = w * 0.9
const endY = h * yPercent
const cp1X = w * 0.7
const cp1Y = startY
const cp2X = w * 0.7
const cp2Y = endY
return `M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${endX} ${endY}`
}
async function doLoadModel(name: string) {
try {
await api(`/api/models/${encodeURIComponent(name)}/load`, { method: "POST" })
@@ -265,30 +210,6 @@ export function Cockpit() {
)
}
async function handleSetBrain(modelName: string) {
showConfirm(
"Agent-Hirn wechseln?",
`'${modelName.split("/").pop()}' als Agent-Hirn (Alias hermes) setzen? Es wird warm gehalten (brains-Gruppe); Hermes nutzt es nach einem kurzen Gateway-Restart.`,
async () => {
try {
await api("/api/agent/brain/set", { method: "POST", body: JSON.stringify({ model_id: modelName }) })
showAlert("Erledigt", "Agent-Hirn gewechselt. Gateway wurde neugestartet.")
setShowBrainSwitch(false)
reload()
} catch (e: any) {
showAlert("Fehler", `Wechsel fehlgeschlagen: ${e.message || e}`)
}
}
)
}
async function copySnippet(snippet?: string) {
if (!snippet) return
await navigator.clipboard.writeText(snippet)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
if (loading) return <div className="text-xs text-muted-foreground py-12 text-center">Initialisiere HUD Cockpit</div>
if (error) {
return (
@@ -308,13 +229,6 @@ export function Cockpit() {
? gttTotal
: (totalRunningSize > virtualMax ? totalRunningSize * 1.2 : virtualMax)
// Find model by role
const getModelForRole = (role: string) => models.find((m) => m.role === role)
const isRoleRunning = (role: string) => {
const m = getModelForRole(role)
return m ? running.includes(m.name) : false
}
return (
<div className="space-y-8">
{/* Styles inside Cockpit for dash flow animations */}
@@ -388,320 +302,6 @@ export function Cockpit() {
</div>
</div>
{/* ZONE B: SVG Node Router */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-6 shadow-lg shadow-black/10 space-y-4">
<div>
<span className="text-xs font-bold uppercase tracking-wider text-foreground">Interactive Gateway Graph</span>
<p className="text-[10px] text-muted-foreground mt-0.5 leading-relaxed">
Klicke links auf Editoren, um Configs zu kopieren. Klicke rechts auf Rollen, um GGUF-Zuweisungen live zu ändern.
</p>
</div>
{/* The Graph Canvas Area */}
<div ref={containerRef} className="relative w-full h-96 border border-border/30 rounded-xl bg-black/25 overflow-hidden flex">
{/* SVG Overlay behind HTML Nodes */}
<svg className="absolute inset-0 pointer-events-none w-full h-full">
{/* Gradients */}
<defs>
<linearGradient id="cyan-to-teal" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#06b6d4" stopOpacity="0.45" />
<stop offset="100%" stopColor="#0d9488" stopOpacity="0.45" />
</linearGradient>
<linearGradient id="active-glow" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.8" />
<stop offset="100%" stopColor="#10b981" stopOpacity="0.8" />
</linearGradient>
</defs>
{/* Bezier Curves: Clients to Gateway (10% -> 50%) */}
{/* Roo Code (y=10) */}
<path d={getClientPath(0.10)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "roocode" || activeClient === "roocode") && (
<path d={getClientPath(0.10)} stroke="#06b6d4" strokeWidth="2.5" fill="none" className="svg-flow-path" />
)}
{/* Cursor (y=30) */}
<path d={getClientPath(0.30)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "cursor" || activeClient === "cursor") && (
<path d={getClientPath(0.30)} stroke="#06b6d4" strokeWidth="2.5" fill="none" className="svg-flow-path" />
)}
{/* OpenCode (y=50) */}
<path d={getClientPath(0.50)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "opencode" || activeClient === "opencode") && (
<path d={getClientPath(0.50)} stroke="#06b6d4" strokeWidth="2.5" fill="none" className="svg-flow-path" />
)}
{/* Zed (y=70) */}
<path d={getClientPath(0.70)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "zed" || activeClient === "zed") && (
<path d={getClientPath(0.70)} stroke="#06b6d4" strokeWidth="2.5" fill="none" className="svg-flow-path" />
)}
{/* Continue (y=90) */}
<path d={getClientPath(0.90)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{(hoveredNode === "continue" || activeClient === "continue") && (
<path d={getClientPath(0.90)} stroke="#06b6d4" strokeWidth="2.5" fill="none" className="svg-flow-path" />
)}
{/* Bezier Curves: Gateway to Roles (50% -> 90%) */}
{/* fast (y=12) */}
<path d={getRolePath(0.12)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{isRoleRunning("fast") && (
<path d={getRolePath(0.12)} stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="svg-flow-path" />
)}
{/* heavy (y=31) */}
<path d={getRolePath(0.31)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{isRoleRunning("heavy") && (
<path d={getRolePath(0.31)} stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="svg-flow-path" />
)}
{/* coder (y=50) */}
<path d={getRolePath(0.50)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{isRoleRunning("coder") && (
<path d={getRolePath(0.50)} stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="svg-flow-path" />
)}
{/* vision (y=69) */}
<path d={getRolePath(0.69)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{isRoleRunning("vision") && (
<path d={getRolePath(0.69)} stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="svg-flow-path" />
)}
{/* scout (y=88) */}
<path d={getRolePath(0.88)} stroke="url(#cyan-to-teal)" strokeWidth="1.5" fill="none" />
{isRoleRunning("scout") && (
<path d={getRolePath(0.88)} stroke="url(#active-glow)" strokeWidth="2.5" fill="none" className="svg-flow-path" />
)}
</svg>
{/* HTML Nodes */}
{/* COLUMN 1: Client nodes */}
<div
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
style={{ left: "10%", top: "10%" }}
onMouseEnter={() => setHoveredNode("roocode")}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => setActiveClient(c => c === "roocode" ? null : "roocode")}
>
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span>Roo Code</span>
</div>
<div
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
style={{ left: "10%", top: "30%" }}
onMouseEnter={() => setHoveredNode("cursor")}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => setActiveClient(c => c === "cursor" ? null : "cursor")}
>
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span>Cursor IDE</span>
</div>
<div
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
style={{ left: "10%", top: "50%" }}
onMouseEnter={() => setHoveredNode("opencode")}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => setActiveClient(c => c === "opencode" ? null : "opencode")}
>
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span>OpenCode</span>
</div>
<div
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
style={{ left: "10%", top: "70%" }}
onMouseEnter={() => setHoveredNode("zed")}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => setActiveClient(c => c === "zed" ? null : "zed")}
>
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span>Zed</span>
</div>
<div
className="absolute cursor-pointer select-none z-10 w-28 h-8 -translate-y-1/2 -translate-x-1/2 rounded-xl border border-border/60 bg-card/75 backdrop-blur-md flex items-center justify-center gap-1.5 px-2 hover:border-primary transition-all text-[10px] font-semibold text-foreground shadow shadow-black/20"
style={{ left: "10%", top: "90%" }}
onMouseEnter={() => setHoveredNode("continue")}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => setActiveClient(c => c === "continue" ? null : "continue")}
>
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span>Continue</span>
</div>
{/* COLUMN 2: Central Gateway Node — virtuelle Lanes (Router pickt das echte Modell) */}
<div
className="absolute select-none z-10 w-40 py-3 px-4 -translate-y-1/2 -translate-x-1/2 rounded-2xl border border-primary/50 bg-card/90 backdrop-blur-md flex flex-col items-center justify-center text-center shadow-lg shadow-primary/5"
style={{ left: "50%", top: "50%" }}
>
<div className="text-[10px] uppercase font-bold text-primary font-space tracking-wide">Gateway · Lanes</div>
<div className="mt-1 flex flex-col gap-0.5 text-[9px] font-mono text-muted-foreground">
{routing?.lanes?.length ? routing.lanes.map((l) => (
<span key={l.name}>
<span className="text-foreground font-semibold">{l.name}</span>
{l.threshold_chars ? ` ${(l.threshold_chars / 1000).toFixed(0)}k` : l.escalate_chars ? `${(l.escalate_chars / 1000).toFixed(0)}k` : ""}
</span>
)) : <span>chat · coding</span>}
</div>
<div className="mt-1 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-[9px] font-semibold text-primary uppercase font-mono">
Router
</div>
</div>
{/* COLUMN 3: Role nodes (fast, heavy, coder, vision, scout) */}
{ROLES.map((role) => {
const yPositions = ["12%", "31%", "50%", "69%", "88%"]
const activeModel = getModelForRole(role)
const isWarm = activeModel ? running.includes(activeModel.name) : false
if (role === "agent") return null
const indexMap = { fast: 0, heavy: 1, coder: 2, vision: 3, scout: 4 }[role] as number
return (
<div
key={role}
className={cn(
"absolute z-10 w-44 py-1.5 px-3 -translate-y-1/2 -translate-x-1/2 rounded-xl border flex flex-col justify-center hover:border-primary/50 transition-all text-left shadow select-none cursor-pointer",
isWarm
? "border-emerald-500/50 bg-emerald-500/5 shadow-emerald-500/5"
: activeModel
? "border-border/60 bg-card/75"
: "border-dashed border-border/40 bg-background/20"
)}
style={{ left: "90%", top: yPositions[indexMap] }}
onClick={() => openRoleAssign(role)}
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{role}</span>
{isWarm && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />}
</div>
<div className="text-[10px] font-mono font-medium truncate mt-0.5 text-foreground max-w-[150px]">
{activeModel ? activeModel.name.split("/").pop()?.replace(".gguf", "") : "Keine Zuweisung"}
</div>
</div>
)
})}
{/* CLIENT SETUP MODAL WINDOW */}
{activeClient && connectData && (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200 flex flex-col justify-between">
{/* Modal Header */}
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<span className="text-xs font-bold uppercase tracking-wider text-primary">
{activeClient === "roocode" && "Roo Code Setup"}
{activeClient === "cursor" && "Cursor Setup"}
{activeClient === "opencode" && "OpenCode Setup"}
{activeClient === "zed" && "Zed Setup"}
{activeClient === "continue" && "Continue Setup"}
</span>
<button
onClick={() => setActiveClient(null)}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Modal Guide Steps */}
<div className="text-xs text-muted-foreground leading-relaxed space-y-2">
{activeClient === "roocode" && (
<ul className="list-decimal pl-4 space-y-1">
<li>Suche in VS Code nach der Erweiterung <strong>Roo Code</strong> und installiere sie.</li>
<li>Wähle in den Roo Code Einstellungen: Provider: <strong>OpenAI Compatible</strong>.</li>
<li>Füge das untenstehende JSON-Snippet in die <code>settings.json</code> ein.</li>
</ul>
)}
{activeClient === "cursor" && (
<ul className="list-decimal pl-4 space-y-1">
<li>Öffne Cursor Settings <strong>Models</strong>.</li>
<li>Deaktiviere Cloud-Modelle, klappe <strong>OpenAI API</strong> auf.</li>
<li>Trage die Base URL unten ein und aktiviere das Modell <strong>auto</strong>.</li>
</ul>
)}
{activeClient === "opencode" && (
<ul className="list-decimal pl-4 space-y-1">
<li>Öffne die <code>opencode.jsonc</code> Konfigurationsdatei.</li>
<li>Ersetze den Provider-Eintrag unter <code>provider</code> mit dem Snippet unten.</li>
</ul>
)}
{activeClient === "zed" && (
<ul className="list-decimal pl-4 space-y-1">
<li>Öffne die Zed Settings (<code>ctrl+,</code>).</li>
<li>Füge das untenstehende JSON-Segment unter <code>language_models</code> ein.</li>
</ul>
)}
{activeClient === "continue" && (
<ul className="list-decimal pl-4 space-y-1">
<li>Klicke auf das Zahnrad-Symbol in der Continue-Erweiterung.</li>
<li>Füge den Gateway-Eintrag zum <code>models</code>-Array hinzu.</li>
</ul>
)}
</div>
{/* Snippet box */}
{connectData.tools && (
<div className="relative rounded-xl border border-border/40 bg-black/40 overflow-hidden mt-1 shrink-0">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border/20 bg-black/20">
<span className="text-[10px] font-mono text-muted-foreground">JSON Config</span>
<button
onClick={() => copySnippet(
activeClient === "roocode" ? connectData.tools.cline?.snippet :
activeClient === "cursor" ? connectData.tools.cursor?.snippet :
activeClient === "opencode" ? connectData.tools.opencode?.snippet :
activeClient === "zed" ? connectData.tools.zed?.snippet :
connectData.tools.continue?.snippet
)}
className="text-xs font-semibold text-primary hover:text-primary-foreground flex items-center gap-1 cursor-pointer"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}
<span>{copied ? "Kopiert" : "Kopieren"}</span>
</button>
</div>
<pre className="p-3 max-h-48 overflow-y-auto text-xs font-mono text-cyan-200/90 whitespace-pre scrollbar-thin select-text">
<code>
{activeClient === "roocode" && connectData.tools.cline?.snippet}
{activeClient === "cursor" && connectData.tools.cursor?.snippet}
{activeClient === "opencode" && connectData.tools.opencode?.snippet}
{activeClient === "zed" && connectData.tools.zed?.snippet}
{activeClient === "continue" && connectData.tools.continue?.snippet}
</code>
</pre>
</div>
)}
<button
onClick={() => setActiveClient(null)}
className="w-full h-9 rounded-lg bg-primary text-primary-foreground hover:opacity-90 font-semibold text-xs transition-opacity cursor-pointer mt-2"
>
Schließen
</button>
</div>
</div>
)}
</div>
{/* Legend for the connection graph */}
<div className="flex flex-wrap items-center gap-6 mt-3 text-[10px] text-muted-foreground border-t border-border/20 pt-3">
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-cyan-400" />
<span>Cyan-Fluss: Client-Anfrage an Gateway</span>
</span>
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<span>Grüner Puls: Aktive Verbindung / Warmes Modell geladen</span>
</span>
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded bg-gradient-to-r from-teal-500 to-emerald-500" />
<span>VRAM-Verlauf: Modellspezifischer Speicheranteil</span>
</span>
</div>
</div>
{/* ZONE B.4: Lane-Routing & Policy (UI-editierbar, hot-reload) */}
<LaneEditor />
@@ -759,10 +359,11 @@ export function Cockpit() {
)}
</div>
<button
onClick={() => setShowBrainSwitch(true)}
onClick={() => window.dispatchEvent(new CustomEvent("mc-navigate", { detail: { view: "agent" } }))}
className="h-7 px-3 rounded-lg border border-indigo-500/30 bg-indigo-500/5 text-indigo-300 text-[9px] font-bold uppercase hover:bg-indigo-500/15 transition-all cursor-pointer"
title="Hirn-Wechsel passiert jetzt zentral im Hermes-Tab"
>
Hirn wechseln
Im Hermes-Tab wechseln
</button>
</div>
@@ -1314,60 +915,6 @@ export function Cockpit() {
/>
)}
{/* Agent-Hirn wechseln */}
{showBrainSwitch && (
<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">
<Bot className="h-4 w-4" /> Agent-Hirn wechseln
</h3>
<button onClick={() => setShowBrainSwitch(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-[11px] text-muted-foreground leading-relaxed">
Wähle ein <strong className="text-foreground">installiertes</strong> Modell als Agent-Hirn. Es bekommt den
<code className="text-primary"> hermes</code>-Alias, wird warm gehalten (brains-Gruppe), und Hermes nutzt es nach
einem kurzen Gateway-Restart. Neues Modell (z.B. Hermes-4.3 oder Gemma-4)? Erst über Modelle finden" laden.
</p>
<div className="space-y-1.5 max-h-72 overflow-y-auto pr-1">
{models.map((m) => {
const isBrain = m.role === "hermes"
return (
<button
key={m.name}
onClick={() => !isBrain && handleSetBrain(m.name)}
disabled={isBrain || m.incomplete}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-xs flex items-center justify-between font-mono border transition-all",
isBrain ? "border-indigo-500/40 bg-indigo-500/10 text-indigo-300"
: m.incomplete ? "border-border/20 bg-background/10 text-muted-foreground/50 cursor-not-allowed"
: "border-border/30 bg-background/20 text-foreground hover:bg-accent cursor-pointer"
)}
>
<div className="flex flex-col min-w-0">
<span className="font-semibold truncate max-w-[280px]">{m.name.split("/").pop()?.replace(/\.gguf$/i, "")}</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{m.capabilities.params_b ? `${m.capabilities.params_b}B` : "?"} · {fmtSize(m.size_bytes)}
{m.role && ` · Rolle: ${m.role}`}{m.capabilities.tools !== "no" ? " · Tools " : " · ohne Tools"}
</span>
</div>
{isBrain
? <span className="text-[9px] font-bold uppercase text-indigo-300 shrink-0 flex items-center gap-1"><Check className="h-3.5 w-3.5" /> Aktiv</span>
: <span className="text-[9px] font-bold uppercase text-primary/70 shrink-0">Als Hirn setzen →</span>}
</button>
)
})}
</div>
<div className="text-[10px] text-amber-400/80 flex items-start gap-1.5 border-t border-border/20 pt-2.5">
<span>💡</span>
<span>Für einen <strong>Agenten</strong> sind Hermes-Modelle (natives Tool-Calling) meist die robustere Wahl als allgemeine Modelle.</span>
</div>
</div>
</div>
)}
{dialogElement}
</div>
)