Zeitachse (24h-Historie), Verbinden-Autofill, Modelltausch abgesichert

Paket 1 - Metrik-Historie mit Zeitachse (User: 'WANN war Last?'):
- services/metrics_history.py: 10s-Sampler (CPU/RAM/GPU/Disk + Token-Totale) in
  24h-Ringpuffer (aelteres faellt automatisch raus, nichts waechst unbegrenzt),
  persistiert alle 5 min -> uebersteht Deploys; GET /api/system/history?minutes
  mit Downsampling auf ~300 Punkte; Lifespan-Task in app.py
- Cockpit-Karten System-Status + Token-Durchsatz: Bereichs-Schalter Live/1h/24h,
  sichtbare Zeitachse (HH:MM:SS bei kurzen, HH:MM bei langen Fenstern), Tooltip
  zeigt Uhrzeit; Token-Raten in 1h/24h aus Totale-Deltas

Paket 2 - Verbinden selbsterklaerend:
- Box-IP vorbefuellt aus window.location.hostname (Dev-Fallback bleibt)
- MCP-Scriptpfad-Feld aus der Kopfzeile in die 'Gedaechtnis anbinden'-Karte
  verschoben, mit Erklaerung WANN man es braucht

Paket 3 - Modelltausch-Loecher gestopft (Review 16.07.):
- install: Registrierung OHNE Alias-Umzug; Rolle wird erst NACH erfolgreichem
  Download uebernommen (on_done) - hermes dabei durch den warm-bewussten
  set_agent_brain-Flow statt rohem Alias-Move (Lucy waere sonst bis Download-
  Ende tot gewesen); Re-Install erhaelt bestehende Aliase
- set_role_alias: lebenswichtige Aliase (hermes/embed) des Ziel-Modells
  ueberleben jeden Rollen-Klick (Qwen3.6 haelt live hermes+fast!)
- Rollen-Endpoint verweigert Rollen-Wechsel am Halter geschuetzter Aliase
  mit klarer Anleitung; Werkbank sperrt die Rollen-Chips sichtbar

