Files
mission-control-v2/frontend/src/views/AgentView.tsx
T
Hitonabi dcfede7e69 Feat: Hermes-Terminal (ttyd) statt AnythingLLM + AnythingLLM komplett raus
Paket B des Plans. AnythingLLM war nur ein Fallback-Chat zu Hermes; ersetzt durch das
echte interaktive Agent-Terminal (`hermes chat`, mit Tools/PC) als eingebettetes
Web-Terminal.

Box: ttyd (apt) wrappt `hermes chat`; systemd-User-Unit deploy/hermes-terminal.service
(LAN-Bind eno1:7681, apt-Default-ttyd-Dienst deaktiviert). In deploy.sh verankert.

Backend: config HERMES_TERMINAL_URL statt ANYTHINGLLM_URL/_REPO; agent_status liefert
terminal_url/terminal_reachable; maintenance ohne _anythingllm_update; system.py Dienst-Liste
zeigt "Hermes-Terminal".

Frontend: neue Terminal-Seite (iframe auf ttyd) + Nav-Tab; AgentView/AgentStatusCard/nav/api
auf Terminal umgestellt; SystemDrawer toter hermes-dashboard raus, hermes-webui -> hermes-terminal;
Guide-Texte aktualisiert.

Cleanup: deploy/hermes-webui.service + deploy/lobechat/ entfernt (LobeChat-Migration hinfaellig),
HERMES_WEBUI_URL-Env raus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 18:23:37 +02:00

468 lines
24 KiB
TypeScript

import { useState, useRef, useCallback, useMemo } 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>
)
}
export function AgentView() {
const { data: s, error: sErr } = useAgentStatus(5_000)
const { data: modelsData } = useModels()
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 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) {
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.`)
qc.invalidateQueries({ queryKey: qk.agentStatus })
setShowBrainSelect(false)
} catch (e: any) {
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
}
}
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?.terminal_url && (
<a
href={resolveExternalUrl(s.terminal_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.terminal_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>Terminal ö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="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>
</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)}
onClick={() => s.terminal_reachable && window.open(resolveExternalUrl(s.terminal_url), "_blank")}
title={s.terminal_reachable ? "Klicken um das Hermes-Terminal zu öffnen" : "Terminal offline"}
>
<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>
{/* 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-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>
</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>
{/* PC Executor Status 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 justify-between">
<div className="flex items-center gap-2">
<Monitor className="h-5 w-5 text-primary" />
<h3 className="text-sm font-bold uppercase tracking-wider text-foreground">PC Verbindung</h3>
</div>
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", s.pc_executor_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
<span className="text-xs font-semibold text-foreground">
{s.pc_executor_reachable ? "Erreichbar" : "Nicht verbunden"}
</span>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2 text-xs text-muted-foreground leading-relaxed text-left">
<div className="space-y-3">
<p>
Der <strong className="text-foreground">Hermes PC Executor</strong> läuft auf <code className="text-foreground font-mono px-1 py-0.5 rounded bg-muted/40 border border-border/30">192.168.178.98:7777</code> und ermöglicht Hermes den direkten Zugriff auf deinen Windows-PC Shell-Befehle, Screenshots, Tastatureingaben.
</p>
<p>
Hermes erreicht den PC über den MCP-Server <code className="text-foreground font-mono px-1 py-0.5 rounded bg-muted/40 border border-border/30">hermes-pc-control</code>, der automatisch beim Gateway-Start verbunden wird.
</p>
</div>
<div className="space-y-3">
{s.pc_executor_reachable ? (
<div className="p-3.5 bg-emerald-500/5 border border-emerald-500/20 rounded-xl space-y-1">
<div className="text-emerald-400 font-semibold text-xs flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
PC Executor verbunden
</div>
<p className="text-[10px]">
Hermes kann jetzt per Telegram oder Terminal Befehle auf TobisNicerPC ausführen. Nutze <code className="text-foreground">pc_shell</code>, <code className="text-foreground">pc_screenshot</code> oder <code className="text-foreground">pc_open</code>.
</p>
</div>
) : (
<div className="space-y-2">
<h4 className="text-xs font-bold text-foreground">PC Executor starten</h4>
<p>Starte <code className="text-foreground font-mono">start.bat</code> im Ordner <code className="text-foreground font-mono">client\hermes-pc\</code> auf dem Windows-PC und lasse das Fenster offen.</p>
<div className="p-3 bg-background/25 rounded-xl border border-border/30 font-mono text-[9px] text-cyan-300">
client\hermes-pc\start.bat
</div>
</div>
)}
</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 &amp; 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-terminal</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>
)}
{/* 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>
)}
{dialogElement}
</div>
)
}