364 lines
19 KiB
TypeScript
364 lines
19 KiB
TypeScript
import { useEffect, useState, useRef, useCallback } from "react"
|
|
import { Bot, ExternalLink, Activity, Cpu, Wrench, Shield } from "lucide-react"
|
|
import { api, type AgentStatus } from "@/lib/api"
|
|
import { cn, resolveExternalUrl } from "@/lib/utils"
|
|
|
|
function Tile({ label, ok, detail, icon: Icon }: { label: string; ok: boolean; detail?: string; icon: any }) {
|
|
return (
|
|
<div className={cn(
|
|
"rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-3 hover:border-primary/40 transition-all duration-300",
|
|
ok ? "border-border/60" : "border-amber-500/30"
|
|
)}>
|
|
<div 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>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function AgentView() {
|
|
const [s, setS] = useState<AgentStatus | null>(null)
|
|
const [error, setError] = useState("")
|
|
|
|
// Graph UI state
|
|
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
|
|
|
|
// 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 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}`
|
|
}
|
|
|
|
function loadData() {
|
|
api<AgentStatus>("/api/agent/status").then(setS).catch((e) => setError(String(e)))
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadData()
|
|
const t = setInterval(loadData, 5000)
|
|
return () => clearInterval(t)
|
|
}, [])
|
|
|
|
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>
|
|
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
|
|
Hermes Agenten-Cockpit
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Hermes ist ein autonomer Agent, der im Hintergrund der Box läuft (Port <code>:8642</code>) und seine eigene UI besitzt.
|
|
</p>
|
|
</div>
|
|
|
|
{s?.webui_url && (
|
|
<a
|
|
href={resolveExternalUrl(s.webui_url)}
|
|
target="_blank"
|
|
rel="noopener"
|
|
className={cn(
|
|
"flex h-9 px-4 items-center gap-1.5 text-xs font-semibold rounded-lg transition-all cursor-pointer shadow-md shrink-0 self-start",
|
|
s.webui_reachable
|
|
? "bg-primary text-primary-foreground hover:opacity-90 shadow-primary/10"
|
|
: "border border-border/60 text-muted-foreground bg-background/20"
|
|
)}
|
|
>
|
|
<ExternalLink className="h-4 w-4" />
|
|
<span>Hermes WebUI öffnen</span>
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400 font-mono">
|
|
Status nicht lesbar ({error}).
|
|
</div>
|
|
)}
|
|
|
|
{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="Agent WebUI"
|
|
ok={s.webui_reachable}
|
|
detail="Port :8787 (Chat UI)"
|
|
icon={Activity}
|
|
/>
|
|
<Tile
|
|
label="Aktives Gehirn"
|
|
ok={s.gateway_reachable}
|
|
detail={s.brain_model ? `Model: ${s.brain_model}` : "Model: auto"}
|
|
icon={Cpu}
|
|
/>
|
|
<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>
|
|
</div>
|
|
|
|
{/* The Graph Canvas Area */}
|
|
<div ref={containerRef} className="relative w-full h-[360px] 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: WebUI 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 === "webui" || s.webui_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("webui")}
|
|
onMouseLeave={() => setHoveredNode(null)}
|
|
onClick={() => s.webui_reachable && window.open(resolveExternalUrl(s.webui_url), "_blank")}
|
|
title={s.webui_reachable ? "Klicken um Chat-WebUI zu öffnen" : "WebUI Offline"}
|
|
>
|
|
<Activity className={cn("h-3.5 w-3.5", s.webui_reachable ? "text-emerald-400" : "text-amber-500")} />
|
|
<span>Agent WebUI</span>
|
|
<span className={cn("w-1.5 h-1.5 rounded-full animate-pulse ml-auto", s.webui_reachable ? "bg-emerald-500" : "bg-amber-500")} />
|
|
</div>
|
|
|
|
{/* 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">
|
|
<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>
|
|
</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-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.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)}
|
|
>
|
|
<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>
|
|
</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>
|
|
{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>
|
|
|
|
{/* SSH / Sandbox-Hilfe Card */}
|
|
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-6 space-y-4 shadow-lg shadow-black/10">
|
|
<div className="flex items-center gap-2">
|
|
<Shield className="h-5 w-5 text-primary" />
|
|
<h3 className="text-sm font-bold uppercase tracking-wider text-foreground">SSH-Kopplung & Sandbox-Umgehung</h3>
|
|
</div>
|
|
|
|
<div className="grid gap-6 md:grid-cols-2 text-xs text-muted-foreground leading-relaxed text-left">
|
|
<div className="space-y-3">
|
|
<h4 className="text-xs font-bold text-foreground">1. Warum verweigert Hermes Aktionen auf dem PC?</h4>
|
|
<p>
|
|
Hermes ist standardmäßig so ausgerichtet, dass er sich als virtueller Assistent ohne Systemzugriff versteht.
|
|
Um dies zu umgehen, musst du Hermes' **System-Prompt** in der Chat-WebUI anpassen.
|
|
</p>
|
|
<div className="p-3 bg-background/25 rounded-xl border border-border/30 font-mono text-[9px] text-cyan-300">
|
|
<span className="text-muted-foreground/60">Beispiel System-Prompt Erweiterung:</span>
|
|
<br />
|
|
"Du hast über das terminal-Tool vollen SSH-Zugriff auf den Windows-PC des Users (TobisPC). Nutze SSH-Befehle um Aktionen dort auszuführen, wenn danach gefragt wird. Behaupte nie, du könntest das nicht!"
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<h4 className="text-xs font-bold text-foreground">2. SSH-Verbindung zum lokalen Windows-PC einrichten</h4>
|
|
<p>
|
|
Damit der Agent Befehle auf deinem PC ausführen kann, muss OpenSSH auf Windows aktiv und mit einem Key gekoppelt sein:
|
|
</p>
|
|
<ul className="list-disc pl-4 space-y-1.5 text-[11px]">
|
|
<li><strong>OpenSSH Server auf Windows starten:</strong> In PowerShell als Admin ausführen: <code className="text-foreground font-mono">Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0</code></li>
|
|
<li><strong>SSH-Key auf der Box erzeugen:</strong> <code className="text-foreground font-mono">ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_hermes_agent</code></li>
|
|
<li><strong>Key autorisieren:</strong> Kopiere den Inhalt von <code className="text-foreground font-mono">~/.ssh/id_ed25519_hermes_agent.pub</code> in deine Windows-Datei <code className="text-foreground font-mono">C:\Users\TobisPC\.ssh\authorized_keys</code></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Diagnostic Details if Offline */}
|
|
{!s.gateway_reachable && (
|
|
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-6 space-y-4 shadow-lg shadow-black/10 text-left">
|
|
<div className="flex items-center gap-2">
|
|
<Shield className="h-5 w-5 text-amber-500" />
|
|
<h3 className="text-sm font-bold uppercase tracking-wider text-foreground">Hermes-Agent Diagnostics</h3>
|
|
</div>
|
|
|
|
<div className="text-xs text-muted-foreground space-y-3 leading-relaxed">
|
|
<p>
|
|
Der Hermes-Dienst ist auf der Box derzeit offline oder kann sich nicht mit dem lokalen llama-swap verbinden.
|
|
</p>
|
|
<p>
|
|
Stelle sicher, dass die systemd-Dienste auf der Box laufen. Du kannst den Status im <strong>OS & Updates Drawer</strong> prüfen und die Dienste bei Bedarf neu starten.
|
|
</p>
|
|
|
|
<div className="p-3.5 bg-background/25 rounded-xl border border-border/30 font-mono text-[10px] space-y-1">
|
|
<div className="text-muted-foreground/60"># Dienste manuell auf der Box prüfen:</div>
|
|
<div className="text-primary">systemctl --user status hermes-gateway</div>
|
|
<div className="text-primary">systemctl --user status hermes-webui</div>
|
|
</div>
|
|
|
|
<p>
|
|
Weitere Einrichtungsdetails (Brain-Konfiguration, MCP-Kopplungen, SSH-Tunneling und Such-Engines) findest du in der Dokumentationsdatei:
|
|
<code className="ml-1 px-1.5 py-0.5 rounded bg-muted/40 text-foreground border border-border/30">docs/HERMES_SETUP.md</code>.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|