Verifiziert: py_compile + Sampler-Smoke (2 Punkte, Persist ok) + Alias-Logik-
Unittest (3 Faelle) + Browser (Zeitachse tickt, Schalter, Leerzustand, Verbinden).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-16 01:08:03 +02:00
parent 26224804ed
commit 71192b3db0
36 changed files with 562 additions and 213 deletions
+3 -1
View File
@@ -43,7 +43,7 @@ from routers import (
) )
from routers import reminders as reminders_router from routers import reminders as reminders_router
from services import memory as memory_svc from services import memory as memory_svc
from services import reminders, sentry, warmer from services import metrics_history, reminders, sentry, warmer
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
# für alle Module (logging.getLogger(__name__)). # für alle Module (logging.getLogger(__name__)).
@@ -68,6 +68,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
if sentry.ENABLED: if sentry.ENABLED:
tasks.append(asyncio.create_task(sentry.sentry_loop())) tasks.append(asyncio.create_task(sentry.sentry_loop()))
tasks.append(asyncio.create_task(reminders.reminders_loop())) tasks.append(asyncio.create_task(reminders.reminders_loop()))
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
if memory_svc.AUTO_DEDUPE_ENABLED: if memory_svc.AUTO_DEDUPE_ENABLED:
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop())) tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
log.info( log.info(
+31 -4
View File
@@ -114,13 +114,30 @@ def install(req: InstallReq) -> dict:
# SETUP-BEWUSST: größter ctx, der neben Hirn/warmem Set passt (nicht nur Modell allein). # SETUP-BEWUSST: größter ctx, der neben Hirn/warmem Set passt (nicht nur Modell allein).
ctx = budget.setup_aware_ctx(budget.params_b_for(repo), req.quant, role=req.role)["ctx"] ctx = budget.setup_aware_ctx(budget.params_b_for(repo), req.quant, role=req.role)["ctx"]
# Sofort registrieren (Datei kommt gleich) — robust gegen -watch-config. # Sofort registrieren (robust gegen -watch-config) — aber OHNE den Rollen-Alias
# umzuhängen: der zeigte sonst minutenlang auf eine noch ladende Datei (Lane kalt;
# bei role=hermes wäre Lucy bis Download-Ende tot gewesen — Review 16.07.).
try: try:
model_id = llamaswap.register_model( model_id = llamaswap.register_model(
model_path, role=req.role, ctx=ctx, mmproj_path=mmproj_path, jinja=req.jinja) model_path, role=req.role, ctx=ctx, mmproj_path=mmproj_path, jinja=req.jinja,
set_alias=False)
except PermissionError as exc: except PermissionError as exc:
raise HTTPException(500, str(exc)) raise HTTPException(500, str(exc))
# Rolle erst NACH erfolgreichem Download übernehmen. hermes geht dabei durch den
# warm-bewussten Hirn-Flow (brains-Gruppe, ttl 0, Hermes-Config, Gateway-Restart) —
# der rohe Alias-Move hatte diesen Flow bisher umgangen.
role = (req.role or "").strip().lower()
def _apply_role() -> None:
if role == "hermes":
from services.agent import set_agent_brain
res = set_agent_brain(model_id)
if not res.get("ok"):
raise RuntimeError(res.get("reason", "Hirn-Wechsel fehlgeschlagen"))
else:
llamaswap.set_role(model_id, role)
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen. # Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
args = [hf.hf_bin(), "download", repo] args = [hf.hf_bin(), "download", repo]
for f in info["files"]: for f in info["files"]:
@@ -131,7 +148,8 @@ def install(req: InstallReq) -> dict:
env = dict(HF_DOWNLOAD_ENV) env = dict(HF_DOWNLOAD_ENV)
if req.hf_token: if req.hf_token:
env["HF_TOKEN"] = req.hf_token env["HF_TOKEN"] = req.hf_token
job_id = jobengine.start_job(args, f"download {req.repo}", env=env) job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
on_done=_apply_role if role else None)
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"]) jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path, return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path,
"total_bytes": info["total_bytes"], "files": len(info["files"])} "total_bytes": info["total_bytes"], "files": len(info["files"])}
@@ -161,9 +179,18 @@ def recommend_role(role: str) -> dict:
@router.post("/models/{model_id}/role") @router.post("/models/{model_id}/role")
def set_model_role(model_id: str, body: RoleReq) -> dict: def set_model_role(model_id: str, body: RoleReq) -> dict:
new_role = (body.role or "").strip().lower()
# Guard: Hält das Modell einen LEBENSWICHTIGEN Alias (hermes/embed), darf die Rolle
# hier nicht weggeklickt werden — sonst verliert Lucy Hirn/Gedächtnis mit einem Klick.
# Weg: die geschützte Rolle zuerst einem ANDEREN Modell zuweisen (Alias zieht um).
prot = llamaswap.protected_alias_of(model_id)
if prot and new_role != prot:
raise HTTPException(400,
f"Dieses Modell hält die lebenswichtige Rolle '{prot}'. Weise '{prot}' zuerst "
f"einem anderen Modell zu — danach lässt sich die Rolle hier ändern.")
# Das Agent-Hirn (Rolle 'hermes') braucht den warm-bewussten Flow (Alias + brains-Gruppe + # Das Agent-Hirn (Rolle 'hermes') braucht den warm-bewussten Flow (Alias + brains-Gruppe +
# ttl 0 + Hermes config.default + Gateway-Restart) — Single Source of Truth UI ↔ Hermes. # ttl 0 + Hermes config.default + Gateway-Restart) — Single Source of Truth UI ↔ Hermes.
if (body.role or "").strip().lower() == "hermes": if new_role == "hermes":
from services.agent import set_agent_brain from services.agent import set_agent_brain
res = set_agent_brain(model_id) res = set_agent_brain(model_id)
if not res.get("ok"): if not res.get("ok"):
+7
View File
@@ -37,6 +37,13 @@ def status() -> dict:
return system_status() return system_status()
@router.get("/system/history")
def history(minutes: int = 60) -> dict:
"""Metrik-Verlauf (max. 24 h) für die Cockpit-Zeitachse — s. services/metrics_history."""
from services import metrics_history
return metrics_history.history(minutes)
def _mem0_reachable() -> bool: def _mem0_reachable() -> bool:
try: try:
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200 return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
+38 -7
View File
@@ -153,9 +153,26 @@ def model_id_from_path(model_path: str) -> str:
return name or "modell" return name or "modell"
# Lebenswichtige Aliase: hängen direkt an Lucys Denk- und Gedächtnis-Pfad. Sie dürfen
# beim Rollen-Umhängen NIE stillschweigend verloren gehen (Review 16.07.: Qwen3.6 hält
# live `hermes` UND `fast` — ein Rollen-Klick hätte beide gelöscht → Lucy tot).
PROTECTED_ALIASES = {"hermes", "embed"}
def protected_alias_of(model_id: str) -> str | None:
"""Hält dieses Modell gerade einen lebenswichtigen Alias? (für Guards in der API)"""
spec = (read_config().get("models") or {}).get(model_id)
if isinstance(spec, dict):
for a in spec.get("aliases") or []:
if str(a).lower() in PROTECTED_ALIASES:
return str(a).lower()
return None
def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None: def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
"""Rolle als eindeutigen llama-swap-`aliases`-Eintrag setzen (vorher bei allen """Rolle als llama-swap-`aliases`-Eintrag setzen (vorher bei allen anderen Modellen
anderen Modellen entfernen). role=None/leer entfernt den Alias.""" entfernen — deren übrige Aliase bleiben). role=None/leer entfernt die Rolle.
Lebenswichtige Aliase (PROTECTED_ALIASES) des Ziel-Modells bleiben IMMER erhalten."""
models = cfg.get("models") or {} models = cfg.get("models") or {}
role = (role or "").strip().lower() role = (role or "").strip().lower()
if role: if role:
@@ -169,8 +186,11 @@ def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
spec.pop("aliases", None) spec.pop("aliases", None)
spec = models.get(model_id) spec = models.get(model_id)
if isinstance(spec, dict): if isinstance(spec, dict):
if role and role != model_id.lower(): keep = [a for a in (spec.get("aliases") or [])
spec["aliases"] = [role] if str(a).lower() in PROTECTED_ALIASES and str(a).lower() != role]
new = keep + ([role] if role and role != model_id.lower() else [])
if new:
spec["aliases"] = new
else: else:
spec.pop("aliases", None) spec.pop("aliases", None)
@@ -214,9 +234,12 @@ def write_config(cfg: dict) -> None:
def register_model(model_path: str, role: str | None = None, ctx: int = 8192, def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
ttl: int | None = None, mmproj_path: str | None = None, ttl: int | None = None, mmproj_path: str | None = None,
jinja: bool = False) -> str: jinja: bool = False, set_alias: bool = True) -> str:
"""Ein GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias). Gibt die """Ein GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias). Gibt die
Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn).""" Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn).
set_alias=False: Rolle nur für die cmd-Flags nutzen, den Alias aber NICHT umhängen —
der Install-Flow setzt ihn erst NACH fertigem Download (sonst zeigt die Rolle
minutenlang auf eine Datei, die noch gar nicht existiert)."""
cfg = read_config() cfg = read_config()
model_id = model_id_from_path(model_path) model_id = model_id_from_path(model_path)
cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx)) cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx))
@@ -242,10 +265,18 @@ def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
if "--spec-draft-model" not in cmd and "--model-draft" not in cmd: if "--spec-draft-model" not in cmd and "--model-draft" not in cmd:
cmd += spec_draft_flags(model_path) cmd += spec_draft_flags(model_path)
cfg.setdefault("models", {})[model_id] = { # Bestehende Aliase des Eintrags erhalten (Re-Install/Upgrade desselben Repos):
# die Rolle soll während des Downloads beim ALTEN Stand bleiben.
old_aliases = (cfg.get("models") or {}).get(model_id, {})
old_aliases = old_aliases.get("aliases") if isinstance(old_aliases, dict) else None
entry: dict = {
"cmd": LiteralScalarString(cmd + "\n"), "cmd": LiteralScalarString(cmd + "\n"),
"ttl": ttl if ttl is not None else DEFAULT_TTL, "ttl": ttl if ttl is not None else DEFAULT_TTL,
} }
if old_aliases:
entry["aliases"] = old_aliases
cfg.setdefault("models", {})[model_id] = entry
if set_alias:
set_role_alias(cfg, model_id, role) set_role_alias(cfg, model_id, role)
write_config(cfg) write_config(cfg)
return model_id return model_id
+128
View File
@@ -0,0 +1,128 @@
"""24-h-Metrik-Verlauf für die Cockpit-Zeitachse (User-Wunsch 16.07.: „WANN war Last?").
Ein leichter Sammler (Lifespan-Task in app.py) legt alle SAMPLE_S Sekunden einen
kompakten Punkt in einen Ringpuffer: CPU/RAM/GPU/Disk in Prozent + die Token-
GESAMTZÄHLER (das Frontend rechnet Raten aus den Deltas). Der Puffer hält exakt
24 h — Älteres fällt automatisch raus (deque maxlen), nichts wächst unbegrenzt.
Periodisch wird auf Platte persistiert (übersteht MC2-Restarts/Deploys); beim
Laden fliegt alles raus, was älter als 24 h ist.
Bewusst NICHT im Steward: die Historie ist reine UI-Ware, und ein paar Sekunden
Lücke pro Deploy sind egal.
"""
import asyncio
import json
import logging
import os
import time
from collections import deque
from config import MODELS_DIR
log = logging.getLogger(__name__)
SAMPLE_S = 10 # Abtast-Takt
RETENTION_S = 24 * 3600 # 24 h — danach löschen und neu bauen (Ringpuffer)
MAXLEN = RETENTION_S // SAMPLE_S # 8640 Punkte
FLUSH_S = 300 # höchstens alle 5 min auf Platte
MAX_RETURN_POINTS = 300 # Endpoint downsampled auf ~diese Punktzahl
HISTORY_PATH = MODELS_DIR / "mc2-metrics-history.json"
# Punkt = [t, cpu, ram, gpu, disk, tp, tc] (t = Epoch-Sekunden; tp/tc = Token-Totale)
_points: deque = deque(maxlen=MAXLEN)
_loaded = False
_last_flush = 0.0
def _load() -> None:
global _loaded
if _loaded:
return
_loaded = True
try:
raw = json.loads(HISTORY_PATH.read_text(encoding="utf-8"))
cutoff = time.time() - RETENTION_S
for p in raw if isinstance(raw, list) else []:
if isinstance(p, list) and len(p) == 7 and isinstance(p[0], (int, float)) and p[0] >= cutoff:
_points.append(p)
if _points:
log.info("metrics_history: %d Punkte aus %s geladen", len(_points), HISTORY_PATH)
except FileNotFoundError:
pass
except Exception: # noqa: BLE001 — kaputte Datei = leer starten, nie crashen
log.warning("metrics_history: %s nicht lesbar — starte leer", HISTORY_PATH, exc_info=True)
def _flush() -> None:
global _last_flush
_last_flush = time.time()
try:
tmp = HISTORY_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(list(_points), separators=(",", ":")), encoding="utf-8")
tmp.replace(HISTORY_PATH)
except OSError:
log.warning("metrics_history: %s nicht schreibbar", HISTORY_PATH, exc_info=True)
def _sample() -> None:
import psutil
from services.system import _gpu_sysfs
from services.token_stats import get_stats
vm = psutil.virtual_memory()
disk = None
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
disk = du.percent
except Exception: # noqa: BLE001
pass
gpu = None
try:
g = _gpu_sysfs()
gpu = g.get("busy_percent") if g else None
except Exception: # noqa: BLE001
pass
tp = tc = None
try:
ts = get_stats()
tp, tc = ts.get("prompt_tokens"), ts.get("completion_tokens")
except Exception: # noqa: BLE001
pass
# interval=None: nicht-blockierende CPU-Messung seit dem letzten Aufruf (10 s her — ideal).
_points.append([int(time.time()), psutil.cpu_percent(interval=None), vm.percent, gpu, disk, tp, tc])
async def sampler_loop() -> None:
"""Dauer-Sammler fürs App-Lifespan. Fehler eines Ticks reißen die Schleife nie."""
_load()
while True:
try:
await asyncio.to_thread(_sample)
except Exception: # noqa: BLE001
log.debug("metrics_history: Sample fehlgeschlagen", exc_info=True)
if time.time() - _last_flush >= FLUSH_S:
try:
await asyncio.to_thread(_flush)
except Exception: # noqa: BLE001
pass
await asyncio.sleep(SAMPLE_S)
def history(minutes: int = 60) -> dict:
"""Punkte der letzten N Minuten, auf ≤ MAX_RETURN_POINTS ausgedünnt (Stride)."""
_load()
minutes = max(1, min(int(minutes), RETENTION_S // 60))
cutoff = time.time() - minutes * 60
pts = [p for p in _points if p[0] >= cutoff]
stride = max(1, len(pts) // MAX_RETURN_POINTS)
pts = pts[::stride]
return {
"sample_s": SAMPLE_S * stride,
"points": [
{"t": p[0], "cpu": p[1], "ram": p[2], "gpu": p[3], "disk": p[4], "tp": p[5], "tc": p[6]}
for p in pts
],
}
@@ -1,4 +1,4 @@
import{c as A,Y as P,l as B,b as M,u as D,r as z,j as e,Z as T,e as a,$ as g,a0 as b,a1 as W,x as f,a2 as j,W as G,X as I,C as N,g as y,q as i}from"./index-CQgUnd1p.js";/** import{c as A,Y as P,l as B,b as M,u as D,r as z,j as e,Z as T,e as a,$ as g,a0 as b,a1 as W,x as f,a2 as j,W as G,X as I,C as N,g as y,q as i}from"./index-BJUCwF6N.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,aa as Y,ab as _,u as q,b as Q,r as u,j as e,a3 as ee,e as C,L as N,C as U,U as J,ac as te,V as O,X as z,D as M,M as R,ad as re,T as K,g as E,ae as se,q as Z}from"./index-CQgUnd1p.js";import{L as ae}from"./lightbulb-Btbex65P.js";import{S as G}from"./send-COuI40dq.js";/** import{c as I,aa as Y,ab as _,u as q,b as Q,r as u,j as e,a3 as ee,e as C,L as N,C as U,U as J,ac as te,V as O,X as z,D as M,M as R,ad as re,T as K,g as E,ae as se,q as Z}from"./index-BJUCwF6N.js";import{L as ae}from"./lightbulb-Bl7otbNF.js";import{S as G}from"./send-CLnv-s74.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 m,af as S,r as c,j as e,ag as z,L as g,U as M,ah as C,ae as D,ai as A,aj as B,ak as Z,e as E,al as L,u as R,b as q,g as k,q as T}from"./index-CQgUnd1p.js";import{R as j}from"./rotate-ccw-ChVZWN8g.js";/** import{c as m,af as S,r as c,j as e,ag as z,L as g,U as M,ah as C,ae as D,ai as A,aj as B,ak as Z,e as E,al as L,u as R,b as q,g as k,q as T}from"./index-BJUCwF6N.js";import{R as j}from"./rotate-ccw-sjUuOvXz.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
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as h,am as b,r as i,j as e,L as g,an as f,a2 as j,U as k,e as o,D as N,M as v,N as w,g as y}from"./index-CQgUnd1p.js";import{B as S}from"./book-open-BFFOe9re.js";import{C as z}from"./circle-x-DKuGYlQP.js";/** import{c as h,am as b,r as i,j as e,L as g,an as f,a2 as j,U as k,e as o,D as N,M as v,N as w,g as y}from"./index-BJUCwF6N.js";import{B as S}from"./book-open-DSKbx2re.js";import{C as z}from"./circle-x-CXUY3WgN.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,x as C,a6 as z,a7 as k,a8 as y,a0 as v,W as L,a9 as G,a2 as T,e as f,U as D}from"./index-CQgUnd1p.js";import{B as w}from"./book-open-BFFOe9re.js";import{a as N,b as B,L as P,C as E,Z as R}from"./zap-Df-ORHkh.js";import{L as I}from"./lightbulb-Btbex65P.js";/** import{c as p,j as e,r as M,x as C,a6 as z,a7 as k,a8 as y,a0 as v,W as L,a9 as G,a2 as T,e as f,U as D}from"./index-BJUCwF6N.js";import{B as w}from"./book-open-DSKbx2re.js";import{a as N,b as B,L as P,C as E,Z as R}from"./zap-C9k94tAM.js";import{L as I}from"./lightbulb-Bl7otbNF.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{Y as b,u as f,$ as h,r as d,j as e,e as c,Z as g,T as p,a3 as j,a4 as N,g as v,a5 as w,q as x}from"./index-CQgUnd1p.js";function y(){const{data:s}=b(),m=f(),r=s!=null&&s.box_console_url?h(s.box_console_url):void 0,a=s==null?void 0:s.box_console_reachable,[l,o]=d.useState(!1),[i,n]=d.useState("");async function u(){o(!0),n("");try{const t=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})});n(t.ok?"Neu gestartet — einen Moment, dann lädt das Terminal.":`Fehlgeschlagen: ${t.err||"Unbekannter Fehler"}`),w(m,x.agentStatus,x.services)}catch(t){n(`Fehlgeschlagen: ${(t==null?void 0:t.message)||t}`)}finally{o(!1)}}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:c("h-2 w-2 rounded-full",a?"bg-emerald-500 animate-pulse":"bg-amber-500")}),a?"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(g,{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:[a===!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(p,{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 Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:u,disabled:l,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(j,{className:c("h-3.5 w-3.5",l&&"animate-spin")})," Dienst neu starten"]}),i&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:i})]}),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(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{y as KonsoleView}; import{Y as b,u as f,$ as h,r as d,j as e,e as c,Z as g,T as p,a3 as j,a4 as N,g as v,a5 as w,q as x}from"./index-BJUCwF6N.js";function y(){const{data:s}=b(),m=f(),r=s!=null&&s.box_console_url?h(s.box_console_url):void 0,a=s==null?void 0:s.box_console_reachable,[l,o]=d.useState(!1),[i,n]=d.useState("");async function u(){o(!0),n("");try{const t=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})});n(t.ok?"Neu gestartet — einen Moment, dann lädt das Terminal.":`Fehlgeschlagen: ${t.err||"Unbekannter Fehler"}`),w(m,x.agentStatus,x.services)}catch(t){n(`Fehlgeschlagen: ${(t==null?void 0:t.message)||t}`)}finally{o(!1)}}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:c("h-2 w-2 rounded-full",a?"bg-emerald-500 animate-pulse":"bg-amber-500")}),a?"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(g,{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:[a===!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(p,{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 Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:u,disabled:l,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(j,{className:c("h-3.5 w-3.5",l&&"animate-spin")})," Dienst neu starten"]}),i&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:i})]}),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(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{y as KonsoleView};
@@ -1,5 +1,5 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/GraphView-BOVrShvN.js","assets/index-CQgUnd1p.js","assets/index-BH760w1h.css"])))=>i.map(i=>d[i]); const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/GraphView-BQzGpC9Q.js","assets/index-BJUCwF6N.js","assets/index-DEL5L7Ku.css"])))=>i.map(i=>d[i]);
var je=Object.defineProperty;var ke=(d,i,c)=>i in d?je(d,i,{enumerable:!0,configurable:!0,writable:!0,value:c}):d[i]=c;var J=(d,i,c)=>ke(d,typeof i!="symbol"?i+"":i,c);import{c as h,r as s,j as e,u as ve,b as Ne,O as W,Q as Se,C as X,R as Ce,S as Ee,P as Me,U as O,e as x,V as ze,W as De,X as Z,A as Y,_ as Oe,g as j}from"./index-CQgUnd1p.js";import{C as ee}from"./copy-hJYMa3at.js";import{S as _e}from"./send-COuI40dq.js";import{B as Ge}from"./book-open-BFFOe9re.js";/** var je=Object.defineProperty;var ke=(d,i,c)=>i in d?je(d,i,{enumerable:!0,configurable:!0,writable:!0,value:c}):d[i]=c;var J=(d,i,c)=>ke(d,typeof i!="symbol"?i+"":i,c);import{c as h,r as s,j as e,u as ve,b as Ne,O as W,Q as Se,C as X,R as Ce,S as Ee,P as Me,U as O,e as x,V as ze,W as De,X as Z,A as Y,_ as Oe,g as j}from"./index-BJUCwF6N.js";import{C as ee}from"./copy-s7zFTglG.js";import{S as _e}from"./send-CLnv-s74.js";import{B as Ge}from"./book-open-DSKbx2re.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 je=Object.defineProperty;var ke=(d,i,c)=>i in d?je(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 Pe=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 te extends s.Component{constructor(){super(...arguments);J(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 se=s.lazy(()=>Oe(()=>import("./GraphView-BOVrShvN.js"),__vite__mapDeps([0,1,2])).then(d=>({default:d.GraphView}))),k=["identity","knowledge","rules","events"],v=new Set(["auto","agent","hermes"]),N={identity:{label:"Identität",icon:Pe,bg:"bg-cyan-500/10",text:"text-cyan-400"},knowledge:{label:"Wissen",icon:De,bg:"bg-indigo-500/10",text:"text-indigo-400"},rules:{label:"Regeln",icon:Le,bg:"bg-violet-500/10",text:"text-violet-400"},events:{label:"Ereignisse",icon:ze,bg:"bg-amber-500/10",text:"text-amber-400"}},ne={label:"Gedächtnis",icon:Ge,text:"text-muted-foreground"},Re={identity:"border-l-cyan-500/80",knowledge:"border-l-indigo-500/80",rules:"border-l-violet-500/80",events:"border-l-amber-500/80"},_={identity:"#00f5ff",knowledge:"#3b82f6",rules:"#d946ef",events:"#fbbf24"},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 Pe=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 te extends s.Component{constructor(){super(...arguments);J(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 se=s.lazy(()=>Oe(()=>import("./GraphView-BQzGpC9Q.js"),__vite__mapDeps([0,1,2])).then(d=>({default:d.GraphView}))),k=["identity","knowledge","rules","events"],v=new Set(["auto","agent","hermes"]),N={identity:{label:"Identität",icon:Pe,bg:"bg-cyan-500/10",text:"text-cyan-400"},knowledge:{label:"Wissen",icon:De,bg:"bg-indigo-500/10",text:"text-indigo-400"},rules:{label:"Regeln",icon:Le,bg:"bg-violet-500/10",text:"text-violet-400"},events:{label:"Ereignisse",icon:ze,bg:"bg-amber-500/10",text:"text-amber-400"}},ne={label:"Gedächtnis",icon:Ge,text:"text-muted-foreground"},Re={identity:"border-l-cyan-500/80",knowledge:"border-l-indigo-500/80",rules:"border-l-violet-500/80",events:"border-l-amber-500/80"},_={identity:"#00f5ff",knowledge:"#3b82f6",rules:"#d946ef",events:"#fbbf24"},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,
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{ao as y,r as x,g as L,j as e,L as v,S,ap as W,U as C,e as w,aq as E}from"./index-CQgUnd1p.js";import{F as M}from"./folder-open-vcdogQ3l.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{ao as y,r as x,g as L,j as e,L as v,S,ap as W,U as C,e as w,aq as E}from"./index-BJUCwF6N.js";import{F as M}from"./folder-open-oIQVVo05.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-CQgUnd1p.js";/** import{c as a}from"./index-BJUCwF6N.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-CQgUnd1p.js";/** import{c}from"./index-BJUCwF6N.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-CQgUnd1p.js";/** import{c}from"./index-BJUCwF6N.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-CQgUnd1p.js";/** import{c as a}from"./index-BJUCwF6N.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
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as t}from"./index-CQgUnd1p.js";/** import{c as t}from"./index-BJUCwF6N.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 t}from"./index-CQgUnd1p.js";/** import{c as t}from"./index-BJUCwF6N.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-CQgUnd1p.js";/** import{c as a}from"./index-BJUCwF6N.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-CQgUnd1p.js";/** import{c as a}from"./index-BJUCwF6N.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.
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-CQgUnd1p.js"></script> <script type="module" crossorigin src="/assets/index-BJUCwF6N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BH760w1h.css"> <link rel="stylesheet" crossorigin href="/assets/index-DEL5L7Ku.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -18,10 +18,15 @@ function fmt(v: number, unit: string): string {
return `${n}${unit}` return `${n}${unit}`
} }
function ChartTooltip({ active, payload, unit }: any) { function ChartTooltip({ active, payload, unit, label }: any) {
if (!active || !payload?.length) return null if (!active || !payload?.length) return null
return ( return (
<div className="rounded-lg border border-border/70 bg-popover/95 px-3 py-2 shadow-xl backdrop-blur"> <div className="rounded-lg border border-border/70 bg-popover/95 px-3 py-2 shadow-xl backdrop-blur">
{Number.isFinite(label) && (
<div className="mb-1 border-b border-border/40 pb-1 font-mono text-[10px] text-muted-foreground/80">
{new Date(label).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
</div>
)}
<div className="space-y-1"> <div className="space-y-1">
{payload.map((p: any) => ( {payload.map((p: any) => (
<div key={p.dataKey} className="flex items-center gap-2 text-[11px] font-mono"> <div key={p.dataKey} className="flex items-center gap-2 text-[11px] font-mono">
@@ -36,15 +41,17 @@ function ChartTooltip({ active, payload, unit }: any) {
} }
/** Wiederverwendbarer Live-Verlaufsgraph (Recharts Area, glatte Splines, Gradient-Fill, /** Wiederverwendbarer Live-Verlaufsgraph (Recharts Area, glatte Splines, Gradient-Fill,
* Hover-Tooltip). yMode='percent' → 0..100 in 25er-Schritten; 'auto' → dynamisch (nice). */ * Hover-Tooltip). yMode='percent' → 0..100 in 25er-Schritten; 'auto' → dynamisch (nice).
* showTime=true blendet die Zeitachse ein (t in ms; Label-Format folgt der Spannweite). */
export function LiveAreaChart({ export function LiveAreaChart({
data, series, unit = "%", yMode = "percent", height = 176, data, series, unit = "%", yMode = "percent", height = 176, showTime = false,
}: { }: {
data: any[] data: any[]
series: ChartSeries[] series: ChartSeries[]
unit?: string unit?: string
yMode?: "percent" | "auto" yMode?: "percent" | "auto"
height?: number height?: number
showTime?: boolean
}) { }) {
const peak = data.reduce( const peak = data.reduce(
(m, row) => series.reduce((mm, s) => Math.max(mm, Number(row[s.key]) || 0), m), 0 (m, row) => series.reduce((mm, s) => Math.max(mm, Number(row[s.key]) || 0), m), 0
@@ -53,6 +60,13 @@ export function LiveAreaChart({
? Math.min(100, Math.max(25, Math.ceil((peak * 1.2) / 25) * 25)) ? Math.min(100, Math.max(25, Math.ceil((peak * 1.2) / 25) * 25))
: Math.max(niceCeil(peak * 1.15), 10) : Math.max(niceCeil(peak * 1.15), 10)
// Kurze Fenster (Live ≈ 2 min) brauchen Sekunden, lange (1 h / 24 h) nur HH:MM.
const spanMs = data.length > 1 ? Number(data[data.length - 1]?.t) - Number(data[0]?.t) : 0
const fmtTime = (v: number) =>
new Date(v).toLocaleTimeString("de-DE",
spanMs <= 10 * 60_000 ? { hour: "2-digit", minute: "2-digit", second: "2-digit" }
: { hour: "2-digit", minute: "2-digit" })
return ( return (
<div style={{ height }} className="w-full"> <div style={{ height }} className="w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
@@ -66,7 +80,15 @@ export function LiveAreaChart({
))} ))}
</defs> </defs>
<CartesianGrid vertical={false} stroke={GRID} /> <CartesianGrid vertical={false} stroke={GRID} />
{showTime ? (
<XAxis
dataKey="t" type="number" domain={["dataMin", "dataMax"]}
tickFormatter={fmtTime} tickCount={5} minTickGap={40} interval="preserveStartEnd"
axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: AXIS }} height={18}
/>
) : (
<XAxis dataKey="t" hide /> <XAxis dataKey="t" hide />
)}
<YAxis <YAxis
domain={[0, yMax]} ticks={[0, yMax / 2, yMax]} tickFormatter={(v) => fmt(v, unit)} domain={[0, yMax]} ticks={[0, yMax / 2, yMax]} tickFormatter={(v) => fmt(v, unit)}
width={42} axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: AXIS }} width={42} axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: AXIS }}
@@ -0,0 +1,27 @@
import { cn } from "@/lib/utils"
// Bereichs-Schalter der Leistungs-Karten: Live (2-min-Browser-Store) vs. 1h/24h
// (Backend-Ringpuffer, /api/system/history). Bewusst winzig — kein Chart-Gebirge.
export type ChartRange = "live" | "1h" | "24h"
export const RANGE_MINUTES: Record<Exclude<ChartRange, "live">, number> = { "1h": 60, "24h": 1440 }
const LABELS: Record<ChartRange, string> = { live: "Live", "1h": "1 h", "24h": "24 h" }
export function RangeSwitch({ value, onChange }: { value: ChartRange; onChange: (r: ChartRange) => void }) {
return (
<div className="flex rounded-lg border border-border/40 bg-background/30 p-0.5">
{(Object.keys(LABELS) as ChartRange[]).map((r) => (
<button
key={r}
onClick={() => onChange(r)}
className={cn(
"rounded-md px-2 py-0.5 text-[9px] font-bold uppercase tracking-wide transition-all cursor-pointer",
value === r ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{LABELS[r]}
</button>
))}
</div>
)
}
@@ -1,7 +1,10 @@
import { useMemo, useState } from "react"
import { Cpu } from "lucide-react" import { Cpu } from "lucide-react"
import { useSystemHistory } from "@/lib/useSystemHistory" import { useSystemHistory } from "@/lib/useSystemHistory"
import { useMetricHistory } from "@/lib/queries"
import { gb } from "@/lib/format" import { gb } from "@/lib/format"
import { LiveAreaChart, type ChartSeries } from "./LiveAreaChart" import { LiveAreaChart, type ChartSeries } from "./LiveAreaChart"
import { RangeSwitch, RANGE_MINUTES, type ChartRange } from "./RangeSwitch"
const SERIES: ChartSeries[] = [ const SERIES: ChartSeries[] = [
{ key: "cpu", label: "CPU", color: "#2dd4bf" }, { key: "cpu", label: "CPU", color: "#2dd4bf" },
@@ -12,6 +15,14 @@ const SERIES: ChartSeries[] = [
export function SystemStatusCard() { export function SystemStatusCard() {
const { sys, hist } = useSystemHistory() const { sys, hist } = useSystemHistory()
const [range, setRange] = useState<ChartRange>("live")
const histQ = useMetricHistory(range === "live" ? 60 : RANGE_MINUTES[range], range !== "live")
// Live = 3-s-Browser-Store (~2 min); 1h/24h = Backend-Ringpuffer mit echten Zeitstempeln.
const chartData = useMemo(() => {
if (range === "live") return hist
return (histQ.data?.points ?? []).map((p) => ({ t: p.t * 1000, cpu: p.cpu, ram: p.ram, gpu: p.gpu, disk: p.disk }))
}, [range, hist, histQ.data])
const hasGpu = !!(sys?.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null) const hasGpu = !!(sys?.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null)
const activeSeries = SERIES.filter((s) => s.key !== "gpu" || hasGpu) const activeSeries = SERIES.filter((s) => s.key !== "gpu" || hasGpu)
@@ -33,9 +44,12 @@ export function SystemStatusCard() {
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
<Cpu className="h-4.5 w-4.5 text-primary" /> <Cpu className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">System-Status</h2> <h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">System-Status</h2>
{range === "live" && (
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70"> <span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
</span> </span>
)}
<div className="ml-auto"><RangeSwitch value={range} onChange={setRange} /></div>
</div> </div>
{sys ? ( {sys ? (
@@ -50,7 +64,14 @@ export function SystemStatusCard() {
</div> </div>
))} ))}
</div> </div>
<LiveAreaChart data={hist} series={activeSeries} unit="%" yMode="percent" height={176} /> {range !== "live" && chartData.length === 0 && (
<div className="flex h-44 items-center justify-center text-xs text-muted-foreground">
{histQ.isLoading ? "Verlauf wird geladen …" : "Noch kein Verlauf — der Sammler baut die Historie gerade erst auf."}
</div>
)}
{(range === "live" || chartData.length > 0) && (
<LiveAreaChart data={chartData} series={activeSeries} unit="%" yMode="percent" height={176} showTime />
)}
</> </>
) : ( ) : (
<div className="flex h-44 items-center justify-center text-xs text-muted-foreground">Lade Systemdaten</div> <div className="flex h-44 items-center justify-center text-xs text-muted-foreground">Lade Systemdaten</div>
@@ -1,7 +1,9 @@
import { useMemo, useState } from "react"
import { Activity } from "lucide-react" import { Activity } from "lucide-react"
import { useTokenStats } from "@/lib/queries" import { useTokenStats, useMetricHistory } from "@/lib/queries"
import { useTokHistory } from "@/lib/metricsStore" import { useTokHistory } from "@/lib/metricsStore"
import { LiveAreaChart, type ChartSeries } from "./LiveAreaChart" import { LiveAreaChart, type ChartSeries } from "./LiveAreaChart"
import { RangeSwitch, RANGE_MINUTES, type ChartRange } from "./RangeSwitch"
const SERIES: ChartSeries[] = [ const SERIES: ChartSeries[] = [
{ key: "prompt", label: "Prompt", color: "#f59e0b" }, { key: "prompt", label: "Prompt", color: "#f59e0b" },
@@ -10,7 +12,27 @@ const SERIES: ChartSeries[] = [
export function TokenPerformanceCard() { export function TokenPerformanceCard() {
const { data: ts } = useTokenStats() const { data: ts } = useTokenStats()
const hist = useTokHistory() // Durchsatz-Verlauf aus dem modul-globalen Store const hist = useTokHistory() // Live-Durchsatz aus dem modul-globalen Store (~2 min)
const [range, setRange] = useState<ChartRange>("live")
const histQ = useMetricHistory(range === "live" ? 60 : RANGE_MINUTES[range], range !== "live")
// 1h/24h: Raten aus den Deltas der Gesamtzähler (tok/s zwischen zwei Sample-Punkten).
const chartData = useMemo(() => {
if (range === "live") return hist
const pts = histQ.data?.points ?? []
const out: { t: number; prompt: number; completion: number }[] = []
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1], b = pts[i]
if (a.tp == null || b.tp == null || a.tc == null || b.tc == null) continue
const dt = Math.max(b.t - a.t, 1)
out.push({
t: b.t * 1000,
prompt: Math.max(0, (b.tp - a.tp) / dt),
completion: Math.max(0, (b.tc - a.tc) / dt),
})
}
return out
}, [range, hist, histQ.data])
const last = hist[hist.length - 1] const last = hist[hist.length - 1]
const curRate = last ? Math.round(last.prompt + last.completion) : 0 const curRate = last ? Math.round(last.prompt + last.completion) : 0
@@ -22,9 +44,11 @@ export function TokenPerformanceCard() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Activity className="h-4.5 w-4.5 text-primary" /> <Activity className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Token-Durchsatz</h2> <h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Token-Durchsatz</h2>
{range === "live" && (
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70"> <span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
</span> </span>
)}
</div> </div>
{ts && ( {ts && (
<div className="mt-2 flex items-baseline gap-2"> <div className="mt-2 flex items-baseline gap-2">
@@ -45,7 +69,9 @@ export function TokenPerformanceCard() {
</div> </div>
)} )}
</div> </div>
<div className="flex shrink-0 flex-col items-end gap-1.5 pt-1"> <div className="flex shrink-0 flex-col items-end gap-2 pt-1">
<RangeSwitch value={range} onChange={setRange} />
<div className="flex flex-col items-end gap-1.5">
{SERIES.map((s) => ( {SERIES.map((s) => (
<div key={s.key} className="flex items-center gap-1.5"> <div key={s.key} className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full" style={{ background: s.color }} /> <span className="h-2 w-2 rounded-full" style={{ background: s.color }} />
@@ -57,9 +83,14 @@ export function TokenPerformanceCard() {
))} ))}
</div> </div>
</div> </div>
</div>
{ts ? ( {range !== "live" && chartData.length === 0 ? (
<LiveAreaChart data={hist} series={SERIES} unit=" tok/s" yMode="auto" height={150} /> <div className="flex h-[150px] items-center justify-center text-xs text-muted-foreground">
{histQ.isLoading ? "Verlauf wird geladen …" : "Noch kein Verlauf — der Sammler baut die Historie gerade erst auf."}
</div>
) : ts ? (
<LiveAreaChart data={chartData} series={SERIES} unit=" tok/s" yMode="auto" height={150} showTime />
) : ( ) : (
<div className="flex h-[150px] items-center justify-center text-xs text-muted-foreground">Lade Durchsatz</div> <div className="flex h-[150px] items-center justify-center text-xs text-muted-foreground">Lade Durchsatz</div>
)} )}
+16
View File
@@ -417,6 +417,22 @@ export interface Health {
brain?: { role: string; model: string | null; ready: boolean } brain?: { role: string; model: string | null; ready: boolean }
} }
// 24-h-Metrik-Verlauf (GET /api/system/history?minutes=N) — Basis der Cockpit-Zeitachse.
// tp/tc sind Token-GESAMTZÄHLER; Raten rechnet das Frontend aus den Deltas.
export interface HistoryPoint {
t: number // Epoch-Sekunden
cpu: number | null
ram: number | null
gpu: number | null
disk: number | null
tp: number | null
tc: number | null
}
export interface HistoryResp {
sample_s: number
points: HistoryPoint[]
}
export interface TokenStats { export interface TokenStats {
prompt_tokens: number prompt_tokens: number
completion_tokens: number completion_tokens: number
+11
View File
@@ -17,6 +17,7 @@ import {
type GroupsResp, type GroupsResp,
type HermesBrainResp, type HermesBrainResp,
type Health, type Health,
type HistoryResp,
type IdeenLogResp, type IdeenLogResp,
type IdeenResp, type IdeenResp,
type Job, type Job,
@@ -169,6 +170,16 @@ export const useHealth = () =>
export const useSystemStatus = (refetchInterval: number = TAKT.graph) => export const useSystemStatus = (refetchInterval: number = TAKT.graph) =>
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"), refetchInterval }) useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"), refetchInterval })
// Metrik-Verlauf (Cockpit-Zeitachse): 1h/24h aus dem Backend-Ringpuffer. Nur aktiv,
// wenn eine Karte gerade diesen Bereich zeigt (enabled) — Live bleibt beim 3s-Store.
export const useMetricHistory = (minutes: number, enabled = true) =>
useQuery({
queryKey: ["metrics-history", minutes],
queryFn: () => api<HistoryResp>(`/api/system/history?minutes=${minutes}`),
enabled,
refetchInterval: 60_000,
})
export const useServices = (refetchInterval: number = TAKT.normal) => export const useServices = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.services, queryFn: () => api<ServicesResp>("/api/system/services"), refetchInterval }) useQuery({ queryKey: qk.services, queryFn: () => api<ServicesResp>("/api/system/services"), refetchInterval })
+36 -19
View File
@@ -72,8 +72,16 @@ function CodeWindow({
) )
} }
// Die Box-Adresse kennt der Browser schon — er ist ja über sie verbunden. Nur im
// Dev-Modus (localhost + Vite-Proxy) greift der bekannte LAN-Fallback.
function defaultHost(): string {
const h = window.location.hostname
if (h && h !== "localhost" && h !== "127.0.0.1") return h
return "192.168.178.151"
}
export function ConnectView() { export function ConnectView() {
const [host, setHost] = useState(localStorage.getItem("mc_host") || "192.168.178.151") const [host, setHost] = useState(localStorage.getItem("mc_host") || defaultHost())
const [mcpPath, setMcpPath] = useState(localStorage.getItem("mc_mcp_path") || "") const [mcpPath, setMcpPath] = useState(localStorage.getItem("mc_mcp_path") || "")
// Legacy-Tools (roher /v1-Zugang) leben im Ausklapper — Hermes Desktop ist der Hauptweg. // Legacy-Tools (roher /v1-Zugang) leben im Ausklapper — Hermes Desktop ist der Hauptweg.
const [active, setActive] = useState("kilo") const [active, setActive] = useState("kilo")
@@ -187,11 +195,12 @@ export function ConnectView() {
</p> </p>
</div> </div>
{/* Gemeinsame Variablen */} {/* Box-Adresse — vorbefüllt aus der Browser-URL; anfassen nur, wenn sich die Box-IP ändert. */}
<div className="grid gap-4 md:grid-cols-2 p-5 mc-card"> <div className="p-5 mc-card">
<div className="space-y-1.5"> <div className="space-y-1.5 max-w-md">
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5"> <label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<Globe className="h-3.5 w-3.5 text-primary" /> Box LAN IP-Adresse <Globe className="h-3.5 w-3.5 text-primary" /> Box LAN IP-Adresse
<span className="normal-case font-semibold tracking-normal text-muted-foreground/60"> vorbefüllt aus der Adresse dieser Seite</span>
</label> </label>
<input <input
value={host} value={host}
@@ -201,21 +210,9 @@ export function ConnectView() {
placeholder="z.B. 192.168.178.151" placeholder="z.B. 192.168.178.151"
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 font-mono text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground" className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 font-mono text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
/> />
</div> <p className="text-[10px] text-muted-foreground/70">
Wird in alle Snippets eingebacken. Ändern musst du sie nur, wenn die Box eine neue IP bekommt.
<div className="space-y-1.5"> </p>
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<FolderOpen className="h-3.5 w-3.5 text-violet-400" /> Lokaler MCP-Scriptpfad
<span className="text-violet-400/70 normal-case font-semibold tracking-normal">(nur Leitung 2)</span>
</label>
<input
value={mcpPath}
onChange={(e) => saveMcpPath(e.target.value)}
aria-label="Lokaler MCP-Scriptpfad"
spellCheck={false}
placeholder="z.B. F:\Coding Stuff\mission-control-2\mcp\mcp_memory.py"
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 font-mono text-xs outline-none focus:ring-1 focus:ring-violet-500/50 text-foreground"
/>
</div> </div>
</div> </div>
@@ -332,6 +329,26 @@ export function ConnectView() {
<span>{data.memory.note}</span> <span>{data.memory.note}</span>
</div> </div>
{/* Der Pfad wirkt NUR auf dieses Snippet — deshalb wohnt das Feld hier,
nicht mehr prominent in der Kopfzeile („Wann brauche ich das?"). */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<FolderOpen className="h-3.5 w-3.5 text-violet-400" /> Wo liegt mcp_memory.py auf DIESEM PC?
</label>
<input
value={mcpPath}
onChange={(e) => saveMcpPath(e.target.value)}
aria-label="Lokaler MCP-Scriptpfad"
spellCheck={false}
placeholder="z.B. F:\Coding Stuff\mission-control-2\mcp\mcp_memory.py"
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 font-mono text-xs outline-none focus:ring-1 focus:ring-violet-500/50 text-foreground"
/>
<p className="text-[10px] text-muted-foreground/70">
Nur nötig, wenn du das Gedächtnis in einem PC-Tool anschließt das Script läuft lokal
und spricht mit der Box. Der Pfad landet direkt im Snippet unten.
</p>
</div>
<CodeWindow <CodeWindow
tool={data.memory} tool={data.memory}
fileName="mcp.json" fileName="mcp.json"
@@ -259,14 +259,18 @@ function Detail({
{lockUnload ? "🔒 bleibt geladen" : warm ? "Entladen" : "Ins Warm-Set laden"} {lockUnload ? "🔒 bleibt geladen" : warm ? "Entladen" : "Ins Warm-Set laden"}
</button> </button>
{/* Rolle wählen */} {/* Rolle wählen — bei lebenswichtigen Rollen (Hirn/Gedächtnis) gesperrt: die Rolle
wandert nur, indem man sie einem ANDEREN Modell zuweist (Backend erzwingt das auch). */}
<div className="rounded-xl border border-border/30 bg-background/20 p-3"> <div className="rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Rolle</div> <div className="mb-2 flex items-center justify-between text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<span>Rolle</span>
{protectedRole && <span className="normal-case font-medium tracking-normal text-muted-foreground/70">🔒 lebenswichtig zum Tausch die Rolle woanders zuweisen</span>}
</div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<RoleChip active={!m.role} onClick={() => onRole("")} dot="bg-slate-400" label="keine" /> <RoleChip active={!m.role} onClick={() => onRole("")} dot="bg-slate-400" label="keine" disabled={protectedRole} />
{ROLE_META.map((r) => ( {ROLE_META.map((r) => (
<RoleChip key={r.role} active={m.role === r.role} onClick={() => onRole(r.role)} <RoleChip key={r.role} active={m.role === r.role} onClick={() => onRole(r.role)}
dot={roleBarColor(r.role).dot} label={r.short} /> dot={roleBarColor(r.role).dot} label={r.short} disabled={protectedRole && m.role !== r.role} />
))} ))}
</div> </div>
</div> </div>
@@ -322,11 +326,16 @@ function Detail({
) )
} }
function RoleChip({ active, onClick, dot, label }: { active: boolean; onClick: () => void; dot: string; label: string }) { function RoleChip({ active, onClick, dot, label, disabled }: {
active: boolean; onClick: () => void; dot: string; label: string; disabled?: boolean
}) {
return ( return (
<button onClick={onClick} <button onClick={onClick} disabled={disabled}
className={cn("flex items-center gap-1.5 rounded-lg border px-2 py-1 text-[10px] font-semibold transition-all cursor-pointer", title={disabled ? "Gesperrt: dieses Modell hält eine lebenswichtige Rolle." : undefined}
active ? "border-primary/60 bg-primary/15 text-foreground" : "border-border/40 bg-background/20 text-muted-foreground hover:border-primary/30")}> className={cn("flex items-center gap-1.5 rounded-lg border px-2 py-1 text-[10px] font-semibold transition-all",
disabled ? "cursor-not-allowed opacity-40 border-border/40 bg-background/20 text-muted-foreground"
: active ? "cursor-pointer border-primary/60 bg-primary/15 text-foreground"
: "cursor-pointer border-border/40 bg-background/20 text-muted-foreground hover:border-primary/30")}>
<span className={cn("h-2 w-2 rounded-sm", dot)} /> <span className={cn("h-2 w-2 rounded-sm", dot)} />
{label} {label}
{active && <Check className="h-3 w-3 text-primary" />} {active && <Check className="h-3 w-3 text-primary" />}