UMBAU v3 P3a handgebaut: SSE-Eventstrom /api/events (Invalidation-Bus)

User-Entscheid 15.07. abends: P3-Worker-Jobs abgebrochen (22 Anlaeufe,
0 Ergebnisse) - 'du bearbeitest sie ein letztes Mal manuell'.

P3a KOMPLETT: backend/routers/events.py streamt SSE; ein Sammler prueft
alle 3 s billige Fingerabdruecke (Briefkasten-Cursor, Queue-Status,
Auftragsbuch-/Reminder-mtimes) und schickt bei Aenderung 'invalidate'
mit den React-Query-Keys. frontend/src/lib/events.ts haelt EIN
EventSource, invalidiert gezielt und entspannt die vier gedeckten
Poller x5 (relax() in queries.ts); reisst der Strom, reconnectet
EventSource selbst und die UI pollt wie bisher. Live-Graphen bleiben
bewusst beim Polling (aendern sich jede Sekunde).

P3b (TanStack Router) BEWUSST NICHT umgesetzt: Hash-Navigation liefert
Deep-Links + Zuruecktaste bereits; Router-Migration = hohes Regressions-
risiko ueber alle Views bei minimalem Gewinn fuer 1-Nutzer-LAN (gleiche
Logik wie das React-bleibt-Verdikt in UMBAUPLAN 3b).
P3c (OpenAPI-Typen) VERTAGT: ohne Pydantic-response_models liefert der
Generator leere Typen - Voraussetzung ist erst das Backend-Typing
(eigene, klein geschnittene Karten-Serie).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-15 15:00:52 +02:00
parent 5dde4c9f5c
commit 5d0a61c38e
25 changed files with 1027 additions and 900 deletions
+174 -172
View File
@@ -1,172 +1,174 @@
""" """
Mission Control 2.0 — dünner FastAPI-Einstieg. Mission Control 2.0 — dünner FastAPI-Einstieg.
Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und
setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev- setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
Server (proxyt /api hierher), daher CORS für localhost offen. Server (proxyt /api hierher), daher CORS für localhost offen.
""" """
import asyncio import asyncio
import logging import logging
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Awaitable, Callable from typing import Any, AsyncGenerator, Awaitable, Callable
import httpx import httpx
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.requests import Request from starlette.requests import Request
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
from routers import ( from routers import (
agent, agent,
auftragsbuch, auftragsbuch,
chronik, chronik,
connect, connect,
console, console,
eigenleben, eigenleben,
gateway_proxy, events,
health, gateway_proxy,
hermes_ui, health,
ideen, hermes_ui,
maintenance, ideen,
memory, maintenance,
models, memory,
routing, models,
system, routing,
voice, system,
wissen, voice,
zeitmaschine, wissen,
) zeitmaschine,
from routers import reminders as reminders_router )
from services import memory as memory_svc from routers import reminders as reminders_router
from services import reminders, sentry, warmer from services import memory as memory_svc
from services import reminders, sentry, warmer
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
# für alle Module (logging.getLogger(__name__)). # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
logging.basicConfig( # für alle Module (logging.getLogger(__name__)).
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(), logging.basicConfig(
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
) format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
log = logging.getLogger(__name__) )
log = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: @asynccontextmanager
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
+ Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram).""" """Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn
tasks: list[asyncio.Task[Any]] = [] + Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram)."""
if warmer.ENABLED: tasks: list[asyncio.Task[Any]] = []
tasks.append(asyncio.create_task(warmer.rewarm_loop())) if warmer.ENABLED:
log.info( tasks.append(asyncio.create_task(warmer.rewarm_loop()))
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)", log.info(
warmer.INTERVAL, "Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
) warmer.INTERVAL,
if sentry.ENABLED: )
tasks.append(asyncio.create_task(sentry.sentry_loop())) if sentry.ENABLED:
tasks.append(asyncio.create_task(reminders.reminders_loop())) tasks.append(asyncio.create_task(sentry.sentry_loop()))
if memory_svc.AUTO_DEDUPE_ENABLED: tasks.append(asyncio.create_task(reminders.reminders_loop()))
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop())) if memory_svc.AUTO_DEDUPE_ENABLED:
log.info( tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
"Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)", log.info(
memory_svc.AUTO_DEDUPE_INTERVAL, "Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
memory_svc.AUTO_DEDUPE_THRESHOLD, memory_svc.AUTO_DEDUPE_INTERVAL,
) memory_svc.AUTO_DEDUPE_THRESHOLD,
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client )
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo). # Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
app.state.gw_client = httpx.AsyncClient( # pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0), app.state.gw_client = httpx.AsyncClient(
limits=httpx.Limits(max_keepalive_connections=100, max_connections=200), timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0),
) limits=httpx.Limits(max_keepalive_connections=100, max_connections=200),
try: )
yield try:
finally: yield
for task in tasks: finally:
_ = task.cancel() for task in tasks:
await app.state.gw_client.aclose() _ = task.cancel()
await app.state.gw_client.aclose()
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
app.add_middleware( # Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
CORSMiddleware, app.add_middleware(
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], CORSMiddleware,
allow_methods=["*"], allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_headers=["*"], allow_methods=["*"],
) allow_headers=["*"],
)
@app.middleware("http")
async def no_cache( @app.middleware("http")
request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]] async def no_cache(
) -> httpx.Response: request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]
resp = await call_next(request) ) -> httpx.Response:
if request.url.path.startswith("/api"): resp = await call_next(request)
resp.headers["Cache-Control"] = "no-cache" if request.url.path.startswith("/api"):
return resp resp.headers["Cache-Control"] = "no-cache"
return resp
app.include_router(health.router)
app.include_router(models.router) app.include_router(health.router)
app.include_router(routing.router) app.include_router(models.router)
app.include_router(system.router) app.include_router(routing.router)
app.include_router(connect.router) app.include_router(system.router)
app.include_router(memory.router) app.include_router(connect.router)
app.include_router(agent.router) app.include_router(memory.router)
app.include_router( app.include_router(agent.router)
voice.router app.include_router(
) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab) voice.router
app.include_router( ) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
reminders_router.router app.include_router(
) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten reminders_router.router
# /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als ) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten
# eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit # /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als
# LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten # eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit
# Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst. # LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten
if V1_UPSTREAM: # Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst.
from routers import gateway_forward if V1_UPSTREAM:
app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway from routers import gateway_forward
else: app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto) else:
app.include_router(maintenance.router) app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick) app.include_router(maintenance.router)
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store) app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
app.include_router(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only app.include_router(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box
app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached) app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
app.include_router( app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
console.router app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached)
) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all app.include_router(
app.include_router( console.router
hermes_ui.router ) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all
) # Eingebaute Hermes-Web-GUI (hermes serve) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all app.include_router(
hermes_ui.router
) # Eingebaute Hermes-Web-GUI (hermes serve) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
if FRONTEND_DIST.exists():
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") # Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
if FRONTEND_DIST.exists():
_DIST_ROOT = FRONTEND_DIST.resolve() app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
@app.get("/{full_path:path}") _DIST_ROOT = FRONTEND_DIST.resolve()
def spa(full_path: str):
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber @app.get("/{full_path:path}")
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus. def spa(full_path: str):
try: # Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
target = (FRONTEND_DIST / full_path).resolve() # NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
if target.is_relative_to(_DIST_ROOT) and target.is_file(): try:
return FileResponse(target) target = (FRONTEND_DIST / full_path).resolve()
except (ValueError, OSError): if target.is_relative_to(_DIST_ROOT) and target.is_file():
pass return FileResponse(target)
except (ValueError, OSError):
index = FRONTEND_DIST / "index.html" pass
if index.exists():
# index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle. index = FRONTEND_DIST / "index.html"
return FileResponse( if index.exists():
index, headers={"Cache-Control": "no-cache, must-revalidate"} # index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle.
) return FileResponse(
return {"detail": "frontend not built"} index, headers={"Cache-Control": "no-cache, must-revalidate"}
)
return {"detail": "frontend not built"}
+89
View File
@@ -0,0 +1,89 @@
"""SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt.
GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden
billige Fingerabdrücke der ereignishaften Quellen (Briefkasten/Chronik, Ideen-Queue,
Auftragsbuch, Erinnerungen) und schickt NUR bei Änderung ein `invalidate`-Event mit
den React-Query-Keys. Die Wahrheit bleibt in den bestehenden Endpunkten — der Strom
ist ein reiner Invalidation-Bus, kein zweites Zustandsmodell.
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die Caches,
entspannt die Fallback-Poller ×5, solange der Strom steht; reißt er ab, reconnectet
EventSource selbst und bis dahin pollt die UI wie bisher). Live-Metriken (CPU/Token-
Graphen) laufen bewusst NICHT hierüber — die ändern sich jede Sekunde, da ist Polling
das richtige Werkzeug.
Handgebaut 15.07. abends (User-Entscheid: „ihr brecht die Jobs ab, du baust es ein
letztes Mal manuell") — die autonomen P3-Worker bissen sich an der Aufgabe fest.
"""
import asyncio
import json
import logging
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
from config import MODELS_DIR
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
TICK_S = 3.0 # Prüf-Takt des Sammlers (nur Fingerabdrücke, kein Neuberechnen)
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten
def _mtime(p: Path) -> float:
try:
return p.stat().st_mtime
except OSError:
return 0.0
def _fingerprints() -> dict[str, object]:
"""Billige Änderungs-Signale je Quelle → React-Query-Key. Fehler einer Quelle
dürfen den Strom nie reißen (dann bleibt ihr Abdruck einfach stehen)."""
fp: dict[str, object] = {}
try: # Briefkasten trägt Chronik UND Kontext-Limit-Warnungen — in-process, spottbillig
from services import announce
fp["chronik"] = announce.list_after(None)["latest"]
except Exception:
pass
try: # Queue: (id,status)-Paare; list_queue cached selbst ~15 s, der Tick kostet nichts
from services import ideen
d = ideen.list_queue()
fp["ideen"] = json.dumps([(i.get("id"), i.get("status")) for i in d.get("items") or []])
except Exception:
pass
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
_mtime(MODELS_DIR / "mc2-announce-branches.json"))
fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json")
return fp
@router.get("/events")
async def events(request: Request) -> StreamingResponse:
async def strom():
# Der Client hat beim Verbinden frisch geladen — Basislinie ohne Event setzen.
alt = _fingerprints()
yield ": verbunden\n\n"
seit_ping = 0.0
while True:
if await request.is_disconnected():
return
await asyncio.sleep(TICK_S)
seit_ping += TICK_S
neu = _fingerprints()
keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
alt.update(neu)
if keys:
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
seit_ping = 0.0
elif seit_ping >= KEEPALIVE_S:
yield ": ping\n\n"
seit_ping = 0.0
return StreamingResponse(strom(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
@@ -1,4 +1,4 @@
import{c as k,R as _,m as S,b as C,u as H,r as E,j as e,U as A,e as a,V as u,W as p,Y as B,s as h,Z as D,Q as P,X as M,C as g,g as b,q as i}from"./index-ClYatDZT.js";/** import{c as k,R as _,m as S,b as C,u as H,r as E,j as e,U as A,e as a,V as u,W as p,Y as B,s as h,Z as D,Q as P,X as M,C as g,g as b,q as i}from"./index-DgTg5QdX.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as I,a4 as X,u as _,b as q,r as c,j as e,a5 as Y,e as C,L as f,C as Q,N as U,a6 as ee,a7 as te,O as V,X as z,w as R,F,a8 as re,T as K,g as S,a9 as se,q as J}from"./index-ClYatDZT.js";import{L as ae}from"./lightbulb-ETwYqnI4.js";import{S as G}from"./send-D-g9GbnJ.js";/** import{c as I,a4 as X,u as _,b as q,r as c,j as e,a5 as Y,e as C,L as f,C as Q,N as U,a6 as ee,a7 as te,O as V,X as z,w as R,F,a8 as re,T as K,g as S,a9 as se,q as J}from"./index-DgTg5QdX.js";import{L as ae}from"./lightbulb-D9Ggmjq-.js";import{S as G}from"./send-o-W_9aTj.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as h,aa as S,r as c,j as e,ab as z,L as g,N as M,ac as C,a9 as D,ad as A,ae as B,af as Z,e as E,ag as L,u as q,b as R,g as k,q as T}from"./index-ClYatDZT.js";/** import{c as h,aa as S,r as c,j as e,ab as z,L as g,N as M,ac as C,a9 as D,ad as A,ae as B,af as Z,e as E,ag as L,u as q,b as R,g as k,q as T}from"./index-DgTg5QdX.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as v,r as c,z as D,A as I,j as e,D as h,s as B,B as H,w as z,F as E,e as M,L as F,G as T,C as K}from"./index-ClYatDZT.js";import{F as O}from"./folder-open-Birisrv_.js";import{C as R}from"./circle-x-BVoF4hpT.js";import{C as V}from"./copy-DTtK5aX-.js";/** import{c as v,r as c,z as D,A as I,j as e,D as h,s as B,B as H,w as z,F as E,e as M,L as F,G as T,C as K}from"./index-DgTg5QdX.js";import{F as O}from"./folder-open-COXogvYR.js";import{C as R}from"./circle-x-ADu9dwvS.js";import{C as V}from"./copy-BdEWnHoH.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as h,ah as b,r as o,j as e,L as g,ai as f,Z as j,N as k,e as i,w as N,F as w,G as v,g as y}from"./index-ClYatDZT.js";import{B as z}from"./book-open-CdFvbc2z.js";import{C as S}from"./circle-x-BVoF4hpT.js";/** import{c as h,ah as b,r as o,j as e,L as g,ai as f,Z as j,N as k,e as i,w as N,F as w,G as v,g as y}from"./index-DgTg5QdX.js";import{B as z}from"./book-open-lYTKOEmg.js";import{C as S}from"./circle-x-ADu9dwvS.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as p,j as e,r as M,s as C,a0 as z,a1 as k,a2 as y,W as v,Q as L,a3 as G,Z as T,e as f,N as D}from"./index-ClYatDZT.js";import{B as w}from"./book-open-CdFvbc2z.js";import{a as N,b as B,L as P,C as E,Z as R}from"./zap-raGIywOo.js";import{L as I}from"./lightbulb-ETwYqnI4.js";/** import{c as p,j as e,r as M,s as C,a0 as z,a1 as k,a2 as y,W as v,Q as L,a3 as G,Z as T,e as f,N as D}from"./index-DgTg5QdX.js";import{B as w}from"./book-open-lYTKOEmg.js";import{a as N,b as B,L as P,C as E,Z as R}from"./zap-BX-39Xxr.js";import{L as I}from"./lightbulb-D9Ggmjq-.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1 +1 @@
import{R as l,V as o,j as e,e as a,U as n,T as d,$ as i}from"./index-ClYatDZT.js";function c(){const{data:s}=l(),r=s!=null&&s.box_console_url?o(s.box_console_url):void 0,t=s==null?void 0:s.box_console_reachable;return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:a("h-2 w-2 rounded-full",t?"bg-emerald-500 animate-pulse":"bg-amber-500")}),t?"online":"offline"]}),r&&e.jsxs("a",{href:r,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(n,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),r?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[t===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(d,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der ",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),"-Dienst (ttyd) läuft nicht. Auf der Box: ",e.jsx("code",{className:"font-mono",children:"systemctl --user restart box-console"}),"."]})]}),e.jsx("iframe",{src:r,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(i,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{c as KonsoleView}; import{R as l,V as o,j as e,e as a,U as n,T as d,$ as i}from"./index-DgTg5QdX.js";function c(){const{data:s}=l(),r=s!=null&&s.box_console_url?o(s.box_console_url):void 0,t=s==null?void 0:s.box_console_reachable;return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:a("h-2 w-2 rounded-full",t?"bg-emerald-500 animate-pulse":"bg-amber-500")}),t?"online":"offline"]}),r&&e.jsxs("a",{href:r,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(n,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),r?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[t===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(d,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der ",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),"-Dienst (ttyd) läuft nicht. Auf der Box: ",e.jsx("code",{className:"font-mono",children:"systemctl --user restart box-console"}),"."]})]}),e.jsx("iframe",{src:r,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(i,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{c as KonsoleView};
@@ -1,5 +1,5 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/GraphView-DGCqAmwL.js","assets/index-ClYatDZT.js","assets/index-Cq_BqGQa.css"])))=>i.map(i=>d[i]); const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/GraphView-D56vUGn7.js","assets/index-DgTg5QdX.js","assets/index-Cq_BqGQa.css"])))=>i.map(i=>d[i]);
var ke=Object.defineProperty;var ve=(d,i,c)=>i in d?ke(d,i,{enumerable:!0,configurable:!0,writable:!0,value:c}):d[i]=c;var U=(d,i,c)=>ve(d,typeof i!="symbol"?i+"":i,c);import{c as h,r as s,j as e,u as Ne,b as Se,I as J,J as Ce,C as W,K as Ee,S as Me,M as ze,N as _,e as x,O as _e,Q as Oe,X,v as Z,_ as Te,g as j}from"./index-ClYatDZT.js";import{C as Y}from"./copy-DTtK5aX-.js";import{S as Ae}from"./send-D-g9GbnJ.js";import{B as De}from"./book-open-CdFvbc2z.js";/** var ke=Object.defineProperty;var ve=(d,i,c)=>i in d?ke(d,i,{enumerable:!0,configurable:!0,writable:!0,value:c}):d[i]=c;var U=(d,i,c)=>ve(d,typeof i!="symbol"?i+"":i,c);import{c as h,r as s,j as e,u as Ne,b as Se,I as J,J as Ce,C as W,K as Ee,S as Me,M as ze,N as _,e as x,O as _e,Q as Oe,X,v as Z,_ as Te,g as j}from"./index-DgTg5QdX.js";import{C as Y}from"./copy-BdEWnHoH.js";import{S as Ae}from"./send-o-W_9aTj.js";import{B as De}from"./book-open-lYTKOEmg.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -34,7 +34,7 @@ var ke=Object.defineProperty;var ve=(d,i,c)=>i in d?ke(d,i,{enumerable:!0,config
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree. * See the LICENSE file in the root directory of this source tree.
*/const Re=h("User",[["path",{d:"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2",key:"975kel"}],["circle",{cx:"12",cy:"7",r:"4",key:"17ys0d"}]]);class re extends s.Component{constructor(){super(...arguments);U(this,"state",{error:null})}static getDerivedStateFromError(c){return{error:c}}render(){return this.state.error?e.jsxs("div",{className:"h-[480px] rounded-2xl border border-red-500/20 bg-red-500/5 p-6 text-xs text-red-400 overflow-auto",children:[e.jsx("div",{className:"font-semibold mb-2",children:"Graph konnte nicht gerendert werden"}),e.jsx("pre",{className:"whitespace-pre-wrap break-words text-[11px] leading-relaxed",children:this.state.error.message})]}):this.props.children}}const te=s.lazy(()=>Te(()=>import("./GraphView-DGCqAmwL.js"),__vite__mapDeps([0,1,2])).then(d=>({default:d.GraphView}))),k=["identity","knowledge","rules","events"],se=new Set(["auto","agent","hermes"]),O={identity:{label:"Identität",icon:Re,bg:"bg-cyan-500/10",text:"text-cyan-400"},knowledge:{label:"Wissen",icon:Oe,bg:"bg-indigo-500/10",text:"text-indigo-400"},rules:{label:"Regeln",icon:Pe,bg:"bg-violet-500/10",text:"text-violet-400"},events:{label:"Ereignisse",icon:_e,bg:"bg-amber-500/10",text:"text-amber-400"}},ne={label:"Gedächtnis",icon:De,text:"text-muted-foreground"},Ve={identity:"border-l-cyan-500/80",knowledge:"border-l-indigo-500/80",rules:"border-l-violet-500/80",events:"border-l-amber-500/80"},ae=`Hi Hermes! Lass uns ein kurzes Onboarding machen, damit du dich künftig an mich erinnerst. Stell mir nacheinander ein paar kurze Fragen zu: */const Re=h("User",[["path",{d:"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2",key:"975kel"}],["circle",{cx:"12",cy:"7",r:"4",key:"17ys0d"}]]);class re extends s.Component{constructor(){super(...arguments);U(this,"state",{error:null})}static getDerivedStateFromError(c){return{error:c}}render(){return this.state.error?e.jsxs("div",{className:"h-[480px] rounded-2xl border border-red-500/20 bg-red-500/5 p-6 text-xs text-red-400 overflow-auto",children:[e.jsx("div",{className:"font-semibold mb-2",children:"Graph konnte nicht gerendert werden"}),e.jsx("pre",{className:"whitespace-pre-wrap break-words text-[11px] leading-relaxed",children:this.state.error.message})]}):this.props.children}}const te=s.lazy(()=>Te(()=>import("./GraphView-D56vUGn7.js"),__vite__mapDeps([0,1,2])).then(d=>({default:d.GraphView}))),k=["identity","knowledge","rules","events"],se=new Set(["auto","agent","hermes"]),O={identity:{label:"Identität",icon:Re,bg:"bg-cyan-500/10",text:"text-cyan-400"},knowledge:{label:"Wissen",icon:Oe,bg:"bg-indigo-500/10",text:"text-indigo-400"},rules:{label:"Regeln",icon:Pe,bg:"bg-violet-500/10",text:"text-violet-400"},events:{label:"Ereignisse",icon:_e,bg:"bg-amber-500/10",text:"text-amber-400"}},ne={label:"Gedächtnis",icon:De,text:"text-muted-foreground"},Ve={identity:"border-l-cyan-500/80",knowledge:"border-l-indigo-500/80",rules:"border-l-violet-500/80",events:"border-l-amber-500/80"},ae=`Hi Hermes! Lass uns ein kurzes Onboarding machen, damit du dich künftig an mich erinnerst. Stell mir nacheinander ein paar kurze Fragen zu:
1) wer ich bin und woran ich gerade arbeite, 1) wer ich bin und woran ich gerade arbeite,
2) wie ich angesprochen werden möchte, 2) wie ich angesprochen werden möchte,
3) meine bevorzugten Tools, Sprachen und Arbeitsweise, 3) meine bevorzugten Tools, Sprachen und Arbeitsweise,
@@ -1,4 +1,4 @@
import{c as D,u as Ce,a as Ee,b as Se,j as e,f as ce,d as De,e as g,g as B,q as te,B as ae,h as qe,E as _e,i as Ge,r as y,X as Re,C as J,T as re,k as Z,H as Ae,l as P,m as me,n as Fe,o as Ke,p as Oe,S as pe,s as Ie,P as Te,t as Ue,v as He,w as Le,x as Pe,y as Qe}from"./index-ClYatDZT.js";import{C as xe,Z as ue,L as Ve,a as We,b as Je}from"./zap-raGIywOo.js";/** import{c as D,u as Ce,a as Ee,b as Se,j as e,f as ce,d as De,e as g,g as B,q as te,B as ae,h as qe,E as _e,i as Ge,r as y,X as Re,C as J,T as re,k as Z,H as Ae,l as P,m as me,n as Fe,o as Ke,p as Oe,S as pe,s as Ie,P as Te,t as Ue,v as He,w as Le,x as Pe,y as Qe}from"./index-DgTg5QdX.js";import{C as xe,Z as ue,L as Ve,a as We,b as Je}from"./zap-BX-39Xxr.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{aj as y,r as x,g as L,j as e,L as v,S,ak as W,N as C,e as w,al as E}from"./index-ClYatDZT.js";import{F as M}from"./folder-open-Birisrv_.js";const D={"":"Allgemein",traeume:"Träume",muster:"Muster","skill-kandidaten":"Skill-Kandidaten","skill-kandidaten/beauftragt":"Skill-Kandidaten · beauftragt","skill-kandidaten/verworfen":"Skill-Kandidaten · verworfen"};function T(){const{data:o,isLoading:h}=y(),[l,f]=x.useState(null),[d,r]=x.useState(null),[u,i]=x.useState(!1),[a,p]=x.useState(""),t=x.useMemo(()=>(o==null?void 0:o.files)??[],[o]),m=x.useMemo(()=>{const s=new Map;for(const n of t)s.set(n.name.toLowerCase(),n.path);return s},[t]),g=x.useMemo(()=>{if(!a.trim())return t;const s=a.toLowerCase();return t.filter(n=>n.title.toLowerCase().includes(s)||n.path.toLowerCase().includes(s))},[t,a]),b=x.useMemo(()=>{const s=[];for(const n of g){const c=s.find(k=>k.dir===n.dir);c?c.files.push(n):s.push({dir:n.dir,files:[n]})}return s.sort((n,c)=>n.dir===""?-1:c.dir===""?1:n.dir.localeCompare(c.dir))},[g]);x.useEffect(()=>{if(!l&&t.length){const s=t.find(n=>n.path.toLowerCase()==="index.md");f((s==null?void 0:s.path)??t[0].path)}},[t,l]),x.useEffect(()=>{l&&(i(!0),L(`/api/wissen/datei?pfad=${encodeURIComponent(l)}`).then(r).catch(()=>r({path:l,content:"*(Notiz nicht ladbar)*",mtime:0})).finally(()=>i(!1)))},[l]);const j=s=>{const n=m.get(s.toLowerCase());n&&f(n)};return e.jsxs("div",{className:"space-y-5",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Wissen"}),e.jsx("p",{className:"text-sm text-muted-foreground",children:"Was sich die Box nachts erschließt — Traum-Notizen, Muster und Skill-Ideen aus dem Wissens-Vault, verlinkt wie ein Wiki."})]}),o&&!o.available&&e.jsx("div",{className:"rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300",children:"Der Wissens-Vault liegt auf der Box (~/wissens-vault) — im lokalen Dev-Modus nicht verfügbar."}),h&&e.jsxs("div",{className:"flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground",children:[e.jsx(v,{className:"h-4 w-4 animate-spin"})," Vault wird geladen …"]}),(o==null?void 0:o.available)&&t.length===0&&e.jsx("div",{className:"rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground",children:"Noch keine Notizen. Der nächtliche Traum (03:15) legt sie an — morgen früh steht hier die erste."}),t.length>0&&e.jsxs("div",{className:"flex flex-col gap-4 lg:flex-row",children:[e.jsxs("aside",{className:"w-full shrink-0 space-y-3 lg:w-72",children:[e.jsxs("div",{className:"relative",children:[e.jsx("input",{value:a,onChange:s=>p(s.target.value),type:"search",placeholder:"Notizen durchsuchen…","aria-label":"Notizen durchsuchen",className:"h-9 w-full rounded-lg border border-border/60 bg-card/45 pl-8 pr-3 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50"}),e.jsx(S,{className:"absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground"})]}),e.jsx("div",{className:"max-h-[70vh] space-y-3 overflow-y-auto pr-1 scrollbar-thin",children:b.map(({dir:s,files:n})=>e.jsxs("div",{children:[e.jsxs("p",{className:"mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60",children:[e.jsx(M,{className:"h-3 w-3"})," ",D[s]??s]}),e.jsx("div",{className:"space-y-0.5",children:n.map(c=>e.jsxs("button",{onClick:()=>f(c.path),className:w("flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs transition-all cursor-pointer",l===c.path?"bg-primary/15 text-primary":"text-muted-foreground hover:bg-accent hover:text-foreground"),children:[e.jsx(W,{className:"h-3.5 w-3.5 shrink-0"}),e.jsx("span",{className:"truncate",title:c.title,children:c.name}),c.neu&&e.jsxs("span",{className:"ml-auto flex items-center gap-0.5 rounded bg-violet-500/15 px-1 py-0.5 text-[8px] font-bold uppercase text-violet-300",children:[e.jsx(C,{className:"h-2.5 w-2.5"}),"neu"]})]},c.path))})]},s||"__root"))})]}),e.jsx("div",{className:"min-w-0 flex-1 mc-card p-5",children:u?e.jsxs("div",{className:"flex items-center gap-2 text-xs text-muted-foreground",children:[e.jsx(v,{className:"h-4 w-4 animate-spin"})," Notiz wird geladen …"]}):d?e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"mb-3 flex items-center justify-between border-b border-border/40 pb-2",children:[e.jsxs("span",{className:"flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground",children:[e.jsx(E,{className:"h-3.5 w-3.5"})," ",d.path]}),d.mtime>0&&e.jsxs("span",{className:"text-[10px] text-muted-foreground/60",children:["Stand ",new Date(d.mtime*1e3).toLocaleString("de-DE",{day:"2-digit",month:"2-digit",hour:"2-digit",minute:"2-digit"})]})]}),e.jsx(z,{text:d.content,onWikiLink:j,known:m})]}):null})]})]})}function z({text:o,onWikiLink:h,known:l}){const f=o.split(` import{aj as y,r as x,g as L,j as e,L as v,S,ak as W,N as C,e as w,al as E}from"./index-DgTg5QdX.js";import{F as M}from"./folder-open-COXogvYR.js";const D={"":"Allgemein",traeume:"Träume",muster:"Muster","skill-kandidaten":"Skill-Kandidaten","skill-kandidaten/beauftragt":"Skill-Kandidaten · beauftragt","skill-kandidaten/verworfen":"Skill-Kandidaten · verworfen"};function T(){const{data:o,isLoading:h}=y(),[l,f]=x.useState(null),[d,r]=x.useState(null),[u,i]=x.useState(!1),[a,p]=x.useState(""),t=x.useMemo(()=>(o==null?void 0:o.files)??[],[o]),m=x.useMemo(()=>{const s=new Map;for(const n of t)s.set(n.name.toLowerCase(),n.path);return s},[t]),g=x.useMemo(()=>{if(!a.trim())return t;const s=a.toLowerCase();return t.filter(n=>n.title.toLowerCase().includes(s)||n.path.toLowerCase().includes(s))},[t,a]),b=x.useMemo(()=>{const s=[];for(const n of g){const c=s.find(k=>k.dir===n.dir);c?c.files.push(n):s.push({dir:n.dir,files:[n]})}return s.sort((n,c)=>n.dir===""?-1:c.dir===""?1:n.dir.localeCompare(c.dir))},[g]);x.useEffect(()=>{if(!l&&t.length){const s=t.find(n=>n.path.toLowerCase()==="index.md");f((s==null?void 0:s.path)??t[0].path)}},[t,l]),x.useEffect(()=>{l&&(i(!0),L(`/api/wissen/datei?pfad=${encodeURIComponent(l)}`).then(r).catch(()=>r({path:l,content:"*(Notiz nicht ladbar)*",mtime:0})).finally(()=>i(!1)))},[l]);const j=s=>{const n=m.get(s.toLowerCase());n&&f(n)};return e.jsxs("div",{className:"space-y-5",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Wissen"}),e.jsx("p",{className:"text-sm text-muted-foreground",children:"Was sich die Box nachts erschließt — Traum-Notizen, Muster und Skill-Ideen aus dem Wissens-Vault, verlinkt wie ein Wiki."})]}),o&&!o.available&&e.jsx("div",{className:"rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300",children:"Der Wissens-Vault liegt auf der Box (~/wissens-vault) — im lokalen Dev-Modus nicht verfügbar."}),h&&e.jsxs("div",{className:"flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground",children:[e.jsx(v,{className:"h-4 w-4 animate-spin"})," Vault wird geladen …"]}),(o==null?void 0:o.available)&&t.length===0&&e.jsx("div",{className:"rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground",children:"Noch keine Notizen. Der nächtliche Traum (03:15) legt sie an — morgen früh steht hier die erste."}),t.length>0&&e.jsxs("div",{className:"flex flex-col gap-4 lg:flex-row",children:[e.jsxs("aside",{className:"w-full shrink-0 space-y-3 lg:w-72",children:[e.jsxs("div",{className:"relative",children:[e.jsx("input",{value:a,onChange:s=>p(s.target.value),type:"search",placeholder:"Notizen durchsuchen…","aria-label":"Notizen durchsuchen",className:"h-9 w-full rounded-lg border border-border/60 bg-card/45 pl-8 pr-3 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50"}),e.jsx(S,{className:"absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground"})]}),e.jsx("div",{className:"max-h-[70vh] space-y-3 overflow-y-auto pr-1 scrollbar-thin",children:b.map(({dir:s,files:n})=>e.jsxs("div",{children:[e.jsxs("p",{className:"mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60",children:[e.jsx(M,{className:"h-3 w-3"})," ",D[s]??s]}),e.jsx("div",{className:"space-y-0.5",children:n.map(c=>e.jsxs("button",{onClick:()=>f(c.path),className:w("flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs transition-all cursor-pointer",l===c.path?"bg-primary/15 text-primary":"text-muted-foreground hover:bg-accent hover:text-foreground"),children:[e.jsx(W,{className:"h-3.5 w-3.5 shrink-0"}),e.jsx("span",{className:"truncate",title:c.title,children:c.name}),c.neu&&e.jsxs("span",{className:"ml-auto flex items-center gap-0.5 rounded bg-violet-500/15 px-1 py-0.5 text-[8px] font-bold uppercase text-violet-300",children:[e.jsx(C,{className:"h-2.5 w-2.5"}),"neu"]})]},c.path))})]},s||"__root"))})]}),e.jsx("div",{className:"min-w-0 flex-1 mc-card p-5",children:u?e.jsxs("div",{className:"flex items-center gap-2 text-xs text-muted-foreground",children:[e.jsx(v,{className:"h-4 w-4 animate-spin"})," Notiz wird geladen …"]}):d?e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"mb-3 flex items-center justify-between border-b border-border/40 pb-2",children:[e.jsxs("span",{className:"flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground",children:[e.jsx(E,{className:"h-3.5 w-3.5"})," ",d.path]}),d.mtime>0&&e.jsxs("span",{className:"text-[10px] text-muted-foreground/60",children:["Stand ",new Date(d.mtime*1e3).toLocaleString("de-DE",{day:"2-digit",month:"2-digit",hour:"2-digit",minute:"2-digit"})]})]}),e.jsx(z,{text:d.content,onWikiLink:j,known:m})]}):null})]})]})}function z({text:o,onWikiLink:h,known:l}){const f=o.split(`
`),d=[];let r=[],u=null;const i=a=>{r.length&&(d.push(e.jsx("ul",{className:"mb-3 ml-4 list-disc space-y-1",children:r},a)),r=[])};return f.forEach((a,p)=>{const t=`l${p}`;if(u!==null){a.trimEnd()==="```"?(d.push(e.jsx("pre",{className:"mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px] leading-relaxed text-foreground/85",children:u.join(` `),d=[];let r=[],u=null;const i=a=>{r.length&&(d.push(e.jsx("ul",{className:"mb-3 ml-4 list-disc space-y-1",children:r},a)),r=[])};return f.forEach((a,p)=>{const t=`l${p}`;if(u!==null){a.trimEnd()==="```"?(d.push(e.jsx("pre",{className:"mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px] leading-relaxed text-foreground/85",children:u.join(`
`)},t)),u=null):u.push(a);return}if(a.trimStart().startsWith("```")){i(t),u=[];return}const m=a.trimEnd();if(!m.trim()){i(t);return}const g=m.match(/^(#{1,4})\s+(.*)$/);if(g){i(t);const j=g[1].length,s=j===1?"text-lg font-bold mt-1 mb-3":j===2?"text-base font-bold mt-4 mb-2":"text-sm font-bold mt-3 mb-1.5";d.push(e.jsx("p",{className:w(s,"font-space text-foreground"),children:N(g[2],h,l,t)},t));return}const b=m.match(/^\s*[-*•]\s+(.*)$/);if(b){r.push(e.jsx("li",{className:"text-xs leading-relaxed text-foreground/85",children:N(b[1],h,l,t)},t));return}i(t),d.push(e.jsx("p",{className:"mb-2 text-xs leading-relaxed text-foreground/85",children:N(m,h,l,t)},t))}),i("end"),u!==null&&d.push(e.jsx("pre",{className:"mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px]",children:u.join(` `)},t)),u=null):u.push(a);return}if(a.trimStart().startsWith("```")){i(t),u=[];return}const m=a.trimEnd();if(!m.trim()){i(t);return}const g=m.match(/^(#{1,4})\s+(.*)$/);if(g){i(t);const j=g[1].length,s=j===1?"text-lg font-bold mt-1 mb-3":j===2?"text-base font-bold mt-4 mb-2":"text-sm font-bold mt-3 mb-1.5";d.push(e.jsx("p",{className:w(s,"font-space text-foreground"),children:N(g[2],h,l,t)},t));return}const b=m.match(/^\s*[-*•]\s+(.*)$/);if(b){r.push(e.jsx("li",{className:"text-xs leading-relaxed text-foreground/85",children:N(b[1],h,l,t)},t));return}i(t),d.push(e.jsx("p",{className:"mb-2 text-xs leading-relaxed text-foreground/85",children:N(m,h,l,t)},t))}),i("end"),u!==null&&d.push(e.jsx("pre",{className:"mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px]",children:u.join(`
`)},"code-end")),e.jsx("div",{className:"max-w-3xl",children:d})}function N(o,h,l,f){return o.split(/(\[\[[^\]]+\]\]|\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g).map((r,u)=>{const i=`${f}-${u}`,a=r.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]$/);if(a){const p=a[1].trim(),t=(a[2]??a[1]).trim();return l.has(p.toLowerCase())?e.jsx("button",{onClick:()=>h(p),className:"rounded bg-primary/10 px-1 text-primary underline decoration-primary/40 underline-offset-2 hover:bg-primary/20 cursor-pointer",children:t},i):e.jsx("span",{className:"rounded bg-background/40 px-1 text-muted-foreground",title:"Notiz existiert (noch) nicht",children:t},i)}return r.startsWith("**")&&r.endsWith("**")?e.jsx("b",{className:"font-semibold text-foreground",children:r.slice(2,-2)},i):r.startsWith("*")&&r.endsWith("*")&&r.length>2?e.jsx("i",{children:r.slice(1,-1)},i):r.startsWith("`")&&r.endsWith("`")?e.jsx("code",{className:"rounded bg-background/50 px-1 font-mono text-[11px] text-teal-300",children:r.slice(1,-1)},i):e.jsx("span",{children:r},i)})}export{T as WissenView}; `)},"code-end")),e.jsx("div",{className:"max-w-3xl",children:d})}function N(o,h,l,f){return o.split(/(\[\[[^\]]+\]\]|\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g).map((r,u)=>{const i=`${f}-${u}`,a=r.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]$/);if(a){const p=a[1].trim(),t=(a[2]??a[1]).trim();return l.has(p.toLowerCase())?e.jsx("button",{onClick:()=>h(p),className:"rounded bg-primary/10 px-1 text-primary underline decoration-primary/40 underline-offset-2 hover:bg-primary/20 cursor-pointer",children:t},i):e.jsx("span",{className:"rounded bg-background/40 px-1 text-muted-foreground",title:"Notiz existiert (noch) nicht",children:t},i)}return r.startsWith("**")&&r.endsWith("**")?e.jsx("b",{className:"font-semibold text-foreground",children:r.slice(2,-2)},i):r.startsWith("*")&&r.endsWith("*")&&r.length>2?e.jsx("i",{children:r.slice(1,-1)},i):r.startsWith("`")&&r.endsWith("`")?e.jsx("code",{className:"rounded bg-background/50 px-1 font-mono text-[11px] text-teal-300",children:r.slice(1,-1)},i):e.jsx("span",{children:r},i)})}export{T as WissenView};
@@ -1,4 +1,4 @@
import{c as a}from"./index-ClYatDZT.js";/** import{c as a}from"./index-DgTg5QdX.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c}from"./index-ClYatDZT.js";/** import{c}from"./index-DgTg5QdX.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c}from"./index-ClYatDZT.js";/** import{c}from"./index-DgTg5QdX.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as a}from"./index-ClYatDZT.js";/** import{c as a}from"./index-DgTg5QdX.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as t}from"./index-ClYatDZT.js";/** import{c as t}from"./index-DgTg5QdX.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as a}from"./index-ClYatDZT.js";/** import{c as a}from"./index-DgTg5QdX.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as a}from"./index-ClYatDZT.js";/** import{c as a}from"./index-DgTg5QdX.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-ClYatDZT.js"></script> <script type="module" crossorigin src="/assets/index-DgTg5QdX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cq_BqGQa.css"> <link rel="stylesheet" crossorigin href="/assets/index-Cq_BqGQa.css">
</head> </head>
<body> <body>
+359 -357
View File
@@ -1,357 +1,359 @@
import { useEffect, useState, useCallback, lazy, Suspense } from "react" import { useEffect, useState, useCallback, lazy, Suspense } from "react"
import { Command as CommandIcon, ChevronLeft, ChevronRight, Loader2, Menu, X } from "lucide-react" import { Command as CommandIcon, ChevronLeft, ChevronRight, Loader2, Menu, X } from "lucide-react"
import { NAV, type ViewId } from "@/nav" import { NAV, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette" import { CommandPalette } from "@/components/CommandPalette"
import { ExpertToggle } from "@/components/ExpertToggle" import { ExpertToggle } from "@/components/ExpertToggle"
import { Placeholder } from "@/views/Placeholder" import { Placeholder } from "@/views/Placeholder"
import { SystemDrawer } from "@/components/SystemDrawer" import { SystemDrawer } from "@/components/SystemDrawer"
import { useHealth, useSystemStatus } from "@/lib/queries" import { useHealth, useSystemStatus } from "@/lib/queries"
import type { Health, SystemStatus } from "@/lib/api" import type { Health, SystemStatus } from "@/lib/api"
import { useMetricsFeeder } from "@/lib/metricsStore" import { useMetricsFeeder } from "@/lib/metricsStore"
import { cn } from "@/lib/utils" import { useEventStream } from "@/lib/events"
import { CockpitView } from "@/views/cockpit/CockpitView" import { cn } from "@/lib/utils"
import { CockpitView } from "@/views/cockpit/CockpitView"
// Code-Splitting: nur das Cockpit (Startseite) lädt sofort, jede weitere View
// kommt als eigenes Chunk erst beim ersten Aufruf — kleineres Start-Bundle. // Code-Splitting: nur das Cockpit (Startseite) lädt sofort, jede weitere View
const ModelsView = lazy(() => import("@/views/ModelsView").then((m) => ({ default: m.ModelsView }))) // kommt als eigenes Chunk erst beim ersten Aufruf — kleineres Start-Bundle.
const ConnectView = lazy(() => import("@/views/ConnectView").then((m) => ({ default: m.ConnectView }))) const ModelsView = lazy(() => import("@/views/ModelsView").then((m) => ({ default: m.ModelsView })))
const MemoryView = lazy(() => import("@/views/MemoryView").then((m) => ({ default: m.MemoryView }))) const ConnectView = lazy(() => import("@/views/ConnectView").then((m) => ({ default: m.ConnectView })))
const AgentView = lazy(() => import("@/views/AgentView").then((m) => ({ default: m.AgentView }))) const MemoryView = lazy(() => import("@/views/MemoryView").then((m) => ({ default: m.MemoryView })))
const KonsoleView = lazy(() => import("@/views/KonsoleView").then((m) => ({ default: m.KonsoleView }))) const AgentView = lazy(() => import("@/views/AgentView").then((m) => ({ default: m.AgentView })))
const GuideView = lazy(() => import("@/views/GuideView").then((m) => ({ default: m.GuideView }))) const KonsoleView = lazy(() => import("@/views/KonsoleView").then((m) => ({ default: m.KonsoleView })))
const AuftragsbuchView = lazy(() => import("@/views/AuftragsbuchView").then((m) => ({ default: m.AuftragsbuchView }))) const GuideView = lazy(() => import("@/views/GuideView").then((m) => ({ default: m.GuideView })))
const ChronikView = lazy(() => import("@/views/ChronikView").then((m) => ({ default: m.ChronikView }))) const AuftragsbuchView = lazy(() => import("@/views/AuftragsbuchView").then((m) => ({ default: m.AuftragsbuchView })))
const EigenlebenView = lazy(() => import("@/views/EigenlebenView").then((m) => ({ default: m.EigenlebenView }))) const ChronikView = lazy(() => import("@/views/ChronikView").then((m) => ({ default: m.ChronikView })))
const WissenView = lazy(() => import("@/views/WissenView").then((m) => ({ default: m.WissenView }))) const EigenlebenView = lazy(() => import("@/views/EigenlebenView").then((m) => ({ default: m.EigenlebenView })))
const WissenView = lazy(() => import("@/views/WissenView").then((m) => ({ default: m.WissenView })))
// Dezenter Lade-Zustand während eine View nachgeladen wird (lokales Netz: kaum sichtbar).
function ViewLoading() { // Dezenter Lade-Zustand während eine View nachgeladen wird (lokales Netz: kaum sichtbar).
return ( function ViewLoading() {
<div className="flex h-64 items-center justify-center text-muted-foreground"> return (
<Loader2 className="h-5 w-5 animate-spin" aria-label="Lädt …" /> <div className="flex h-64 items-center justify-center text-muted-foreground">
</div> <Loader2 className="h-5 w-5 animate-spin" aria-label="Lädt …" />
) </div>
} )
}
// Tastatur-Hinweis passend zum Betriebssystem (Palette hört auf Ctrl UND Cmd).
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform) // Tastatur-Hinweis passend zum Betriebssystem (Palette hört auf Ctrl UND Cmd).
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform)
export default function App() {
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab export default function App() {
// View ist im URL-Hash (#models) verankert → Reload/Teilen/Cmd-Klick funktionieren. useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab
const [view, setView] = useState<ViewId>(() => { useEventStream() // SSE-Invalidation-Bus (P3a): sofortige Updates, Poller nur Netz
const h = window.location.hash.slice(1) as ViewId // View ist im URL-Hash (#models) verankert → Reload/Teilen/Cmd-Klick funktionieren.
return NAV.some((n) => n.id === h) ? h : "dashboard" const [view, setView] = useState<ViewId>(() => {
}) const h = window.location.hash.slice(1) as ViewId
const navigate = useCallback((v: ViewId) => { return NAV.some((n) => n.id === h) ? h : "dashboard"
if (window.location.hash.slice(1) === v) setView(v) })
else window.location.hash = v const navigate = useCallback((v: ViewId) => {
}, []) if (window.location.hash.slice(1) === v) setView(v)
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true") else window.location.hash = v
const [mobileNavOpen, setMobileNavOpen] = useState(false) }, [])
const [drawerOpen, setDrawerOpen] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance") const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [drawerOpen, setDrawerOpen] = useState(false)
const { data: health } = useHealth() const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
const { data: sysStatus } = useSystemStatus()
const { data: health } = useHealth()
useEffect(() => { const { data: sysStatus } = useSystemStatus()
document.documentElement.classList.add("dark")
}, []) useEffect(() => {
document.documentElement.classList.add("dark")
useEffect(() => { }, [])
const handleOpen = (e: Event) => {
const customEvent = e as CustomEvent useEffect(() => {
setDrawerTab(customEvent.detail?.tab || "maintenance") const handleOpen = (e: Event) => {
setDrawerOpen(true) const customEvent = e as CustomEvent
} setDrawerTab(customEvent.detail?.tab || "maintenance")
window.addEventListener("open-system-drawer", handleOpen) setDrawerOpen(true)
return () => window.removeEventListener("open-system-drawer", handleOpen) }
}, []) window.addEventListener("open-system-drawer", handleOpen)
return () => window.removeEventListener("open-system-drawer", handleOpen)
useEffect(() => { }, [])
const handleNav = (e: Event) => {
const v = (e as CustomEvent).detail?.view as ViewId | undefined useEffect(() => {
if (v) navigate(v) const handleNav = (e: Event) => {
} const v = (e as CustomEvent).detail?.view as ViewId | undefined
window.addEventListener("mc-navigate", handleNav) if (v) navigate(v)
return () => window.removeEventListener("mc-navigate", handleNav) }
}, [navigate]) window.addEventListener("mc-navigate", handleNav)
return () => window.removeEventListener("mc-navigate", handleNav)
useEffect(() => { }, [navigate])
const onHash = () => {
const h = window.location.hash.slice(1) as ViewId useEffect(() => {
if (NAV.some((n) => n.id === h)) setView(h) const onHash = () => {
} const h = window.location.hash.slice(1) as ViewId
window.addEventListener("hashchange", onHash) if (NAV.some((n) => n.id === h)) setView(h)
return () => window.removeEventListener("hashchange", onHash) }
}, []) window.addEventListener("hashchange", onHash)
return () => window.removeEventListener("hashchange", onHash)
// Mobile-Navigation schließt nach jedem View-Wechsel (ein Tipp = ein Ziel). }, [])
useEffect(() => { setMobileNavOpen(false) }, [view])
// Mobile-Navigation schließt nach jedem View-Wechsel (ein Tipp = ein Ziel).
const active = NAV.find((n) => n.id === view)! useEffect(() => { setMobileNavOpen(false) }, [view])
return ( const active = NAV.find((n) => n.id === view)!
<div className="flex h-full relative">
{/* Ruhiger Ambient-Hintergrund: statische radial-gradients statt animierter return (
Fullscreen-Blur-Filter — gleicher Look, praktisch keine GPU-Last. */} <div className="flex h-full relative">
<div {/* Ruhiger Ambient-Hintergrund: statische radial-gradients statt animierter
className="fixed inset-0 -z-50 pointer-events-none" Fullscreen-Blur-Filter — gleicher Look, praktisch keine GPU-Last. */}
style={{ <div
backgroundColor: "hsl(224,30%,6%)", className="fixed inset-0 -z-50 pointer-events-none"
backgroundImage: [ style={{
"radial-gradient(45% 40% at 12% 8%, hsl(172 72% 50% / 0.10), transparent 70%)", backgroundColor: "hsl(224,30%,6%)",
"radial-gradient(50% 45% at 88% 92%, hsl(270 70% 60% / 0.10), transparent 70%)", backgroundImage: [
"radial-gradient(40% 35% at 75% 30%, hsl(239 70% 62% / 0.07), transparent 70%)", "radial-gradient(45% 40% at 12% 8%, hsl(172 72% 50% / 0.10), transparent 70%)",
].join(", "), "radial-gradient(50% 45% at 88% 92%, hsl(270 70% 60% / 0.10), transparent 70%)",
}} "radial-gradient(40% 35% at 75% 30%, hsl(239 70% 62% / 0.07), transparent 70%)",
/> ].join(", "),
}}
<CommandPalette onNavigate={navigate} /> />
<SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} />
<CommandPalette onNavigate={navigate} />
{/* Sidebar (Desktop, ab md) */} <SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} />
<aside className={cn(
"hidden md:flex shrink-0 flex-col border-r border-border/40 bg-card/45 backdrop-blur-md transition-all duration-300 ease-in-out", {/* Sidebar (Desktop, ab md) */}
sidebarCollapsed ? "w-16" : "w-60" <aside className={cn(
)}> "hidden md:flex shrink-0 flex-col border-r border-border/40 bg-card/45 backdrop-blur-md transition-all duration-300 ease-in-out",
<SidebarContent sidebarCollapsed ? "w-16" : "w-60"
collapsed={sidebarCollapsed} )}>
onToggleCollapse={() => { <SidebarContent
setSidebarCollapsed((c) => { collapsed={sidebarCollapsed}
const next = !c onToggleCollapse={() => {
localStorage.setItem("mc_sidebar_collapsed", next.toString()) setSidebarCollapsed((c) => {
return next const next = !c
}) localStorage.setItem("mc_sidebar_collapsed", next.toString())
}} return next
view={view} })
health={health} }}
sysStatus={sysStatus} view={view}
/> health={health}
</aside> sysStatus={sysStatus}
/>
{/* Sidebar (Mobile, Off-Canvas mit Backdrop) */} </aside>
<div
className={cn( {/* Sidebar (Mobile, Off-Canvas mit Backdrop) */}
"md:hidden fixed inset-0 z-50 transition-opacity duration-200", <div
mobileNavOpen ? "opacity-100" : "pointer-events-none opacity-0" className={cn(
)} "md:hidden fixed inset-0 z-50 transition-opacity duration-200",
aria-hidden={!mobileNavOpen} mobileNavOpen ? "opacity-100" : "pointer-events-none opacity-0"
> )}
<div className="absolute inset-0 bg-black/60" onClick={() => setMobileNavOpen(false)} /> aria-hidden={!mobileNavOpen}
<aside className={cn( >
"absolute inset-y-0 left-0 flex w-72 max-w-[85vw] flex-col border-r border-border/40 bg-[hsl(224,28%,8%)] transition-transform duration-300 ease-in-out", <div className="absolute inset-0 bg-black/60" onClick={() => setMobileNavOpen(false)} />
mobileNavOpen ? "translate-x-0" : "-translate-x-full" <aside className={cn(
)}> "absolute inset-y-0 left-0 flex w-72 max-w-[85vw] flex-col border-r border-border/40 bg-[hsl(224,28%,8%)] transition-transform duration-300 ease-in-out",
<SidebarContent mobileNavOpen ? "translate-x-0" : "-translate-x-full"
collapsed={false} )}>
onClose={() => setMobileNavOpen(false)} <SidebarContent
view={view} collapsed={false}
health={health} onClose={() => setMobileNavOpen(false)}
sysStatus={sysStatus} view={view}
/> health={health}
</aside> sysStatus={sysStatus}
</div> />
</aside>
{/* Main */} </div>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-border/40 px-4 md:px-6 bg-card/20 backdrop-blur-sm"> {/* Main */}
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 flex-1 flex-col">
<button <header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-border/40 px-4 md:px-6 bg-card/20 backdrop-blur-sm">
onClick={() => setMobileNavOpen(true)} <div className="flex min-w-0 items-center gap-3">
className="md:hidden p-1.5 -ml-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer" <button
aria-label="Navigation öffnen" onClick={() => setMobileNavOpen(true)}
> className="md:hidden p-1.5 -ml-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
<Menu className="h-5 w-5" aria-hidden="true" /> aria-label="Navigation öffnen"
</button> >
<div className="truncate text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active.hint}</div> <Menu className="h-5 w-5" aria-hidden="true" />
</div> </button>
<div className="flex shrink-0 items-center gap-2"> <div className="truncate text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active.hint}</div>
<ExpertToggle /> </div>
<a <div className="flex shrink-0 items-center gap-2">
href="https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2/src/branch/main/docs/BEDIENUNG.md" <ExpertToggle />
target="_blank" <a
rel="noopener" href="https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2/src/branch/main/docs/BEDIENUNG.md"
className="hidden sm:flex h-8 items-center rounded-md border border-border/40 bg-background/40 px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all cursor-pointer font-semibold" target="_blank"
title="Bedien-Anleitung" rel="noopener"
> className="hidden sm:flex h-8 items-center rounded-md border border-border/40 bg-background/40 px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all cursor-pointer font-semibold"
Hilfe title="Bedien-Anleitung"
</a> >
Hilfe
<button </a>
onClick={() => {
const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true }) <button
document.dispatchEvent(ev) onClick={() => {
}} const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true })
className="flex items-center gap-2 rounded-md border border-border/40 bg-background/40 px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all cursor-pointer" document.dispatchEvent(ev)
> }}
<CommandIcon className="h-3.5 w-3.5" /> className="flex items-center gap-2 rounded-md border border-border/40 bg-background/40 px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all cursor-pointer"
<span className="hidden sm:inline">Suchen</span> >
<kbd className="hidden sm:inline rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">{IS_MAC ? "⌘K" : "Strg+K"}</kbd> <CommandIcon className="h-3.5 w-3.5" />
</button> <span className="hidden sm:inline">Suchen</span>
</div> <kbd className="hidden sm:inline rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">{IS_MAC ? "⌘K" : "Strg+K"}</kbd>
</header> </button>
</div>
<main className="flex-1 overflow-y-auto p-4 md:p-6 scrollbar-thin"> </header>
<Suspense fallback={<ViewLoading />}>
{view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />} <main className="flex-1 overflow-y-auto p-4 md:p-6 scrollbar-thin">
{view === "auftraege" && <AuftragsbuchView />} <Suspense fallback={<ViewLoading />}>
{view === "models" && <ModelsView />} {view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />}
{view === "connect" && <ConnectView />} {view === "auftraege" && <AuftragsbuchView />}
{view === "memory" && <MemoryView />} {view === "models" && <ModelsView />}
{view === "wissen" && <WissenView />} {view === "connect" && <ConnectView />}
{view === "chronik" && <ChronikView />} {view === "memory" && <MemoryView />}
{view === "eigenleben" && <EigenlebenView />} {view === "wissen" && <WissenView />}
{view === "agent" && <AgentView />} {view === "chronik" && <ChronikView />}
{view === "konsole" && <KonsoleView />} {view === "eigenleben" && <EigenlebenView />}
{view === "guide" && <GuideView />} {view === "agent" && <AgentView />}
{!["dashboard", "auftraege", "eigenleben", "models", "connect", "memory", "wissen", "chronik", "agent", "konsole", "guide"].includes(view) && ( {view === "konsole" && <KonsoleView />}
<Placeholder title={active.label} hint={active.hint} /> {view === "guide" && <GuideView />}
)} {!["dashboard", "auftraege", "eigenleben", "models", "connect", "memory", "wissen", "chronik", "agent", "konsole", "guide"].includes(view) && (
</Suspense> <Placeholder title={active.label} hint={active.hint} />
</main> )}
</div> </Suspense>
</div> </main>
) </div>
} </div>
)
// Sidebar-Inhalt (Logo, Navigation, Status-Fuß) — geteilt zwischen Desktop-Leiste }
// und Mobile-Off-Canvas, damit beide immer identisch bleiben.
function SidebarContent({ // Sidebar-Inhalt (Logo, Navigation, Status-Fuß) — geteilt zwischen Desktop-Leiste
collapsed, onToggleCollapse, onClose, view, health, sysStatus, // und Mobile-Off-Canvas, damit beide immer identisch bleiben.
}: { function SidebarContent({
collapsed: boolean collapsed, onToggleCollapse, onClose, view, health, sysStatus,
onToggleCollapse?: () => void }: {
onClose?: () => void collapsed: boolean
view: ViewId onToggleCollapse?: () => void
health: Health | undefined onClose?: () => void
sysStatus: SystemStatus | undefined view: ViewId
}) { health: Health | undefined
return ( sysStatus: SystemStatus | undefined
<> }) {
<div className={cn("flex items-center py-4 border-b border-border/40 shrink-0", collapsed ? "flex-col gap-3 px-2" : "justify-between px-5")}> return (
<div className="flex items-center gap-2 overflow-hidden"> <>
<div className="h-7 w-7 rounded-lg bg-primary shadow-md shadow-primary/20 shrink-0" /> <div className={cn("flex items-center py-4 border-b border-border/40 shrink-0", collapsed ? "flex-col gap-3 px-2" : "justify-between px-5")}>
{!collapsed && ( <div className="flex items-center gap-2 overflow-hidden">
<div className="leading-tight"> <div className="h-7 w-7 rounded-lg bg-primary shadow-md shadow-primary/20 shrink-0" />
<div className="text-sm font-semibold tracking-wide font-space">Mission Control</div> {!collapsed && (
<div className="text-xs text-muted-foreground">2.0</div> <div className="leading-tight">
</div> <div className="text-sm font-semibold tracking-wide font-space">Mission Control</div>
)} <div className="text-xs text-muted-foreground">2.0</div>
</div> </div>
{onToggleCollapse && ( )}
<button </div>
onClick={onToggleCollapse} {onToggleCollapse && (
className="p-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer" <button
aria-label={collapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"} onClick={onToggleCollapse}
title={collapsed ? "Maximieren" : "Minimieren"} className="p-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
> aria-label={collapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
{collapsed ? <ChevronRight className="h-4 w-4" aria-hidden="true" /> : <ChevronLeft className="h-4 w-4" aria-hidden="true" />} title={collapsed ? "Maximieren" : "Minimieren"}
</button> >
)} {collapsed ? <ChevronRight className="h-4 w-4" aria-hidden="true" /> : <ChevronLeft className="h-4 w-4" aria-hidden="true" />}
{onClose && ( </button>
<button )}
onClick={onClose} {onClose && (
className="p-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer" <button
aria-label="Navigation schließen" onClick={onClose}
> className="p-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
<X className="h-4 w-4" aria-hidden="true" /> aria-label="Navigation schließen"
</button> >
)} <X className="h-4 w-4" aria-hidden="true" />
</div> </button>
)}
<nav className="flex-1 space-y-1 px-3 py-4 overflow-y-auto"> </div>
{(() => {
let lastGroup: string | null = null <nav className="flex-1 space-y-1 px-3 py-4 overflow-y-auto">
return NAV.map((item) => { {(() => {
const showGroupHeader = item.group !== lastGroup let lastGroup: string | null = null
lastGroup = item.group return NAV.map((item) => {
return ( const showGroupHeader = item.group !== lastGroup
<div key={item.id} className="space-y-1"> lastGroup = item.group
{showGroupHeader && ( return (
collapsed ? ( <div key={item.id} className="space-y-1">
lastGroup !== NAV[0].group && <div className="my-3 border-t border-border/30" /> {showGroupHeader && (
) : ( collapsed ? (
<div className="mt-5 mb-1.5 px-3 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/40 first:mt-0 select-none"> lastGroup !== NAV[0].group && <div className="my-3 border-t border-border/30" />
{item.group} ) : (
</div> <div className="mt-5 mb-1.5 px-3 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/40 first:mt-0 select-none">
) {item.group}
)} </div>
<a )
href={`#${item.id}`} )}
onClick={onClose} <a
aria-current={view === item.id ? "page" : undefined} href={`#${item.id}`}
className={cn( onClick={onClose}
"flex w-full items-center rounded-md text-sm transition-all cursor-pointer", aria-current={view === item.id ? "page" : undefined}
collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2", className={cn(
view === item.id "flex w-full items-center rounded-md text-sm transition-all cursor-pointer",
? (collapsed ? "nav-active-collapsed" : "nav-active") collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2",
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground", view === item.id
)} ? (collapsed ? "nav-active-collapsed" : "nav-active")
title={collapsed ? item.label : undefined} : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
aria-label={collapsed ? item.label : undefined} )}
> title={collapsed ? item.label : undefined}
<item.icon className="h-4.5 w-4.5 shrink-0" aria-hidden="true" /> aria-label={collapsed ? item.label : undefined}
{!collapsed && <span className="truncate">{item.label}</span>} >
</a> <item.icon className="h-4.5 w-4.5 shrink-0" aria-hidden="true" />
</div> {!collapsed && <span className="truncate">{item.label}</span>}
) </a>
}) </div>
})()} )
</nav> })
})()}
<div className={cn("py-3 text-xs text-muted-foreground border-t border-border/40 shrink-0", collapsed ? "px-2 text-center" : "px-5")}> </nav>
{collapsed ? (
<div className="flex justify-center"> <div className={cn("py-3 text-xs text-muted-foreground border-t border-border/40 shrink-0", collapsed ? "px-2 text-center" : "px-5")}>
<span className={cn( {collapsed ? (
"h-2.5 w-2.5 rounded-full ring-2 ring-black/40", <div className="flex justify-center">
health <span className={cn(
? (!health.engine_reachable ? "bg-amber-500" "h-2.5 w-2.5 rounded-full ring-2 ring-black/40",
: (health.brain && !health.brain.ready ? "bg-amber-500 animate-pulse" : "bg-emerald-500 animate-pulse")) health
: "bg-red-500" ? (!health.engine_reachable ? "bg-amber-500"
)} title={health : (health.brain && !health.brain.ready ? "bg-amber-500 animate-pulse" : "bg-emerald-500 animate-pulse"))
? (!health.engine_reachable ? "Engine offline" : "bg-red-500"
: (health.brain && !health.brain.ready ? `Hirn offline (${health.brain.model ?? "fast"})` : "Engine + Hirn online")) )} title={health
: "Backend offline"} /> ? (!health.engine_reachable ? "Engine offline"
</div> : (health.brain && !health.brain.ready ? `Hirn offline (${health.brain.model ?? "fast"})` : "Engine + Hirn online"))
) : ( : "Backend offline"} />
<div className="space-y-2 text-left"> </div>
{health ? ( ) : (
<> <div className="space-y-2 text-left">
<span className="flex items-center gap-2"> {health ? (
<span className={cn("h-2 w-2 rounded-full animate-pulse", health.engine_reachable ? "bg-emerald-500" : "bg-amber-500")} /> <>
<span className="truncate">Engine {health.engine_reachable ? "online" : "offline"}</span> <span className="flex items-center gap-2">
</span> <span className={cn("h-2 w-2 rounded-full animate-pulse", health.engine_reachable ? "bg-emerald-500" : "bg-amber-500")} />
{health.brain && !health.brain.ready && ( <span className="truncate">Engine {health.engine_reachable ? "online" : "offline"}</span>
<span className="flex items-center gap-2 text-amber-400" title={`Agent-Hirn '${health.brain.model ?? "fast"}' lädt nicht/abgestürzt`}> </span>
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" /> {health.brain && !health.brain.ready && (
<span className="truncate">Hirn offline{health.brain.model ? ` (${health.brain.model})` : ""}</span> <span className="flex items-center gap-2 text-amber-400" title={`Agent-Hirn '${health.brain.model ?? "fast"}' lädt nicht/abgestürzt`}>
</span> <span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
)} <span className="truncate">Hirn offline{health.brain.model ? ` (${health.brain.model})` : ""}</span>
</> </span>
) : ( )}
<span className="flex items-center gap-2"> </>
<span className="h-2 w-2 rounded-full bg-red-500" /> <span className="truncate">Backend offline</span> ) : (
</span> <span className="flex items-center gap-2">
)} <span className="h-2 w-2 rounded-full bg-red-500" /> <span className="truncate">Backend offline</span>
</span>
{sysStatus?.versions && ( )}
<div className="space-y-1 text-[9px] text-muted-foreground/60 mt-2 pt-2 border-t border-border/30">
<div className="truncate" title={sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.branch}-${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""} (${sysStatus.versions.mc2.date})` : "nicht gefunden"}> {sysStatus?.versions && (
<strong>MC2:</strong> {sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""}` : "—"} <div className="space-y-1 text-[9px] text-muted-foreground/60 mt-2 pt-2 border-t border-border/30">
</div> <div className="truncate" title={sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.branch}-${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""} (${sysStatus.versions.mc2.date})` : "nicht gefunden"}>
<div className="truncate" title={sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.branch}-${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""} (${sysStatus.versions.engine.date})` : sysStatus.versions.engine?.version_text || "unbekannt"}> <strong>MC2:</strong> {sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""}` : "—"}
<strong>Engine:</strong> {sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""}` : (sysStatus.versions.engine?.version_text?.split(" ").pop() || "—")} </div>
</div> <div className="truncate" title={sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.branch}-${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""} (${sysStatus.versions.engine.date})` : sysStatus.versions.engine?.version_text || "unbekannt"}>
<div className="truncate" title={sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.branch}-${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""} (${sysStatus.versions.hermes_agent.date})` : "nicht gefunden"}> <strong>Engine:</strong> {sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""}` : (sysStatus.versions.engine?.version_text?.split(" ").pop() || "—")}
<strong>Hermes Agent:</strong> {sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""}` : "—"} </div>
</div> <div className="truncate" title={sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.branch}-${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""} (${sysStatus.versions.hermes_agent.date})` : "nicht gefunden"}>
</div> <strong>Hermes Agent:</strong> {sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""}` : "—"}
)} </div>
</div> </div>
)} )}
</div> </div>
</> )}
) </div>
} </>
)
}
+27
View File
@@ -0,0 +1,27 @@
// SSE-Eventstrom (UMBAU v3 P3a) — Gegenstück zu backend/routers/events.py.
// EIN EventSource auf /api/events; `invalidate`-Events stoßen gezielt die React-Query-
// Caches an. Solange der Strom steht, sind die Poller nur noch Sicherheitsnetz
// (queries.ts entspannt sie ×5 über sseVerbunden()). Reißt der Strom (MC2-Deploy,
// Netz), reconnectet EventSource von selbst — bis dahin pollt die UI wie früher.
import { useEffect } from "react"
import { useQueryClient } from "@tanstack/react-query"
let verbunden = false
export const sseVerbunden = () => verbunden
export function useEventStream() {
const qc = useQueryClient()
useEffect(() => {
const es = new EventSource("/api/events")
es.onopen = () => { verbunden = true }
es.onerror = () => { verbunden = false }
es.addEventListener("invalidate", (e) => {
try {
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
} catch { /* kaputtes Event bricht den Strom nicht */ }
})
return () => { verbunden = false; es.close() }
}, [qc])
}
+249 -242
View File
@@ -1,242 +1,249 @@
// Zentrale Daten-Hooks (TanStack Query). Kapseln lib/api.ts und liefern Caching, // Zentrale Daten-Hooks (TanStack Query). Kapseln lib/api.ts und liefern Caching,
// Dedup (gleicher queryKey = eine Anfrage über alle Views), Retry und Polling. // Dedup (gleicher queryKey = eine Anfrage über alle Views), Retry und Polling.
// Mutations invalidieren gezielt die betroffenen Keys, statt manuell neu zu laden. // Mutations invalidieren gezielt die betroffenen Keys, statt manuell neu zu laden.
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query" import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"
import { import { sseVerbunden } from "./events"
api, import {
type AgentStatus, api,
type AuftragsbuchResp, type AgentStatus,
type ChronikResp, type AuftragsbuchResp,
type ConnectResp, type ChronikResp,
type ConnectHealth, type ConnectResp,
type DiscoverResp, type ConnectHealth,
type DraftsResp, type DiscoverResp,
type EigenlebenResp, type DraftsResp,
type GroupsResp, type EigenlebenResp,
type HermesBrainResp, type GroupsResp,
type Health, type HermesBrainResp,
type IdeenLogResp, type Health,
type IdeenResp, type IdeenLogResp,
type Job, type IdeenResp,
type Memory, type Job,
type MemoryGraph, type Memory,
type ModelsResp, type MemoryGraph,
type Reminder, type ModelsResp,
type RoutingResp, type Reminder,
type RoutingPolicyMeta, type RoutingResp,
type ServicesResp, type RoutingPolicyMeta,
type SystemStatus, type ServicesResp,
type TokenStats, type SystemStatus,
type UpdatesResp, type TokenStats,
type VoiceTraceResp, type UpdatesResp,
type WissenResp, type VoiceTraceResp,
type ZeitmaschineResp, type WissenResp,
} from "./api" type ZeitmaschineResp,
} from "./api"
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
export const qk = { // Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
health: ["health"] as const, export const qk = {
systemStatus: ["system-status"] as const, health: ["health"] as const,
services: ["services"] as const, systemStatus: ["system-status"] as const,
models: ["models"] as const, services: ["services"] as const,
groups: ["groups"] as const, models: ["models"] as const,
routing: ["routing"] as const, groups: ["groups"] as const,
routingPolicy: ["routing-policy"] as const, routing: ["routing"] as const,
jobs: ["jobs"] as const, routingPolicy: ["routing-policy"] as const,
tokenStats: ["token-stats"] as const, jobs: ["jobs"] as const,
agentStatus: ["agent-status"] as const, tokenStats: ["token-stats"] as const,
hermesBrain: ["hermes-brain"] as const, agentStatus: ["agent-status"] as const,
updates: ["updates"] as const, hermesBrain: ["hermes-brain"] as const,
discover: ["discover"] as const, updates: ["updates"] as const,
drafts: (target?: string) => ["drafts", target ?? ""] as const, discover: ["discover"] as const,
connect: (params?: string) => ["connect", params ?? ""] as const, drafts: (target?: string) => ["drafts", target ?? ""] as const,
connectHealth: ["connect-health"] as const, connect: (params?: string) => ["connect", params ?? ""] as const,
memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const, connectHealth: ["connect-health"] as const,
memoryGraph: ["memory-graph"] as const, memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const,
voiceTrace: ["voice-trace"] as const, memoryGraph: ["memory-graph"] as const,
auftragsbuch: ["auftragsbuch"] as const, voiceTrace: ["voice-trace"] as const,
ideen: ["ideen"] as const, auftragsbuch: ["auftragsbuch"] as const,
chronik: ["chronik"] as const, ideen: ["ideen"] as const,
eigenleben: ["eigenleben"] as const, chronik: ["chronik"] as const,
wissen: ["wissen"] as const, eigenleben: ["eigenleben"] as const,
zeitmaschine: ["zeitmaschine"] as const, wissen: ["wissen"] as const,
reminders: ["reminders"] as const, zeitmaschine: ["zeitmaschine"] as const,
} reminders: ["reminders"] as const,
}
// ── Polling-Takte ────────────────────────────────────────────────────────────
// EINE Stelle für alle Intervalle. Regel: Die Live-Graphen (System/Token) takten // ── Polling-Takte ────────────────────────────────────────────────────────────
// schnell, alles andere gemächlich — Mutationen invalidieren ohnehin gezielt, // EINE Stelle für alle Intervalle. Regel: Die Live-Graphen (System/Token) takten
// Polling ist nur das Sicherheitsnetz. Komponenten übergeben KEINE eigenen // schnell, alles andere gemächlich — Mutationen invalidieren ohnehin gezielt,
// Intervalle mehr (mehrere Beobachter = kleinstes Intervall gewinnt). // Polling ist nur das Sicherheitsnetz. Komponenten übergeben KEINE eigenen
const TAKT = { // Intervalle mehr (mehrere Beobachter = kleinstes Intervall gewinnt).
graph: 3_000, // Live-Graphen im Cockpit (System, Token-Durchsatz) const TAKT = {
schnell: 8_000, // „fühlt sich live an": Modelle, Voice-Trace, Auftragsbuch graph: 3_000, // Live-Graphen im Cockpit (System, Token-Durchsatz)
normal: 10_000, // Dienste, Routing, Agent-Status, Health schnell: 8_000, // „fühlt sich live an": Modelle, Voice-Trace, Auftragsbuch
gemuetlich: 30_000, // Chronik, Ideen normal: 10_000, // Dienste, Routing, Agent-Status, Health
traege: 60_000, // Updates-Check, Reminders, Zeitmaschine, HF-Abfragen gemuetlich: 30_000, // Chronik, Ideen
} as const traege: 60_000, // Updates-Check, Reminders, Zeitmaschine, HF-Abfragen
} as const
// Auftragsbuch: Vorschlags-Inbox (Branches + Skill-Kandidaten). Pollt, damit laufende
// Annahme-Läufe (Status aus der JSON-Datei des Runners) live sichtbar werden. // UMBAU v3 P3a: Läuft der SSE-Eventstrom (/api/events, siehe lib/events.ts), sind die
export const useAuftragsbuch = (refetchInterval: number = TAKT.schnell) => // Poller der ereignishaften Quellen nur noch SICHERHEITSNETZ — der Strom invalidiert
useQuery({ // bei Änderung sofort. Verbunden = Takt ×5; Strom weg = alter Takt (EventSource
queryKey: qk.auftragsbuch, // reconnectet selbst). Live-Graphen/Status bleiben bewusst beim Polling.
queryFn: () => api<AuftragsbuchResp>("/api/auftragsbuch"), const relax = (ms: number) => () => (sseVerbunden() ? ms * 5 : ms)
refetchInterval,
}) // Auftragsbuch: Vorschlags-Inbox (Branches + Skill-Kandidaten). Pollt, damit laufende
// Annahme-Läufe (Status aus der JSON-Datei des Runners) live sichtbar werden.
// Ideen-Queue (natives Hermes-Kanban): das Backend cached ~15 s, öfter pollen lohnt nicht. export const useAuftragsbuch = (refetchInterval: number = TAKT.schnell) =>
export const useIdeen = (refetchInterval: number = TAKT.gemuetlich) => useQuery({
useQuery({ queryKey: qk.auftragsbuch,
queryKey: qk.ideen, queryFn: () => api<AuftragsbuchResp>("/api/auftragsbuch"),
queryFn: () => api<IdeenResp>("/api/ideen"), refetchInterval: relax(refetchInterval),
refetchInterval, })
})
// Ideen-Queue (natives Hermes-Kanban): das Backend cached ~15 s, öfter pollen lohnt nicht.
// Ideen-Log (Live Worker Log): pollt nur solange Idee triage/running UND offen export const useIdeen = (refetchInterval: number = TAKT.gemuetlich) =>
export const useIdeenLog = (id: string, enabled: boolean = false) => useQuery({
useQuery({ queryKey: qk.ideen,
queryKey: ["ideen-log", id], queryFn: () => api<IdeenResp>("/api/ideen"),
queryFn: () => api<IdeenLogResp>(`/api/ideen/${id}/log`), refetchInterval: relax(refetchInterval),
enabled, })
refetchInterval: 4000, // alle ~4s
staleTime: 0, // Ideen-Log (Live Worker Log): pollt nur solange Idee triage/running UND offen
}) export const useIdeenLog = (id: string, enabled: boolean = false) =>
useQuery({
export const useChronik = (limit = 150, refetchInterval: number = TAKT.gemuetlich) => queryKey: ["ideen-log", id],
useQuery({ queryFn: () => api<IdeenLogResp>(`/api/ideen/${id}/log`),
queryKey: qk.chronik, enabled,
queryFn: () => api<ChronikResp>(`/api/chronik?limit=${limit}`), refetchInterval: 4000, // alle ~4s
refetchInterval, staleTime: 0,
select: (d) => d.items ?? [], })
})
export const useChronik = (limit = 150, refetchInterval: number = TAKT.gemuetlich) =>
export const useWissen = () => useQuery({
useQuery({ queryKey: qk.wissen, queryFn: () => api<WissenResp>("/api/wissen") }) queryKey: qk.chronik,
queryFn: () => api<ChronikResp>(`/api/chronik?limit=${limit}`),
export const useEigenleben = (refetchInterval: number = TAKT.traege) => refetchInterval: relax(refetchInterval),
useQuery({ select: (d) => d.items ?? [],
queryKey: qk.eigenleben, })
queryFn: () => api<EigenlebenResp>("/api/eigenleben"),
refetchInterval, export const useWissen = () =>
}) useQuery({ queryKey: qk.wissen, queryFn: () => api<WissenResp>("/api/wissen") })
export const useZeitmaschine = (refetchInterval: number = TAKT.traege) => export const useEigenleben = (refetchInterval: number = TAKT.traege) =>
useQuery({ useQuery({
queryKey: qk.zeitmaschine, queryKey: qk.eigenleben,
queryFn: () => api<ZeitmaschineResp>("/api/zeitmaschine"), queryFn: () => api<EigenlebenResp>("/api/eigenleben"),
refetchInterval, refetchInterval,
}) })
export const useReminders = (refetchInterval: number = TAKT.traege) => export const useZeitmaschine = (refetchInterval: number = TAKT.traege) =>
useQuery({ useQuery({
queryKey: qk.reminders, queryKey: qk.zeitmaschine,
queryFn: () => api<{ items: Reminder[] }>("/api/reminders"), queryFn: () => api<ZeitmaschineResp>("/api/zeitmaschine"),
refetchInterval, refetchInterval,
select: (d) => d.items ?? [], })
})
export const useReminders = (refetchInterval: number = TAKT.traege) =>
// Per-Turn-Latenz-Trace (letzte N Voice/Lucy-Turns mit Stufen-Breakdown). useQuery({
export const useVoiceTrace = (limit = 12, refetchInterval: number = TAKT.schnell) => queryKey: qk.reminders,
useQuery({ queryFn: () => api<{ items: Reminder[] }>("/api/reminders"),
queryKey: qk.voiceTrace, refetchInterval: relax(refetchInterval),
queryFn: () => api<VoiceTraceResp>(`/api/voice/trace?limit=${limit}`), select: (d) => d.items ?? [],
refetchInterval, })
select: (d) => d.turns ?? [],
}) // Per-Turn-Latenz-Trace (letzte N Voice/Lucy-Turns mit Stufen-Breakdown).
export const useVoiceTrace = (limit = 12, refetchInterval: number = TAKT.schnell) =>
export const useMemoryGraph = (enabled = true) => useQuery({
useQuery({ queryKey: qk.voiceTrace,
queryKey: qk.memoryGraph, queryFn: () => api<VoiceTraceResp>(`/api/voice/trace?limit=${limit}`),
queryFn: () => api<MemoryGraph>("/api/memory/graph"), refetchInterval,
enabled, select: (d) => d.turns ?? [],
}) })
export const useHealth = () => export const useMemoryGraph = (enabled = true) =>
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: TAKT.normal }) useQuery({
queryKey: qk.memoryGraph,
export const useSystemStatus = (refetchInterval: number = TAKT.graph) => queryFn: () => api<MemoryGraph>("/api/memory/graph"),
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"), refetchInterval }) enabled,
})
export const useServices = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.services, queryFn: () => api<ServicesResp>("/api/system/services"), refetchInterval }) export const useHealth = () =>
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: TAKT.normal })
export const useModels = (refetchInterval: number = TAKT.schnell) =>
useQuery({ queryKey: qk.models, queryFn: () => api<ModelsResp>("/api/models"), refetchInterval }) export const useSystemStatus = (refetchInterval: number = TAKT.graph) =>
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"), refetchInterval })
export const useGroups = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.groups, queryFn: () => api<GroupsResp>("/api/groups"), refetchInterval }) export const useServices = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.services, queryFn: () => api<ServicesResp>("/api/system/services"), refetchInterval })
export const useRouting = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.routing, queryFn: () => api<RoutingResp>("/api/routing"), refetchInterval }) export const useModels = (refetchInterval: number = TAKT.schnell) =>
useQuery({ queryKey: qk.models, queryFn: () => api<ModelsResp>("/api/models"), refetchInterval })
export const useRoutingPolicy = () =>
useQuery({ queryKey: qk.routingPolicy, queryFn: () => api<RoutingPolicyMeta>("/api/routing/policy") }) export const useGroups = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.groups, queryFn: () => api<GroupsResp>("/api/groups"), refetchInterval })
// Jobs (Downloads) takten etwas schneller, damit Fortschritts-Balken flüssig wirken.
export const useJobs = (refetchInterval = 3_000) => export const useRouting = (refetchInterval: number = TAKT.normal) =>
useQuery({ useQuery({ queryKey: qk.routing, queryFn: () => api<RoutingResp>("/api/routing"), refetchInterval })
queryKey: qk.jobs,
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"), export const useRoutingPolicy = () =>
refetchInterval, useQuery({ queryKey: qk.routingPolicy, queryFn: () => api<RoutingPolicyMeta>("/api/routing/policy") })
select: (d) => d.jobs ?? [],
}) // Jobs (Downloads) takten etwas schneller, damit Fortschritts-Balken flüssig wirken.
export const useJobs = (refetchInterval = 3_000) =>
export const useTokenStats = (refetchInterval: number = TAKT.graph) => useQuery({
useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"), refetchInterval }) queryKey: qk.jobs,
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
export const useAgentStatus = (refetchInterval: number = TAKT.normal) => refetchInterval,
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval }) select: (d) => d.jobs ?? [],
})
// Agent-Hirn (Hermes) + bestes NousResearch-Update. Selten pollen (HF-Abfrage).
export const useHermesBrain = (refetchInterval: number = TAKT.traege) => export const useTokenStats = (refetchInterval: number = TAKT.graph) =>
useQuery({ queryKey: qk.hermesBrain, queryFn: () => api<HermesBrainResp>("/api/agent/brain"), refetchInterval }) useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"), refetchInterval })
// Updates-Check ist teuer (apt/git auf der Box) — träge pollen reicht völlig. export const useAgentStatus = (refetchInterval: number = TAKT.normal) =>
export const useUpdates = (refetchInterval: number = TAKT.traege) => useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval })
useQuery({ queryKey: qk.updates, queryFn: () => api<UpdatesResp>("/api/maintenance/updates"), refetchInterval })
// Agent-Hirn (Hermes) + bestes NousResearch-Update. Selten pollen (HF-Abfrage).
export const useDiscover = () => export const useHermesBrain = (refetchInterval: number = TAKT.traege) =>
useQuery({ queryKey: qk.discover, queryFn: () => api<DiscoverResp>("/api/discover") }) useQuery({ queryKey: qk.hermesBrain, queryFn: () => api<HermesBrainResp>("/api/agent/brain"), refetchInterval })
// Verfügbare Spec-Draft-Modelle + Vocab-Kompatibilität zum Ziel-Modell (GGUF-Pfad). // Updates-Check ist teuer (apt/git auf der Box) — träge pollen reicht völlig.
export const useDrafts = (target?: string) => export const useUpdates = (refetchInterval: number = TAKT.traege) =>
useQuery({ useQuery({ queryKey: qk.updates, queryFn: () => api<UpdatesResp>("/api/maintenance/updates"), refetchInterval })
queryKey: qk.drafts(target),
queryFn: () => api<DraftsResp>(`/api/models/drafts?target=${encodeURIComponent(target ?? "")}`), export const useDiscover = () =>
enabled: !!target, useQuery({ queryKey: qk.discover, queryFn: () => api<DiscoverResp>("/api/discover") })
})
// Verfügbare Spec-Draft-Modelle + Vocab-Kompatibilität zum Ziel-Modell (GGUF-Pfad).
export const useConnect = (params?: string) => export const useDrafts = (target?: string) =>
useQuery({ useQuery({
queryKey: qk.connect(params), queryKey: qk.drafts(target),
queryFn: () => api<ConnectResp>(params ? `/api/connect?${params}` : "/api/connect"), queryFn: () => api<DraftsResp>(`/api/models/drafts?target=${encodeURIComponent(target ?? "")}`),
}) enabled: !!target,
})
// Live-Status der zwei Leitungen (Gateway + Gedächtnis) — alle 15s aktualisiert.
export const useConnectHealth = () => export const useConnect = (params?: string) =>
useQuery({ useQuery({
queryKey: qk.connectHealth, queryKey: qk.connect(params),
queryFn: () => api<ConnectHealth>("/api/connect/health"), queryFn: () => api<ConnectResp>(params ? `/api/connect?${params}` : "/api/connect"),
refetchInterval: 15000, })
})
// Live-Status der zwei Leitungen (Gateway + Gedächtnis) — alle 15s aktualisiert.
export const useMemory = (opts?: { q?: string; category?: string; limit?: number }) => export const useConnectHealth = () =>
useQuery({ useQuery({
queryKey: qk.memory(opts?.q, opts?.category), queryKey: qk.connectHealth,
queryFn: () => { queryFn: () => api<ConnectHealth>("/api/connect/health"),
const p = new URLSearchParams() refetchInterval: 15000,
if (opts?.q) p.set("q", opts.q) })
if (opts?.category) p.set("category", opts.category)
return api<Memory[]>(`/api/memory?${p}`) export const useMemory = (opts?: { q?: string; category?: string; limit?: number }) =>
}, useQuery({
select: (d) => (opts?.limit ? d.slice(0, opts.limit) : d), queryKey: qk.memory(opts?.q, opts?.category),
}) queryFn: () => {
const p = new URLSearchParams()
/** Nach Mutationen die betroffenen Listen-Queries neu ziehen. */ if (opts?.q) p.set("q", opts.q)
export function invalidate(qc: QueryClient, ...keys: readonly unknown[][]) { if (opts?.category) p.set("category", opts.category)
for (const key of keys) qc.invalidateQueries({ queryKey: key }) return api<Memory[]>(`/api/memory?${p}`)
} },
select: (d) => (opts?.limit ? d.slice(0, opts.limit) : d),
export { useQueryClient } })
/** Nach Mutationen die betroffenen Listen-Queries neu ziehen. */
export function invalidate(qc: QueryClient, ...keys: readonly unknown[][]) {
for (const key of keys) qc.invalidateQueries({ queryKey: key })
}
export { useQueryClient }